1import * as Neon from "@distilled.cloud/neon";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../AdoptPolicy.ts";
6import { isResolved } from "../Diff.ts";
7import * as Provider from "../Provider.ts";
8import { Resource } from "../Resource.ts";
9import type { Providers } from "./Providers.ts";
10
11export interface CustomDomainProps {
12 /** Target Function. Domain ownership remains independent of Function configuration. */ function: {
13 projectId: string;
14 branchId: string;
15 slug: string;
16 url: string;
17 };
18 /** Public DNS hostname. Register the returned target with a DNS-only CNAME. */ hostname: string;
19}
20export interface CustomDomainAttributes {
21 /** Owning project. */ projectId: string;
22 /** Owning branch. */ branchId: string;
23 /** Target Function slug. */ slug: string;
24 /** Normalized custom hostname. */ hostname: string;
25 /** DNS-only CNAME target; returned before DNS activation to avoid dependency cycles. */ cnameTarget: string;
26 /** Registration state. This is not an independent HTTPS probe. */ status:
27 | string
28 | undefined;
29 /** DNS/CAA check state. */ dnsStatus: string | undefined;
30 /** Internal routing state. */ bindingStatus: string | undefined;
31 /** Machine-readable status explanation. */ statusReason: string | undefined;
32 /** Custom HTTPS URL; not proof that the certificate is ready. */ url: string;
33 /** Native Function URL. */ nativeUrl: string;
34}
35export interface CustomDomain extends Resource<
36 "Neon.CustomDomain",
37 CustomDomainProps,
38 CustomDomainAttributes,
39 never,
40 Providers
41> {}
42
43/**
44 * Register a branch-local custom Function domain without waiting for DNS.
45 * A registration does not prove HTTPS works. Publish a DNS-only CNAME, then
46 * verify the custom URL separately. Domains are not inherited by child branches.
47 *
48 * ### Register a Domain
49 * **Example:** Obtain DNS configuration
50 * ```typescript
51 * const domain = yield* Neon.CustomDomain("Domain", { function: api, hostname: "api.example.com" });
52 * // Publish domain.cnameTarget as a DNS-only CNAME for domain.hostname.
53 * ```
54 *
55 * @resource
56 * @product Function
57 */
58export const CustomDomain = Resource<CustomDomain>("Neon.CustomDomain");
59export class FunctionDomainConflict extends Data.TaggedError(
60 "FunctionDomainConflict",
61)<{ hostname: string }> {}
62const normalize = (value: string) => value.toLowerCase().replace(/\.$/, "");
63const scopeOf = (fn: { projectId: string; branchId: string }) => ({
64 project_id: fn.projectId,
65 branch_id: fn.branchId,
66});
67const observe = Effect.fn(function* (
68 scope: { project_id: string; branch_id: string },
69 hostname: string,
70) {
71 const domains = yield* Neon.listProjectBranchCustomDomains
72 .items(scope)
73 .pipe(Stream.runCollect);
74 return domains.find((domain) => domain.domain === hostname);
75});
76const attrs = (
77 fn: { projectId: string; branchId: string; slug: string; url: string },
78 domain: Neon.CustomDomain,
79): CustomDomainAttributes => ({
80 projectId: fn.projectId,
81 branchId: fn.branchId,
82 slug: fn.slug,
83 hostname: domain.domain,
84 cnameTarget: domain.cname_target,
85 status: domain.status,
86 dnsStatus: domain.dns_status,
87 bindingStatus: domain.binding_status,
88 statusReason: domain.status_reason,
89 url: `https://${domain.domain}`,
90 nativeUrl: fn.url,
91});
92export const CustomDomainProvider = () =>
93 Provider.succeed(CustomDomain, {
94 stables: ["projectId", "branchId", "slug", "hostname", "url"],
95 list: Effect.fn(function* () {
96 const result: CustomDomainAttributes[] = [];
97 for (const project of yield* Neon.listProjects
98 .items({})
99 .pipe(Stream.runCollect)) {
100 for (const branch of yield* Neon.listProjectBranches
101 .items({ project_id: project.id })
102 .pipe(Stream.runCollect)) {
103 for (const domain of yield* Neon.listProjectBranchCustomDomains
104 .items({ project_id: project.id, branch_id: branch.id })
105 .pipe(Stream.runCollect)) {
106 result.push(
107 attrs(
108 {
109 projectId: project.id,
110 branchId: branch.id,
111 slug: domain.entity_id,
112 url: "",
113 },
114 domain,
115 ),
116 );
117 }
118 }
119 }
120 return result;
121 }),
122 diff: Effect.fn(function* ({ news, output }) {
123 if (!isResolved(news) || !output) return;
124 if (
125 normalize(news.hostname) !== output.hostname ||
126 news.function.projectId !== output.projectId ||
127 news.function.branchId !== output.branchId ||
128 news.function.slug !== output.slug
129 )
130 return { action: "replace", deleteFirst: true };
131 }),
132 read: Effect.fn(function* ({ olds, output }) {
133 if (!output && (!olds?.function || !olds.hostname)) return undefined;
134 const fn = output ? { ...output, url: output.nativeUrl } : olds!.function;
135 const domain = yield* observe(
136 scopeOf(fn),
137 output?.hostname ?? normalize(olds!.hostname),
138 );
139 if (!domain) return undefined;
140 const result = attrs(fn, domain);
141 return output ? result : Unowned(result);
142 }),
143 reconcile: Effect.fn(function* ({ news }) {
144 const scope = scopeOf(news.function);
145 const hostname = normalize(news.hostname);
146 let domain = yield* observe(scope, hostname);
147 if (!domain) {
148 yield* Neon.registerProjectBranchCustomDomain({
149 ...scope,
150 domain: hostname,
151 entity_type: "function",
152 entity_id: news.function.slug,
153 }).pipe(Effect.catchTag("Conflict", () => Effect.void));
154 domain = yield* observe(scope, hostname);
155 }
156 if (
157 !domain ||
158 domain.entity_type !== "function" ||
159 domain.entity_id !== news.function.slug
160 )
161 return yield* new FunctionDomainConflict({ hostname });
162 return attrs(news.function, domain);
163 }),
164 delete: Effect.fn(function* ({ output }) {
165 const scope = scopeOf(output);
166 const domain = yield* observe(scope, output.hostname);
167 if (!domain) return;
168 if (domain.entity_id !== output.slug || domain.entity_type !== "function")
169 return yield* new FunctionDomainConflict({ hostname: output.hostname });
170 yield* Neon.deleteProjectBranchCustomDomain({
171 ...scope,
172 domain: output.hostname,
173 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
174 }),
175 });