1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as pathe from "pathe";
10
11const { test } = Test.make({
12 providers: Cloudflare.providers(),
13 dev: true,
14});
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21const fixtureDir = pathe.resolve(
22 import.meta.dirname,
23 "fixtures/assets-routing",
24);
25const main = pathe.resolve(fixtureDir, "worker.ts");
26const assetsDir = pathe.resolve(fixtureDir, "assets");
27
28class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
29 status: number;
30}> {}
31
32const getTextReady = (url: string) =>
33 Effect.gen(function* () {
34 const client = yield* HttpClient.HttpClient;
35 const res = yield* client.get(url).pipe(
36 Effect.flatMap((res) =>
37 res.status === 200
38 ? Effect.succeed(res)
39 : Effect.fail(new WorkerNotReady({ status: res.status })),
40 ),
41 Effect.retry({
42 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
43 schedule: Schedule.max([
44 Schedule.min([
45 Schedule.exponential("500 millis"),
46 Schedule.spaced("2 seconds"),
47 ]),
48 Schedule.recurs(10),
49 ]),
50 }),
51 );
52 return yield* res.text;
53 }).pipe(Effect.orDie);
54
55/**
56 * Pins `runWorkerFirst` routing through the local dev pipeline
57 * (LocalWorkerProvider → cloudflare-runtime), matching deployed behavior:
58 *
59 * - omitted → assets-first: a request matching an asset never invokes the
60 * worker (the #1121 dev default fix)
61 * - a glob array → only matching paths go worker-first, shadowing even a
62 * matching asset; everything else stays assets-first (a boolean coercion
63 * of the array anywhere along the pass-through fails one direction or
64 * the other)
65 *
66 * The fixture makes the answering layer observable: the worker echoes
67 * `assets-routing-worker:<path>` for every request, and the assets
68 * directory contains `index.html` plus `api/hello.txt` — a path that both
69 * layers can answer.
70 */
71test.provider(
72 "dev runWorkerFirst routing: assets-first default and selective glob arrays",
73 (stack) =>
74 Effect.gen(function* () {
75 yield* stack.destroy();
76
77 const deployed = yield* stack.deploy(
78 Effect.gen(function* () {
79 const globs = yield* Cloudflare.Worker("assets-routing-globs", {
80 main,
81 assets: {
82 directory: assetsDir,
83 runWorkerFirst: ["/api/*"],
84 },
85 });
86 const defaults = yield* Cloudflare.Worker("assets-routing-default", {
87 main,
88 assets: { directory: assetsDir },
89 });
90 return { globs, defaults };
91 }),
92 );
93
94 // Both serve from the local dev proxy — proof no cloud call ran.
95 expect(deployed.globs.url).toMatch(/^http:\/\/localhost:\d+$/);
96 expect(deployed.defaults.url).toMatch(/^http:\/\/localhost:\d+$/);
97
98 // ── glob array ────────────────────────────────────────────────────
99 // `/` is outside the globs: the asset answers. If the array were
100 // coerced to blanket worker-first, the worker's echo would win.
101 const globsIndex = yield* getTextReady(`${deployed.globs.url}/`);
102 expect(globsIndex).toContain("alchemy-assets-routing-index");
103
104 // `/api/hello.txt` matches both an asset and the `/api/*` glob: the
105 // worker must answer. If the array were dropped (treated as false),
106 // the asset would serve instead.
107 const globsApi = yield* getTextReady(
108 `${deployed.globs.url}/api/hello.txt`,
109 );
110 expect(globsApi).toBe("assets-routing-worker:/api/hello.txt");
111
112 // A path neither layer claims still falls through to the worker.
113 const globsMiss = yield* getTextReady(`${deployed.globs.url}/nothing`);
114 expect(globsMiss).toBe("assets-routing-worker:/nothing");
115
116 // ── omitted (assets-first default) ────────────────────────────────
117 // The same both-layers path now serves the asset: the worker is only
118 // invoked for requests with no matching asset — deployed behavior.
119 const defaultApi = yield* getTextReady(
120 `${deployed.defaults.url}/api/hello.txt`,
121 );
122 expect(defaultApi).toContain("alchemy-assets-routing-api-asset");
123
124 const defaultMiss = yield* getTextReady(
125 `${deployed.defaults.url}/nothing`,
126 );
127 expect(defaultMiss).toBe("assets-routing-worker:/nothing");
128
129 yield* stack.destroy();
130 }).pipe(logLevel),
131 {
132 tags: ["provider:cloudflare", "provider:cloudflare:worker", "local"],
133 timeout: 120_000,
134 },
135);