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