1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Test from "@/Test/Alchemy";
4import * as kv from "@distilled.cloud/cloudflare/kv";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import * as Stream from "effect/Stream";
13import * as HttpClient from "effect/http/HttpClient";
14import * as HttpClientRequest from "effect/http/HttpClientRequest";
15import * as pathe from "pathe";
16import { cloneFixture } from "../Utils/Fixture.ts";
17import {
18 expectDirectStatus,
19 expectUrlContains,
20 expectUrlHeader,
21} from "../Utils/Http.ts";
22import {
23 expectWorkerExists,
24 waitForWorkerToBeDeleted,
25} from "../Utils/Worker.ts";
26
27const { test } = Test.make({ providers: Cloudflare.providers() });
28
29const logLevel = Effect.provideService(
30 MinimumLogLevel,
31 process.env.DEBUG ? "Debug" : "Info",
32);
33
34const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "waku-app");
35
36// Keep the temp clone under the alchemy package (same convention as the
37// Vite tests) so the project root stays representable relative to cwd.
38const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
39
40const wakuProps = (rootDir: string) => ({
41 rootDir,
42 workersDev: { enabled: true, previewsEnabled: true },
43 // Deliberately NO `flags`: Waku's server runtime needs
44 // AsyncLocalStorage, and the resource must default to `nodejs_als`
45 // when the user provides no compatibility flags (a zero-config deploy
46 // would otherwise fail at first request).
47 compatibility: {
48 date: "2026-03-10",
49 },
50 memo: {
51 include: ["src/**", "public/**", "package.json", "tsconfig.json"],
52 },
53});
54
55// Concurrent like the other Website suites: the Waku source build runs
56// in a child process with cwd = project root (core/BuildChild.ts), so
57// nothing here mutates process-global state.
58describe.concurrent(
59 "Waku",
60 {
61 tags: [
62 "provider:cloudflare",
63 "provider:cloudflare:website",
64 "provider:cloudflare:worker",
65 "live",
66 ],
67 },
68 () => {
69 test.provider(
70 "Waku: deploys RSC SSR + binding + static assets and memoizes unchanged rebuilds",
71 (stack) =>
72 Effect.gen(function* () {
73 const { accountId } = yield* yield* CloudflareEnvironment;
74 const fs = yield* FileSystem.FileSystem;
75 const path = yield* Path.Path;
76
77 yield* stack.destroy();
78
79 const rootDir = yield* cloneFixture(fixtureDir, {
80 prefix: "alchemy-waku-",
81 tempRoot,
82 entries: [
83 ".gitignore",
84 "package.json",
85 "tsconfig.json",
86 "public",
87 "src",
88 ],
89 });
90
91 const bindingMarker = "waku-binding-marker";
92
93 const deploy = () =>
94 stack.deploy(
95 Effect.gen(function* () {
96 const siteKv = yield* Cloudflare.KV.Namespace("SiteKV");
97 const site = yield* Cloudflare.Website.Waku("WakuSite", {
98 ...wakuProps(rootDir),
99 env: {
100 MESSAGE: bindingMarker,
101 // A real resource binding (not a plain-string var): the
102 // `/api/kv` route reads and writes through it at request
103 // time via `cloudflare:workers` env.
104 SITE_KV: siteKv,
105 },
106 });
107 return { site, siteKv };
108 }),
109 );
110
111 const { site: site1, siteKv } = yield* deploy();
112
113 expect(site1.url).toBeDefined();
114 expect(site1.hash?.input).toBeDefined();
115 yield* expectWorkerExists(site1.workerName, accountId);
116
117 // Dynamic RSC page rendered by the Worker at request time.
118 yield* expectUrlContains(`${site1.url!}/`, "WAKU_PAGE_MARKER", {
119 timeout: "120 seconds",
120 label: "waku dynamic home page",
121 });
122
123 // The same page reads the `MESSAGE` binding from `cloudflare:workers`
124 // env at request time — proves bindings reach the RSC server bundle.
125 yield* expectUrlContains(
126 `${site1.url!}/`,
127 `MESSAGE=${bindingMarker}`,
128 {
129 timeout: "60 seconds",
130 label: "waku env binding in SSR output",
131 },
132 );
133
134 // Static asset from `public/`.
135 yield* expectUrlContains(
136 `${site1.url!}/hello.txt`,
137 "hello from public/",
138 {
139 timeout: "60 seconds",
140 label: "waku static asset",
141 },
142 );
143
144 // SSG page prerendered at build time and served from assets.
145 yield* expectUrlContains(
146 `${site1.url!}/about`,
147 "WAKU_ABOUT_STATIC_MARKER",
148 {
149 timeout: "60 seconds",
150 label: "waku SSG page",
151 },
152 );
153
154 // The SSG page serves 200 directly at the extensionless URL — the
155 // default `drop-trailing-slash` asset handling must not 307-redirect
156 // `/about` to `/about/`.
157 yield* expectDirectStatus(`${site1.url!}/about`, 200, {
158 label: "waku SSG page serves without a redirect",
159 });
160
161 // ── API route: a POST reaches the worker through the asset router
162 // and round-trips a JSON body plus the env binding ──────────────────
163 const echoed = yield* requestJsonReady<{
164 echoed: { ping: string };
165 message: string;
166 }>(
167 HttpClientRequest.post(`${site1.url!}/echo`).pipe(
168 HttpClientRequest.bodyJsonUnsafe({ ping: "pong" }),
169 ),
170 );
171 expect(echoed.echoed).toEqual({ ping: "pong" });
172 expect(echoed.message).toBe(bindingMarker);
173
174 // ── middleware: `src/middleware/*.ts` runs ahead of the RSC
175 // middleware for every worker-handled request — the header shows on
176 // the dynamic SSR page (static assets bypass the worker) ────────────
177 yield* expectUrlHeader(
178 `${site1.url!}/`,
179 "x-waku-middleware",
180 "alchemy-waku-fixture",
181 {
182 timeout: "60 seconds",
183 label: "waku middleware response header",
184 },
185 );
186
187 // ── KV: the SITE_KV namespace binding round-trips through the
188 // `/api/kv` route ───────────────────────────────────────────────────
189 expect(siteKv.namespaceId).toBeDefined();
190 yield* requestJsonReady<{ ok: boolean }>(
191 HttpClientRequest.put(`${site1.url!}/api/kv`).pipe(
192 HttpClientRequest.bodyJsonUnsafe({
193 key: "waku-live-key",
194 value: "kv-live-value",
195 }),
196 ),
197 );
198 const kvRead = yield* requestJsonReady<{
199 key: string;
200 value: string | null;
201 }>(
202 HttpClientRequest.get(`${site1.url!}/api/kv?key=waku-live-key`),
203 ).pipe(
204 Effect.filterOrFail(
205 (res) => res.value === "kv-live-value",
206 (res) => new Error(`kv value not visible yet: ${res.value}`),
207 ),
208 Effect.retry({
209 schedule: Schedule.exponential("1 second", 1.5),
210 times: 6,
211 }),
212 );
213 expect(kvRead.value).toBe("kv-live-value");
214
215 // Out-of-band via the cloud API: the worker's write landed in the
216 // REAL namespace the stack provisioned.
217 const rawValue = yield* kv
218 .getNamespaceValue({
219 accountId,
220 namespaceId: siteKv.namespaceId,
221 keyName: "waku-live-key",
222 })
223 .pipe(
224 Effect.flatMap((res) =>
225 Effect.tryPromise(() =>
226 new Response(
227 Stream.toReadableStream(res.body) as BodyInit,
228 ).text(),
229 ),
230 ),
231 // The KV REST read can lag the worker's write briefly.
232 Effect.retry({
233 while: (e) => e._tag === "KeyNotFound",
234 schedule: Schedule.exponential("1 second", 1.5),
235 times: 8,
236 }),
237 );
238 expect(rawValue).toBe("kv-live-value");
239
240 // ── deploy 2: no changes ⇒ the rebuild-free input hash matches and
241 // the deploy short-circuits without rebuilding ───────────────────────
242 const { site: site2 } = yield* deploy();
243
244 expect(site2.hash?.input).toBeDefined();
245 expect(site2.hash?.input).toEqual(site1.hash?.input);
246 expect(site2.url).toBe(site1.url);
247
248 // ── deploy 3: edit a page ⇒ the input hash changes and the new
249 // content deploys. The edited marker is asserted on the *dynamic*
250 // page (worker-rendered per request) — a changed static asset at the
251 // same URL can stay stale at a PoP far longer than a worker-version
252 // flip ─────────────────────────────────────────────────────────────
253 const indexPath = path.join(rootDir, "src/pages/index.tsx");
254 const index = yield* fs.readFileString(indexPath);
255 yield* fs.writeFileString(
256 indexPath,
257 index.replace("WAKU_PAGE_MARKER", "WAKU_PAGE_MARKER_V2"),
258 );
259
260 const { site: site3 } = yield* deploy();
261
262 expect(site3.hash?.input).toBeDefined();
263 expect(site3.hash?.input).not.toEqual(site1.hash?.input);
264 yield* expectUrlContains(`${site3.url!}/`, "WAKU_PAGE_MARKER_V2", {
265 timeout: "180 seconds",
266 label: "waku dynamic page after edit",
267 });
268
269 yield* stack.destroy();
270 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
271 // Destroy must also delete the bound KV namespace from the cloud.
272 yield* waitForNamespaceToBeDeleted(siteKv.namespaceId, accountId);
273 }).pipe(logLevel),
274 { tags: ["provider:cloudflare:kv"], timeout: 360_000 },
275 );
276
277 // ─────────────────────────────────────────────────────────────────────
278 // Custom worker entry (seam: WakuProps.main → the waku target's
279 // user-entry carriage → framework-core's DeployTarget.entry)
280 //
281 // `main` points the deploy at the user's own module, which wraps waku's
282 // emitted handler via `virtual:waku/server-entry` and re-exports the
283 // `Counter` Durable Object class — DO classes must live on the deployed
284 // worker for their namespace bindings to resolve. Mirrors
285 // `Website.Vite`'s "main overrides the worker entry" test.
286 // ─────────────────────────────────────────────────────────────────────
287
288 test.provider(
289 "Waku: main deploys a custom worker entry hosting a Durable Object",
290 (stack) =>
291 Effect.gen(function* () {
292 const { accountId } = yield* yield* CloudflareEnvironment;
293
294 yield* stack.destroy();
295
296 const rootDir = yield* cloneFixture(fixtureDir, {
297 prefix: "alchemy-waku-main-",
298 tempRoot,
299 entries: [
300 ".gitignore",
301 "package.json",
302 "tsconfig.json",
303 "public",
304 "src",
305 ],
306 });
307
308 const site = yield* stack.deploy(
309 Effect.gen(function* () {
310 return yield* Cloudflare.Website.Waku("WakuMainSite", {
311 ...wakuProps(rootDir),
312 // The user's own worker entry: wraps waku's handler (imported
313 // from `virtual:waku/server-entry`) and exports the Counter DO.
314 main: "src/worker-entry.ts",
315 env: {
316 MESSAGE: "waku-main-marker",
317 COUNTER: Cloudflare.DurableObject("Counter", {
318 className: "Counter",
319 }),
320 },
321 });
322 }),
323 );
324
325 expect(site.url).toBeDefined();
326 yield* expectWorkerExists(site.workerName, accountId);
327
328 // The deployed entry is the USER module, not waku's own entry.
329 const entry = yield* fetchJsonReady<{ entry: string }>(
330 `${site.url!}/api/entry`,
331 );
332 expect(entry.entry).toBe("worker-entry");
333
334 // The DO namespace is bound and state increments ACROSS requests —
335 // instance identity on the deployed worker, through the custom entry.
336 const first = yield* fetchJsonReady<{ count: number }>(
337 `${site.url!}/api/counter/increment`,
338 );
339 const second = yield* fetchJsonReady<{ count: number }>(
340 `${site.url!}/api/counter/increment`,
341 );
342 expect(second.count).toBe(first.count + 1);
343
344 const read = yield* fetchJsonReady<{ count: number }>(
345 `${site.url!}/api/counter`,
346 );
347 expect(read.count).toBe(second.count);
348
349 // Wrapping waku's handler keeps every framework route working:
350 // the dynamic RSC page still renders (and still reads bindings).
351 yield* expectUrlContains(`${site.url!}/`, "WAKU_PAGE_MARKER", {
352 timeout: "120 seconds",
353 label: "waku dynamic page through the custom entry",
354 });
355 yield* expectUrlContains(
356 `${site.url!}/`,
357 "MESSAGE=waku-main-marker",
358 {
359 timeout: "60 seconds",
360 label: "waku env binding through the custom entry",
361 },
362 );
363
364 // SSG page + public asset still serve from assets alongside the
365 // custom entry.
366 yield* expectUrlContains(
367 `${site.url!}/about`,
368 "WAKU_ABOUT_STATIC_MARKER",
369 {
370 timeout: "60 seconds",
371 label: "waku SSG page with custom entry",
372 },
373 );
374 yield* expectUrlContains(
375 `${site.url!}/hello.txt`,
376 "hello from public/",
377 {
378 timeout: "60 seconds",
379 label: "waku static asset with custom entry",
380 },
381 );
382
383 yield* stack.destroy();
384 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
385 }).pipe(logLevel),
386 { timeout: 360_000 },
387 );
388 },
389);
390
391/**
392 * Execute `request` until it answers 200 with a JSON body (fresh
393 * workers.dev URLs take a few seconds to start serving; DO namespaces can
394 * lag the first deploy). Generalizes Vite.test.ts's `fetchJsonReady` to
395 * arbitrary methods/bodies (POST/PUT round-trips).
396 */
397const requestJsonReady = <T>(request: HttpClientRequest.HttpClientRequest) =>
398 Effect.gen(function* () {
399 const client = yield* HttpClient.HttpClient;
400 return yield* client.execute(request).pipe(
401 Effect.flatMap((res) =>
402 res.status === 200
403 ? Effect.flatMap(res.text, (body) =>
404 Effect.try({
405 try: () => JSON.parse(body) as T,
406 catch: () => new Error(`non-json body: ${body}`),
407 }),
408 )
409 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
410 ),
411 Effect.retry({
412 // Capped interval, ~90s total budget (workers.dev / DO propagation).
413 schedule: Schedule.min([
414 Schedule.exponential("500 millis"),
415 Schedule.spaced("2 seconds"),
416 ]),
417 times: 45,
418 }),
419 );
420 });
421
422const fetchJsonReady = <T>(url: string) =>
423 requestJsonReady<T>(HttpClientRequest.get(url));
424
425class NamespaceStillExists extends Data.TaggedError("NamespaceStillExists") {}
426
427/**
428 * Poll until the KV namespace is gone from the cloud (bounded). Same
429 * pattern as Astro.test.ts.
430 */
431const waitForNamespaceToBeDeleted = Effect.fn(function* (
432 namespaceId: string,
433 accountId: string,
434) {
435 yield* kv.getNamespace({ accountId, namespaceId }).pipe(
436 Effect.flatMap(() => Effect.fail(new NamespaceStillExists())),
437 Effect.retry({
438 while: (e): e is NamespaceStillExists =>
439 e instanceof NamespaceStillExists,
440 schedule: Schedule.min([
441 Schedule.exponential(250),
442 Schedule.spaced("2 seconds"),
443 ]),
444 times: 10,
445 }),
446 Effect.catchTag("NamespaceNotFound", () => Effect.void),
447 );
448});