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";
10import { cloneFixture } from "../Utils/Fixture.ts";
11
12// `dev: true` runs local providers behind the RPC sidecar proxy by default,
13// matching the process topology of the real `alchemy dev` command. The
14// Worker and Consumer lifecycles (vite child, consumer wiring, worker
15// restart hooks) all run in the sidecar process.
16const { test } = Test.make({
17 providers: Cloudflare.providers(),
18 dev: true,
19});
20
21const logLevel = Effect.provideService(
22 MinimumLogLevel,
23 process.env.DEBUG ? "Debug" : "Info",
24);
25
26const queueFixtureDir = pathe.resolve(
27 import.meta.dirname,
28 "vite-queue-fixture",
29);
30const cronFixtureDir = pathe.resolve(import.meta.dirname, "vite-cron-fixture");
31// Keep the temp clone under the workspace so Vite can express the project
32// root relative to cwd (see the note on `tempRoot` in Vite.test.ts).
33const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
34const fixtureEntries = [
35 "index.html",
36 "package.json",
37 "vite.config.ts",
38 "worker.ts",
39];
40
41class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
42 status: number;
43}> {}
44
45const getJsonReady = (url: string) =>
46 Effect.gen(function* () {
47 const client = yield* HttpClient.HttpClient;
48 const res = yield* client.get(url).pipe(
49 Effect.flatMap((res) =>
50 res.status === 200
51 ? Effect.succeed(res)
52 : Effect.fail(new WorkerNotReady({ status: res.status })),
53 ),
54 Effect.retry({
55 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
56 schedule: Schedule.max([
57 Schedule.min([
58 Schedule.exponential("500 millis"),
59 Schedule.spaced("2 seconds"),
60 ]),
61 Schedule.recurs(15),
62 ]),
63 }),
64 );
65 return yield* res.json;
66 }).pipe(Effect.orDie);
67
68/**
69 * Regression test for #1109: a `Queues.Consumer` registered against a
70 * `Website.Vite` worker in dev. The Consumer depends on the site's
71 * `workerName`, so the Vite child always boots before the consumer wiring
72 * lands in `LocalRuntimeState` — the consumer's reconcile must then restart
73 * the child through the `workerRestarts` hook (which `runVite` previously
74 * never registered). Pre-fix, the producer binding resolved to the dev
75 * registry's drop-and-warn stub: `queue()` never ran and `send()` hung.
76 */
77test.provider(
78 "Vite dev: queue consumer on the site worker receives produced messages",
79 (stack) =>
80 Effect.gen(function* () {
81 yield* stack.destroy();
82
83 const rootDir = yield* cloneFixture(queueFixtureDir, {
84 prefix: "alchemy-vite-queue-",
85 tempRoot,
86 entries: fixtureEntries,
87 });
88
89 const deployed = yield* stack.deploy(
90 Effect.gen(function* () {
91 const queue = yield* Cloudflare.Queues.Queue("ViteJobsQueue");
92 const site = yield* Cloudflare.Website.Vite("ViteQueueSite", {
93 rootDir,
94 main: "worker.ts",
95 workersDev: true,
96 compatibility: {
97 date: "2024-09-23",
98 flags: ["nodejs_compat"],
99 },
100 memo: { include: fixtureEntries },
101 assets: { runWorkerFirst: true },
102 dev: { port: 0 },
103 env: { QUEUE: queue },
104 });
105 yield* Cloudflare.Queues.Consumer("ViteJobsConsumer", {
106 queueId: queue.queueId,
107 scriptName: site.workerName,
108 settings: { batchSize: 5, maxRetries: 2, maxWaitTimeMs: 500 },
109 });
110 return { queue, site };
111 }),
112 );
113
114 // The queue is emulated (`dev:` id — proof no cloud call ran) and the
115 // site serves from the local dev proxy.
116 expect(deployed.queue.queueId).toMatch(/^dev:/);
117 expect(deployed.site.url).toMatch(/^http:\/\/localhost:\d+$/);
118
119 // `send()` resolves (pre-fix it hung forever against the registry's
120 // drop stub)...
121 const sent = (yield* getJsonReady(
122 `${deployed.site.url}/api/send?text=vite-queue-hello`,
123 ).pipe(Effect.timeout("60 seconds"))) as { sent: string };
124 expect(sent.sent).toBe("vite-queue-hello");
125
126 // ...and the broker delivers to the fixture's queue() handler.
127 const received = yield* getJsonReady(
128 `${deployed.site.url}/api/received`,
129 ).pipe(
130 Effect.map((body) => (body as { received: string[] }).received),
131 Effect.repeat({
132 schedule: Schedule.spaced("500 millis"),
133 until: (received) => received.includes("vite-queue-hello"),
134 times: 30,
135 }),
136 );
137 expect(received).toContain("vite-queue-hello");
138
139 yield* stack.destroy();
140 }).pipe(logLevel),
141 {
142 tags: [
143 "provider:cloudflare",
144 "provider:cloudflare:queue",
145 "provider:cloudflare:website",
146 "local",
147 ],
148 timeout: 240_000,
149 },
150);
151
152/** Regression test for cron configuration dropped by the Vite child path. */
153test.provider(
154 "Vite dev: automatically invokes scheduled handlers",
155 (stack) =>
156 Effect.gen(function* () {
157 yield* stack.destroy();
158
159 const rootDir = yield* cloneFixture(cronFixtureDir, {
160 prefix: "alchemy-vite-cron-",
161 tempRoot,
162 entries: fixtureEntries,
163 });
164
165 const site = yield* stack.deploy(
166 Cloudflare.Website.Vite("ViteCronSite", {
167 rootDir,
168 main: "worker.ts",
169 workersDev: true,
170 compatibility: {
171 date: "2024-09-23",
172 flags: ["nodejs_compat"],
173 },
174 memo: { include: fixtureEntries },
175 assets: { runWorkerFirst: true },
176 dev: { port: 0 },
177 // Effect Cron supports seconds for fast local feedback; five-field
178 // production expressions use the same runtime path.
179 crons: ["* * * * * *"],
180 }),
181 );
182
183 const scheduledCount = yield* getJsonReady(
184 `${site.url}/api/scheduled`,
185 ).pipe(
186 Effect.map(
187 (body) => (body as { scheduledCount: number }).scheduledCount,
188 ),
189 Effect.repeat({
190 schedule: Schedule.spaced("500 millis"),
191 until: (count) => count > 0,
192 times: 20,
193 }),
194 );
195 expect(scheduledCount).toBeGreaterThan(0);
196
197 yield* stack.destroy();
198 }).pipe(logLevel),
199 {
200 tags: ["provider:cloudflare", "provider:cloudflare:website", "local"],
201 timeout: 240_000,
202 },
203);