| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 3 | import { findZoneByName } from "@/Cloudflare/Zone/lookup"; |
| 4 | import { Project } from "@/Neon/Project"; |
| 5 | import { Function } from "@/Neon/Function"; |
| 6 | import { CustomDomain } from "@/Neon/CustomDomain"; |
| 7 | import { providers } from "@/Neon/Providers"; |
| 8 | import * as AlchemyOutput from "@/Output"; |
| 9 | import * as Test from "@/Test/Alchemy"; |
| 10 | import * as dns from "@distilled.cloud/cloudflare/dns"; |
| 11 | import * as Api from "@distilled.cloud/neon"; |
| 12 | import { expect } from "alchemy-test"; |
| 13 | import * as Config from "effect/Config"; |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Schedule from "effect/Schedule"; |
| 17 | import * as Stream from "effect/Stream"; |
| 18 | import * as HttpClient from "effect/http/HttpClient"; |
| 19 | |
| 20 | const { test } = Test.make({ |
| 21 | providers: Layer.mergeAll(providers(), Cloudflare.providers()), |
| 22 | }); |
| 23 | test.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 | ); |