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 { expectUrlContains } from "../Utils/Http.ts";
18import {
19 expectWorkerExists,
20 waitForWorkerToBeDeleted,
21} from "../Utils/Worker.ts";
22
23const { test } = Test.make({ providers: Cloudflare.providers() });
24
25const logLevel = Effect.provideService(
26 MinimumLogLevel,
27 process.env.DEBUG ? "Debug" : "Info",
28);
29
30const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "nuxt-app");
31
32// Keep the temp clone under the alchemy package (same convention as the
33// Vite tests) so the project root stays representable relative to cwd —
34// and so `nuxt`/`nitropack` resolve from the workspace's hoisted
35// node_modules when the fixture's own tree has none.
36const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
37
38const fixtureEntries = [
39 ".gitignore",
40 "package.json",
41 "nuxt.config.ts",
42 "worker-entry.ts",
43 "app",
44 "server",
45 "public",
46];
47
48const memoInclude = [
49 "app/**",
50 "server/**",
51 "public/**",
52 "nuxt.config.ts",
53 "worker-entry.ts",
54 "package.json",
55];
56
57const nuxtProps = (rootDir: string) => ({
58 rootDir,
59 workersDev: { enabled: true, previewsEnabled: true },
60 memo: {
61 include: memoInclude,
62 },
63});
64
65class NamespaceStillExists extends Data.TaggedError("NamespaceStillExists") {}
66
67const waitForNamespaceToBeDeleted = Effect.fn(function* (
68 namespaceId: string,
69 accountId: string,
70) {
71 yield* kv.getNamespace({ accountId, namespaceId }).pipe(
72 Effect.flatMap(() => Effect.fail(new NamespaceStillExists())),
73 Effect.retry({
74 while: (e): e is NamespaceStillExists =>
75 e instanceof NamespaceStillExists,
76 schedule: Schedule.min([
77 Schedule.exponential(250),
78 Schedule.spaced("2 seconds"),
79 ]),
80 times: 10,
81 }),
82 Effect.catchTag("NamespaceNotFound", () => Effect.void),
83 );
84});
85
86/**
87 * Read `keyName` from the real cloud namespace via distilled's KV API,
88 * retrying through KV's brief read-after-write lag while the key is not
89 * yet visible.
90 */
91const readNamespaceValue = Effect.fn(function* (options: {
92 accountId: string;
93 namespaceId: string;
94 keyName: string;
95}) {
96 const res = yield* kv.getNamespaceValue(options).pipe(
97 Effect.retry({
98 while: (e): boolean => e._tag === "KeyNotFound",
99 schedule: Schedule.min([
100 Schedule.exponential("1 second"),
101 Schedule.spaced("3 seconds"),
102 ]),
103 times: 8,
104 }),
105 );
106 return yield* Effect.tryPromise(() =>
107 new Response(Stream.toReadableStream(res.body) as BodyInit).text(),
108 );
109});
110
111// Tests are independent (per-test scratch stacks, private fixture clones),
112// so run them concurrently; suites are sequential by default.
113describe.concurrent(
114 "Nuxt",
115 {
116 tags: [
117 "provider:cloudflare",
118 "provider:cloudflare:website",
119 "provider:cloudflare:worker",
120 "live",
121 ],
122 },
123 () => {
124 test.provider(
125 "Nuxt: deploys SSR + bindings + static assets and memoizes unchanged rebuilds",
126 (stack) =>
127 Effect.gen(function* () {
128 const { accountId } = yield* yield* CloudflareEnvironment;
129 const fs = yield* FileSystem.FileSystem;
130 const path = yield* Path.Path;
131
132 yield* stack.destroy();
133
134 const rootDir = yield* cloneFixture(fixtureDir, {
135 prefix: "alchemy-nuxt-",
136 tempRoot,
137 entries: fixtureEntries,
138 });
139
140 const bindingMarker = "nuxt-binding-marker";
141
142 const deploy = () =>
143 stack.deploy(
144 Effect.gen(function* () {
145 const siteKv = yield* Cloudflare.KV.Namespace("SiteKV");
146 const site = yield* Cloudflare.Website.Nuxt("NuxtSite", {
147 ...nuxtProps(rootDir),
148 env: {
149 TEST_BINDING: bindingMarker,
150 SITE_KV: siteKv,
151 },
152 });
153 return { site, siteKv };
154 }),
155 );
156
157 const { site: site1, siteKv } = yield* deploy();
158
159 expect(site1.url).toBeDefined();
160 expect(site1.hash?.input).toBeDefined();
161 yield* expectWorkerExists(site1.workerName, accountId);
162
163 // SSR page: rendered by the Worker at request time.
164 yield* expectUrlContains(`${site1.url!}/`, "NUXT_PAGE_MARKER", {
165 timeout: "120 seconds",
166 label: "SSR home page",
167 });
168
169 // The SSR page reads `event.context.cloudflare.env.TEST_BINDING` —
170 // proves bindings reach nitro's cloudflare_module runtime contract.
171 yield* expectUrlContains(
172 `${site1.url!}/`,
173 `binding:${bindingMarker}`,
174 {
175 timeout: "60 seconds",
176 label: "SSR page with event.context.cloudflare.env binding",
177 },
178 );
179
180 // The fixture's own nuxt.config.ts loaded natively — its
181 // `runtimeConfig.public.fixtureMarker` renders on the page.
182 yield* expectUrlContains(
183 `${site1.url!}/`,
184 "config:nuxt-user-config-loaded",
185 {
186 timeout: "60 seconds",
187 label: "user nuxt.config.ts applied",
188 },
189 );
190
191 // API route: reads the binding + waitUntil from the runtime contract.
192 const hello = yield* fetchJsonReady<{
193 marker: string;
194 binding: string | null;
195 hasWaitUntil: boolean;
196 }>(`${site1.url!}/api/hello`);
197 expect(hello.marker).toBe("api-route-ok");
198 expect(hello.binding).toBe(bindingMarker);
199 expect(hello.hasWaitUntil).toBe(true);
200
201 // POST route: the JSON body flows through h3's readBody and comes
202 // back verbatim, with the binding visible on a non-GET route.
203 const echoPayload = { message: "hello-from-post", n: 42 };
204 const echo = yield* postJsonReady<{
205 method: string;
206 echoed: { message: string; n: number };
207 binding: string | null;
208 }>(`${site1.url!}/api/echo`, echoPayload);
209 expect(echo.method).toBe("POST");
210 expect(echo.echoed).toEqual(echoPayload);
211 expect(echo.binding).toBe(bindingMarker);
212
213 // KV binding: the worker's PUT lands in the real namespace and the
214 // worker's GET reads it back through the native binding.
215 const kvKey = "nuxt-live-key";
216 const kvValue = "nuxt-live-value";
217 expect(siteKv.namespaceId).toBeDefined();
218 const put = yield* putJsonReady<{ put: boolean; key: string }>(
219 `${site1.url!}/api/kv?key=${kvKey}&value=${kvValue}`,
220 );
221 expect(put.put).toBe(true);
222 const got = yield* fetchJsonReady<{
223 key: string;
224 value: string | null;
225 }>(`${site1.url!}/api/kv?key=${kvKey}`);
226 expect(got.value).toBe(kvValue);
227
228 // Out-of-band: the write is visible through the cloud KV API — the
229 // binding really targeted the namespace this stack provisioned.
230 const observed = yield* readNamespaceValue({
231 accountId,
232 namespaceId: siteKv.namespaceId,
233 keyName: kvKey,
234 });
235 expect(observed).toBe(kvValue);
236
237 // Static asset from `public/`.
238 yield* expectUrlContains(`${site1.url!}/robots.txt`, "User-agent", {
239 timeout: "60 seconds",
240 label: "static asset",
241 });
242
243 // Route-rule prerendered page, served from assets.
244 yield* expectUrlContains(
245 `${site1.url!}/prerendered`,
246 "this-page-is-prerendered",
247 {
248 timeout: "60 seconds",
249 label: "prerendered page",
250 },
251 );
252
253 // ── deploy 2: no changes ⇒ the rebuild-free input hash matches and
254 // the deploy short-circuits without building ─────────────────────────
255 const { site: site2 } = yield* deploy();
256
257 expect(site2.hash?.input).toBeDefined();
258 expect(site2.hash?.input).toEqual(site1.hash?.input);
259 expect(site2.url).toBe(site1.url);
260
261 // ── deploy 3: edit a page ⇒ the input hash changes and the new
262 // content deploys. The edited marker is asserted on the *dynamic*
263 // page (worker-rendered per request) — a changed static asset at the
264 // same URL can stay stale at a PoP far longer than a worker-version
265 // flip ─────────────────────────────────────────────────────────────
266 const indexPath = path.join(rootDir, "app/pages/index.vue");
267 const index = yield* fs.readFileString(indexPath);
268 yield* fs.writeFileString(
269 indexPath,
270 index.replace("NUXT_PAGE_MARKER", "NUXT_PAGE_MARKER_V2"),
271 );
272
273 const { site: site3 } = yield* deploy();
274
275 expect(site3.hash?.input).toBeDefined();
276 expect(site3.hash?.input).not.toEqual(site1.hash?.input);
277 yield* expectUrlContains(`${site3.url!}/`, "NUXT_PAGE_MARKER_V2", {
278 timeout: "180 seconds",
279 label: "SSR page after edit",
280 });
281
282 yield* stack.destroy();
283 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
284
285 // Destroy must also delete the stack-provisioned KV namespace.
286 yield* waitForNamespaceToBeDeleted(siteKv.namespaceId, accountId);
287 }).pipe(logLevel),
288 { tags: ["provider:cloudflare:kv"], timeout: 600_000 },
289 );
290
291 // ─────────────────────────────────────────────────────────────────────
292 // Custom worker entry (seam: NuxtProps.main → nitro.options.entry)
293 //
294 // `main` points the deploy at the user's own module, which wraps nitro's
295 // emitted cloudflare-module handler (imported from
296 // `nitropack/presets/cloudflare/runtime/cloudflare-module`) and re-exports
297 // the `Counter` Durable Object class — DO classes must live on the
298 // deployed worker for their namespace bindings to resolve. Mirrors
299 // Waku.test.ts's "main deploys a custom worker entry" test.
300 // ─────────────────────────────────────────────────────────────────────
301
302 test.provider(
303 "Nuxt: main deploys a custom worker entry hosting a Durable Object",
304 (stack) =>
305 Effect.gen(function* () {
306 const { accountId } = yield* yield* CloudflareEnvironment;
307
308 yield* stack.destroy();
309
310 const rootDir = yield* cloneFixture(fixtureDir, {
311 prefix: "alchemy-nuxt-main-",
312 tempRoot,
313 entries: fixtureEntries,
314 });
315
316 const bindingMarker = "nuxt-main-marker";
317
318 const site = yield* stack.deploy(
319 Effect.gen(function* () {
320 return yield* Cloudflare.Website.Nuxt("NuxtMainSite", {
321 ...nuxtProps(rootDir),
322 // The user's own worker entry: wraps nitro's handler and
323 // exports the Counter DO.
324 main: "worker-entry.ts",
325 env: {
326 TEST_BINDING: bindingMarker,
327 COUNTER: Cloudflare.DurableObject("Counter", {
328 className: "Counter",
329 }),
330 },
331 });
332 }),
333 );
334
335 expect(site.url).toBeDefined();
336 yield* expectWorkerExists(site.workerName, accountId);
337
338 // The DO namespace is bound and state increments ACROSS requests —
339 // instance identity on the deployed worker, through the custom entry.
340 // (POST increments, GET reads — see server/api/counter.ts.)
341 const first = yield* postJsonReady<{ count: number }>(
342 `${site.url!}/api/counter`,
343 {},
344 );
345 const second = yield* postJsonReady<{ count: number }>(
346 `${site.url!}/api/counter`,
347 {},
348 );
349 expect(second.count).toBe(first.count + 1);
350
351 const read = yield* fetchJsonReady<{ count: number }>(
352 `${site.url!}/api/counter`,
353 );
354 expect(read.count).toBe(second.count);
355
356 // Wrapping nitro's handler keeps every framework route working:
357 // the SSR page still renders (and still reads bindings).
358 yield* expectUrlContains(`${site.url!}/`, "NUXT_PAGE_MARKER", {
359 timeout: "120 seconds",
360 label: "SSR page through the custom entry",
361 });
362 yield* expectUrlContains(
363 `${site.url!}/`,
364 `binding:${bindingMarker}`,
365 {
366 timeout: "60 seconds",
367 label: "env binding through the custom entry",
368 },
369 );
370
371 // API route through the wrapped handler.
372 const hello = yield* fetchJsonReady<{
373 marker: string;
374 binding: string | null;
375 }>(`${site.url!}/api/hello`);
376 expect(hello.marker).toBe("api-route-ok");
377 expect(hello.binding).toBe(bindingMarker);
378
379 // Static asset + prerendered page still serve from assets alongside
380 // the custom entry.
381 yield* expectUrlContains(`${site.url!}/robots.txt`, "User-agent", {
382 timeout: "60 seconds",
383 label: "static asset with custom entry",
384 });
385 yield* expectUrlContains(
386 `${site.url!}/prerendered`,
387 "this-page-is-prerendered",
388 {
389 timeout: "60 seconds",
390 label: "prerendered page with custom entry",
391 },
392 );
393
394 yield* stack.destroy();
395 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
396 }).pipe(logLevel),
397 { timeout: 600_000 },
398 );
399
400 // ─────────────────────────────────────────────────────────────────────
401 // SPA mode: `ssr: false`
402 //
403 // Pages render exclusively in the browser — the raw HTML the deploy
404 // serves is the app shell (identified by the fixture's `app.head` meta
405 // marker), for `/` and for deep links alike. Nitro keeps `server/`
406 // routes executing in the worker with the full cloudflare runtime
407 // contract; only page rendering moves to the client.
408 // ─────────────────────────────────────────────────────────────────────
409
410 const spaFixtureDir = pathe.resolve(
411 import.meta.dirname,
412 "fixtures",
413 "nuxt-spa-app",
414 );
415
416 const SPA_SHELL_MARKER = "nuxt-spa-shell";
417
418 test.provider(
419 "Nuxt: ssr:false serves the SPA shell while server routes still run in the worker",
420 (stack) =>
421 Effect.gen(function* () {
422 const { accountId } = yield* yield* CloudflareEnvironment;
423
424 yield* stack.destroy();
425
426 const rootDir = yield* cloneFixture(spaFixtureDir, {
427 prefix: "alchemy-nuxt-spa-",
428 tempRoot,
429 entries: [
430 ".gitignore",
431 "package.json",
432 "nuxt.config.ts",
433 "app",
434 "server",
435 "public",
436 ],
437 });
438
439 const bindingMarker = "nuxt-spa-binding-marker";
440
441 const site = yield* stack.deploy(
442 Effect.gen(function* () {
443 return yield* Cloudflare.Website.Nuxt("NuxtSpaSite", {
444 rootDir,
445 workersDev: { enabled: true, previewsEnabled: true },
446 memo: {
447 include: [
448 "app/**",
449 "server/**",
450 "public/**",
451 "nuxt.config.ts",
452 "package.json",
453 ],
454 },
455 env: {
456 TEST_BINDING: bindingMarker,
457 },
458 });
459 }),
460 );
461
462 expect(site.url).toBeDefined();
463 yield* expectWorkerExists(site.workerName, accountId);
464
465 // (a) `/` serves the app shell: the `app.head` meta marker is in
466 // the raw HTML, while the page markup (which only ever renders in
467 // the browser under `ssr: false`) is absent.
468 const homeBody = yield* expectUrlContains(
469 `${site.url!}/`,
470 SPA_SHELL_MARKER,
471 {
472 timeout: "120 seconds",
473 label: "SPA shell at /",
474 },
475 );
476 expect(homeBody).not.toContain("NUXT_SPA_PAGE_MARKER");
477
478 // (b) A hard GET to a client route serves the shell too — the
479 // client router owns the route; its markup never appears in HTML.
480 const deepBody = yield* expectUrlContains(
481 `${site.url!}/deep`,
482 SPA_SHELL_MARKER,
483 {
484 timeout: "60 seconds",
485 label: "deep link serves SPA shell",
486 },
487 );
488 expect(deepBody).not.toContain("NUXT_SPA_DEEP_MARKER");
489
490 // (c) Nitro keeps `server/api` routes executing in the worker,
491 // `ssr: false` notwithstanding — with the cloudflare_module
492 // runtime contract (env binding + waitUntil) intact.
493 const hello = yield* fetchJsonReady<{
494 marker: string;
495 binding: string | null;
496 hasWaitUntil: boolean;
497 }>(`${site.url!}/api/hello`);
498 expect(hello.marker).toBe("spa-api-route-ok");
499 expect(hello.binding).toBe(bindingMarker);
500 expect(hello.hasWaitUntil).toBe(true);
501
502 // (d) A `public/` asset serves from the asset layer.
503 yield* expectUrlContains(`${site.url!}/robots.txt`, "User-agent", {
504 timeout: "60 seconds",
505 label: "static asset in SPA mode",
506 });
507
508 yield* stack.destroy();
509 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
510 }).pipe(logLevel),
511 { timeout: 600_000 },
512 );
513 },
514);
515
516/**
517 * GET `url` until it answers 200 with a JSON body (fresh workers.dev URLs
518 * take a few seconds to start serving). Mirrors SvelteKit.test.ts's
519 * helper of the same name.
520 */
521const fetchJsonReady = <T>(url: string) =>
522 Effect.gen(function* () {
523 const client = yield* HttpClient.HttpClient;
524 return yield* client.get(url).pipe(
525 Effect.flatMap((res) =>
526 res.status === 200
527 ? Effect.flatMap(res.text, (body) =>
528 Effect.try({
529 try: () => JSON.parse(body) as T,
530 catch: () => new Error(`non-json body: ${body}`),
531 }),
532 )
533 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
534 ),
535 Effect.retry({
536 // Capped interval, ~90s total budget: fresh workers.dev subdomains and
537 // DO namespaces can take over a minute to start serving under
538 // concurrent deploys.
539 schedule: Schedule.min([
540 Schedule.exponential("500 millis"),
541 Schedule.spaced("2 seconds"),
542 ]),
543 times: 45,
544 }),
545 );
546 });
547
548/** POST a JSON body to `url` until it answers 200 with a JSON body. */
549const postJsonReady = <T>(url: string, body: unknown) =>
550 HttpClient.execute(
551 HttpClientRequest.post(url).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
552 ).pipe(
553 Effect.flatMap((res) =>
554 res.status === 200
555 ? Effect.flatMap(res.text, (responseBody) =>
556 Effect.try({
557 try: () => JSON.parse(responseBody) as T,
558 catch: () => new Error(`non-json body: ${responseBody}`),
559 }),
560 )
561 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
562 ),
563 Effect.retry({
564 // Capped interval, ~90s total budget (workers.dev / DO propagation).
565 schedule: Schedule.min([
566 Schedule.exponential("500 millis"),
567 Schedule.spaced("2 seconds"),
568 ]),
569 times: 45,
570 }),
571 );
572
573/** PUT (empty body) to `url` until it answers 200 with a JSON body. */
574const putJsonReady = <T>(url: string) =>
575 HttpClient.execute(HttpClientRequest.put(url)).pipe(
576 Effect.flatMap((res) =>
577 res.status === 200
578 ? Effect.flatMap(res.text, (responseBody) =>
579 Effect.try({
580 try: () => JSON.parse(responseBody) as T,
581 catch: () => new Error(`non-json body: ${responseBody}`),
582 }),
583 )
584 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
585 ),
586 Effect.retry({
587 // Capped interval, ~90s total budget (workers.dev / DO propagation).
588 schedule: Schedule.min([
589 Schedule.exponential("500 millis"),
590 Schedule.spaced("2 seconds"),
591 ]),
592 times: 45,
593 }),
594 );