1import * as Cloudflare from "@/Cloudflare";
2import * as Alchemy from "@/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import type * as HttpClientResponse from "effect/http/HttpClientResponse";
11import WfpPlatformWorker from "./fixtures/platform-worker.ts";
12import {
13 AsyncPlatformWorker,
14 DispatchNs,
15 userWorkerScript,
16} from "./fixtures/shared.ts";
17
18/**
19 * End-to-end test for the Workers for Platforms dynamic-dispatch binding
20 * (`Cloudflare.WorkersForPlatforms.Get`). Deploys a dispatch namespace, a user
21 * Worker uploaded *into* it (via the Worker `namespace` prop), and a platform
22 * Worker that binds the namespace and forwards requests to the user Worker by
23 * script name. The platform Worker is then driven over HTTP.
24 *
25 * Workers for Platforms is a paid add-on. The standing test account is
26 * entitled, so this runs live by default; set `CLOUDFLARE_TEST_WFP=0` to skip
27 * the suite skip-clean on a non-entitled account.
28 */
29const WFP_ENABLED = process.env.CLOUDFLARE_TEST_WFP !== "0";
30
31const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
32 providers: Cloudflare.providers(),
33 state: Cloudflare.state(),
34});
35
36const HOOK_TIMEOUT = 300_000;
37const TEST_TIMEOUT = 180_000;
38
39const logLevel = Effect.provideService(
40 MinimumLogLevel,
41 process.env.DEBUG ? "Debug" : "Info",
42);
43
44const Stack = Alchemy.Stack(
45 "WfpDispatchBindingStack",
46 { providers: Cloudflare.providers(), state: Cloudflare.state() },
47 Effect.gen(function* () {
48 // Reference the namespace by its `name` output: resolves to the namespace
49 // name string and establishes the dependency edge so the namespace is
50 // created before the user Worker is uploaded into it.
51 const ns = yield* DispatchNs;
52 const userWorker = yield* Cloudflare.Worker("WfpBindingUserWorker", {
53 namespace: ns.name,
54 script: userWorkerScript,
55 });
56 const platformWorker = yield* WfpPlatformWorker;
57 const asyncPlatformWorker = yield* AsyncPlatformWorker;
58 return {
59 platformUrl: platformWorker.url.as<string>(),
60 asyncPlatformUrl: asyncPlatformWorker.url.as<string>(),
61 userWorkerName: userWorker.workerName.as<string>(),
62 };
63 }),
64);
65
66class NotReady extends Data.TaggedError("NotReady")<{
67 status: number;
68 body: string;
69}> {}
70
71// Fresh workers.dev URLs take a few seconds to start serving 200s; ride out
72// cold-start non-200s with a bounded spaced schedule so a real failure
73// surfaces fast instead of running to the vitest timeout.
74const untilOk = <E, R>(
75 eff: Effect.Effect<HttpClientResponse.HttpClientResponse, E, R>,
76) =>
77 eff.pipe(
78 Effect.flatMap((res) =>
79 res.status === 200
80 ? Effect.succeed(res)
81 : res.text.pipe(
82 Effect.flatMap((body) =>
83 Effect.fail(new NotReady({ status: res.status, body })),
84 ),
85 ),
86 ),
87 Effect.retry({
88 while: (e): e is NotReady => e instanceof NotReady,
89 schedule: Schedule.max([
90 Schedule.spaced("2 seconds"),
91 Schedule.recurs(30),
92 ]),
93 }),
94 );
95
96const stack = beforeAll(
97 WFP_ENABLED
98 ? deploy(Stack)
99 : Effect.succeed({
100 platformUrl: "",
101 asyncPlatformUrl: "",
102 userWorkerName: "",
103 }),
104 { timeout: HOOK_TIMEOUT },
105);
106afterAll.skipIf(!WFP_ENABLED || !!process.env.NO_DESTROY)(destroy(Stack), {
107 timeout: HOOK_TIMEOUT,
108});
109
110// Drive one platform worker base URL: dispatch to `scriptName`, assert the
111// request reached the dispatched user worker (not the platform worker's own
112// fallback) and that the path + header were forwarded.
113const assertDispatch = (base: string, scriptName: string) =>
114 Effect.gen(function* () {
115 const client = yield* HttpClient.HttpClient;
116 const res = yield* untilOk(
117 client.get(`${base}/dispatch/${scriptName}/hello`, {
118 headers: { "x-custom": "ping" },
119 }),
120 );
121 expect(res.status).toBe(200);
122
123 const body = (yield* res.json) as {
124 handledBy: string;
125 path: string;
126 customHeader: string;
127 };
128 expect(body.handledBy).toBe("user-worker");
129 expect(body.path).toBe("/hello");
130 expect(body.customHeader).toBe("ping");
131 });
132
133// Effect-native platform worker: binds the namespace via `Get`.
134test.skipIf(!WFP_ENABLED)(
135 "Get binding dispatches to a user worker in the namespace",
136 Effect.gen(function* () {
137 const { platformUrl, userWorkerName } = yield* stack;
138 yield* assertDispatch(platformUrl, userWorkerName);
139 }).pipe(logLevel),
140 {
141 tags: [
142 "provider:cloudflare",
143 "provider:cloudflare:worker",
144 "provider:cloudflare:workersforplatforms",
145 "live",
146 ],
147 timeout: TEST_TIMEOUT,
148 },
149);
150
151// Async platform worker: binds the namespace via `env: { DISPATCH }` + InferEnv.
152test.skipIf(!WFP_ENABLED)(
153 "env binding (InferEnv) dispatches to a user worker in the namespace",
154 Effect.gen(function* () {
155 const { asyncPlatformUrl, userWorkerName } = yield* stack;
156 yield* assertDispatch(asyncPlatformUrl, userWorkerName);
157 }).pipe(logLevel),
158 {
159 tags: [
160 "provider:cloudflare",
161 "provider:cloudflare:worker",
162 "provider:cloudflare:workersforplatforms",
163 "live",
164 ],
165 timeout: TEST_TIMEOUT,
166 },
167);
168
169test.skipIf(!WFP_ENABLED)(
170 "dispatching an unknown script does not reach a user worker",
171 Effect.gen(function* () {
172 const { platformUrl } = yield* stack;
173 const client = yield* HttpClient.HttpClient;
174
175 // A script that doesn't exist in the namespace can't be dispatched to:
176 // the forwarded fetch surfaces as a 404 from the edge rather than a
177 // dispatched 200. Either way it must be an error status, never a real
178 // user-worker response.
179 const res = yield* client
180 .get(`${platformUrl}/dispatch/this-script-does-not-exist/x`)
181 .pipe(
182 Effect.retry({
183 schedule: Schedule.exponential("500 millis"),
184 times: 8,
185 }),
186 );
187 expect(res.status).toBeGreaterThanOrEqual(400);
188 }).pipe(logLevel),
189 {
190 tags: [
191 "provider:cloudflare",
192 "provider:cloudflare:worker",
193 "provider:cloudflare:workersforplatforms",
194 "live",
195 ],
196 timeout: TEST_TIMEOUT,
197 },
198);