1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import { Project } from "@/Neon/Project";
5import { Function } from "@/Neon/Function";
6import { CustomDomain } from "@/Neon/CustomDomain";
7import { providers } from "@/Neon/Providers";
8import * as AlchemyOutput from "@/Output";
9import * as Test from "@/Test/Alchemy";
10import * as dns from "@distilled.cloud/cloudflare/dns";
11import * as Api from "@distilled.cloud/neon";
12import { expect } from "alchemy-test";
13import * as Config from "effect/Config";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import * as Schedule from "effect/Schedule";
17import * as Stream from "effect/Stream";
18import * as HttpClient from "effect/http/HttpClient";
19
20const { test } = Test.make({
21 providers: Layer.mergeAll(providers(), Cloudflare.providers()),
22});
23test.provider(
24 "custom domain registers independently and returns DNS before activation",
25 (stack) =>
26 Effect.gen(function* () {
27 yield* stack.destroy();
28 const tls = yield* Config.String("NODE_TLS_REJECT_UNAUTHORIZED").pipe(
29 Config.withDefault("1"),
30 );
31 expect(tls).not.toBe("0");
32 const { accountId } = yield* yield* CloudflareEnvironment;
33 const zone = yield* findZoneByName({
34 accountId,
35 name: "alchemy-test-2.us",
36 });
37 if (!zone) {
38 return yield* Effect.fail(new Error("alchemy-test-2.us not found"));
39 }
40 const zoneId = zone.id;
41 const resources = Effect.gen(function* () {
42 const project = yield* Project("DomainProject", {
43 region: "aws-us-east-2",
44 });
45 const api = yield* Function("Api", {
46 project,
47 main: new URL("./fixtures/function-bare.ts", import.meta.url).href,
48 });
49 return { project, api };
50 });
51 const deployed = yield* stack.deploy(
52 Effect.gen(function* () {
53 const { project, api } = yield* resources;
54 const domain = yield* CustomDomain("Domain", {
55 function: api,
56 hostname: api.slug.pipe(
57 AlchemyOutput.map((slug) => `${slug}.alchemy-test-2.us`),
58 ),
59 });
60 const record = yield* Cloudflare.DNS.Record("DomainCname", {
61 zoneId,
62 name: domain.hostname,
63 type: "CNAME",
64 content: domain.cnameTarget,
65 proxied: false,
66 ttl: 60,
67 });
68 return { project, api, domain, record };
69 }),
70 );
71 expect(deployed.domain.cnameTarget.length).toBeGreaterThan(0);
72 expect(deployed.domain.url).toBe(`https://${deployed.domain.hostname}`);
73 const liveRecord = yield* dns.getRecord({
74 zoneId,
75 dnsRecordId: deployed.record.recordId,
76 });
77 expect(liveRecord.name).toBe(deployed.domain.hostname);
78 expect(liveRecord.type).toBe("CNAME");
79 expect(liveRecord.content).toBe(deployed.domain.cnameTarget);
80 expect(liveRecord.proxied).toBe(false);
81 expect(liveRecord.ttl).toBe(60);
82 const observeDomain = Api.listProjectBranchCustomDomains({
83 project_id: deployed.api.projectId,
84 branch_id: deployed.api.branchId,
85 }).pipe(
86 Effect.map(({ custom_domains }) =>
87 custom_domains.find(
88 (domain) => domain.domain === deployed.domain.hostname,
89 ),
90 ),
91 );
92 const registered = yield* observeDomain;
93 expect(registered?.entity_type).toBe("function");
94 expect(registered?.entity_id).toBe(deployed.api.slug);
95 expect(registered?.cname_target).toBe(deployed.domain.cnameTarget);
96 const client = yield* HttpClient.HttpClient;
97 const activation = yield* Effect.gen(function* () {
98 const domain = yield* observeDomain;
99 const https = yield* Effect.gen(function* () {
100 if (domain?.dns_status !== "ok") {
101 return { status: 0, body: "DNS validation pending" };
102 }
103 const response = yield* client.get(deployed.domain.url);
104 const body = yield* response.text;
105 return { status: response.status, body };
106 }).pipe(
107 Effect.timeout("6 seconds"),
108 Effect.catch((error) =>
109 Effect.logWarning("Custom domain HTTPS probe failed", error).pipe(
110 Effect.as({ status: 0, body: String(error) }),
111 ),
112 ),
113 );
114 yield* Effect.logInfo("Custom domain activation", { domain, https });
115 return { domain, https };
116 }).pipe(
117 Effect.repeat({
118 // Neon's background DNS check lands around 45-50s, so leave headroom.
119 schedule: Schedule.spaced("5 seconds"),
120 times: 23,
121 until: ({ domain, https }) =>
122 domain?.status === "active" &&
123 domain.dns_status === "ok" &&
124 domain.binding_status === "present" &&
125 https.status === 200 &&
126 https.body === "bare-v2",
127 }),
128 Effect.timeout("150 seconds"),
129 );
130 expect(activation.domain?.status).toBe("active");
131 expect(activation.domain?.dns_status).toBe("ok");
132 expect(activation.domain?.binding_status).toBe("present");
133 expect(activation.https.status).toBe(200);
134 expect(activation.https.body).toBe("bare-v2");
135
136 const retained = yield* stack.deploy(resources);
137 expect(retained.project.projectId).toBe(deployed.project.projectId);
138 expect(retained.api.slug).toBe(deployed.api.slug);
139 const remainingRecords = yield* dns.listRecords
140 .items({
141 zoneId,
142 name: { exact: deployed.domain.hostname },
143 type: "CNAME",
144 })
145 .pipe(Stream.runCollect);
146 expect(remainingRecords.length).toBe(0);
147 expect(yield* observeDomain).toBeUndefined();
148 const retainedProject = yield* Api.getProject({
149 project_id: deployed.project.projectId,
150 });
151 expect(retainedProject.project.id).toBe(deployed.project.projectId);
152 yield* stack.destroy();
153 const projectExists = yield* Api.getProject({
154 project_id: deployed.project.projectId,
155 }).pipe(
156 Effect.as(true),
157 Effect.catchTag("NotFound", () => Effect.succeed(false)),
158 );
159 expect(projectExists).toBe(false);
160 yield* Effect.logInfo(
161 "Custom domain, DNS record, and project independently absent",
162 );
163 }),
164 {
165 tags: [
166 "provider:cloudflare",
167 "provider:cloudflare:dns",
168 "provider:cloudflare:zone",
169 "provider:neon",
170 "provider:neon:customdomain",
171 "provider:neon:function",
172 "provider:neon:project",
173 "live",
174 ],
175 timeout: 240_000,
176 },
177);