1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import { MinimumLogLevel } from "effect/References";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientRequest from "effect/http/HttpClientRequest";
9import {
10 materializeIsolatedProject,
11 removeIsolatedProject,
12} from "../../IsolatedProject.ts";
13import { project } from "./fixtures/isolated/container.ts";
14import 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.
23const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
24 providers: Cloudflare.providers(),
25 state: Cloudflare.state(),
26});
27
28const 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.
35const 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.
39beforeAll(materializeIsolatedProject(project));
40const stack = beforeAll(deploy(IsolatedStack), { timeout: HOOK_TIMEOUT });
41afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(IsolatedStack), {
42 timeout: HOOK_TIMEOUT,
43});
44afterAll(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.
48const 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.
56const DEPLOY_PLACEHOLDER = "Alchemy worker is being deployed...";
57
58const 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
84test(
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
102test(
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);