| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import { expect } from "alchemy-test"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import { MinimumLogLevel } from "effect/References"; |
| 6 | import * as Schedule from "effect/Schedule"; |
| 7 | import * as HttpClient from "effect/http/HttpClient"; |
| 8 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 9 | import { |
| 10 | materializeIsolatedProject, |
| 11 | removeIsolatedProject, |
| 12 | } from "../../IsolatedProject.ts"; |
| 13 | import { project } from "./fixtures/isolated/container.ts"; |
| 14 | import IsolatedStack from "./fixtures/isolated/stack.ts"; |
| 15 | |
| 16 | // Live proof that the Cloudflare Container bootstrap boots when the |
| 17 | // container's `main` lives in an isolated project (see |
| 18 | // test/IsolatedProject.ts) — the bundle `cwd` resolves none of alchemy's |
| 19 | // dependencies, so the bootstrap's `@effect/platform-bun` / `alchemy/*` |
| 20 | // imports must be bundled by the virtual-entry plugin rather than found from |
| 21 | // the project root. With them left external bun dies at module load and the |
| 22 | // Durable Object's RPC / TCP requests never get a reply. |
| 23 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 24 | providers: Cloudflare.providers(), |
| 25 | state: Cloudflare.state(), |
| 26 | }); |
| 27 | |
| 28 | const logLevel = Effect.provideService( |
| 29 | MinimumLogLevel, |
| 30 | process.env.DEBUG ? "Debug" : "Info", |
| 31 | ); |
| 32 | |
| 33 | // Container image build + push + worker/DO deploy comfortably exceeds the |
| 34 | // default 120s hook budget. |
| 35 | const HOOK_TIMEOUT = 600_000; |
| 36 | |
| 37 | // The project must exist before the deploy bundles it; written in its own |
| 38 | // hook so a failed deploy still runs the cleanup. |
| 39 | beforeAll(materializeIsolatedProject(project)); |
| 40 | const stack = beforeAll(deploy(IsolatedStack), { timeout: HOOK_TIMEOUT }); |
| 41 | afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(IsolatedStack), { |
| 42 | timeout: HOOK_TIMEOUT, |
| 43 | }); |
| 44 | afterAll(removeIsolatedProject(project)); |
| 45 | |
| 46 | // Force `Connection: close` so each readiness attempt opens a fresh |
| 47 | // connection and can land on an edge that already has the new deploy. |
| 48 | const freshConn = HttpClient.HttpClient.pipe( |
| 49 | Effect.map( |
| 50 | HttpClient.mapRequest(HttpClientRequest.setHeader("connection", "close")), |
| 51 | ), |
| 52 | ); |
| 53 | |
| 54 | // While a freshly pre-created worker propagates, the edge serves Alchemy's |
| 55 | // pre-create stub; any poll that sees it retries. |
| 56 | const DEPLOY_PLACEHOLDER = "Alchemy worker is being deployed..."; |
| 57 | |
| 58 | const fetchReady = (url: URL, expected: string) => |
| 59 | Effect.gen(function* () { |
| 60 | const client = yield* freshConn; |
| 61 | return yield* client.get(url).pipe( |
| 62 | Effect.flatMap((r) => |
| 63 | r.text.pipe( |
| 64 | Effect.flatMap((body) => |
| 65 | r.status !== 200 |
| 66 | ? Effect.fail(new Error(`Worker not ready: ${r.status} ${body}`)) |
| 67 | : body.includes(DEPLOY_PLACEHOLDER) || !body.includes(expected) |
| 68 | ? Effect.fail(new Error(`not ready: got ${body}`)) |
| 69 | : Effect.succeed(body), |
| 70 | ), |
| 71 | ), |
| 72 | ), |
| 73 | Effect.timeout("10 seconds"), |
| 74 | Effect.retry({ |
| 75 | schedule: Schedule.min([ |
| 76 | Schedule.exponential("500 millis"), |
| 77 | Schedule.spaced("3 seconds"), |
| 78 | ]), |
| 79 | times: 60, |
| 80 | }), |
| 81 | ); |
| 82 | }); |
| 83 | |
| 84 | test( |
| 85 | "RPC: ping round-trips into the container", |
| 86 | Effect.gen(function* () { |
| 87 | const { url } = yield* stack; |
| 88 | const pong = yield* fetchReady(new URL("/ping", url), "pong"); |
| 89 | expect(pong).toContain("pong"); |
| 90 | }).pipe(logLevel), |
| 91 | { |
| 92 | tags: [ |
| 93 | "provider:cloudflare", |
| 94 | "provider:cloudflare:container", |
| 95 | "provider:cloudflare:worker", |
| 96 | "live", |
| 97 | ], |
| 98 | timeout: 300_000, |
| 99 | }, |
| 100 | ); |
| 101 | |
| 102 | test( |
| 103 | "fetch: the container's HTTP server answers over its TCP port", |
| 104 | Effect.gen(function* () { |
| 105 | const { url } = yield* stack; |
| 106 | const hello = yield* fetchReady( |
| 107 | new URL("/hello", url), |
| 108 | "hello from isolated project", |
| 109 | ); |
| 110 | expect(hello).toContain("hello from isolated project"); |
| 111 | }).pipe(logLevel), |
| 112 | { |
| 113 | tags: [ |
| 114 | "provider:cloudflare", |
| 115 | "provider:cloudflare:container", |
| 116 | "provider:cloudflare:worker", |
| 117 | "live", |
| 118 | ], |
| 119 | timeout: 300_000, |
| 120 | }, |
| 121 | ); |