1/**
2 * Build-output / option parity with upstream `@astrojs/cloudflare` v14.1.3:
3 * zero-config sessions, `_redirects` generation, `_headers` immutable
4 * Cache-Control, `base !== "/"` handling, `injectTypes`, the unsupported
5 * upstream-option guard, and build-time env feeding.
6 */
7import * as Effect from "effect/Effect";
8import * as Redacted from "effect/Redacted";
9import * as NodeFsPromises from "node:fs/promises";
10import * as NodeOs from "node:os";
11import * as NodePath from "node:path";
12import { pathToFileURL } from "node:url";
13import { afterEach, describe, expect, it } from "vitest";
14import cloudflareTarget, {
15 distilledCloudflare,
16 usesCloudflareKVSessionDriver,
17 withDevSessionKv,
18 withPrerenderSessionKv,
19 type DistilledCloudflareOptions,
20} from "../cloudflare.ts";
21import {
22 buildAssetsHeadersContent,
23 headersFileHasCacheControlForPath,
24} from "../headers.ts";
25import { applyWorkerEnvToProcess } from "../source.ts";
26
27const noopLogger = {
28 info: () => {},
29 warn: () => {},
30 error: () => {},
31 debug: () => {},
32} as never;
33
34interface CapturedSetup {
35 session?: {
36 driver?: {
37 entrypoint?: string | { toString(): string };
38 config?: Record<string, unknown>;
39 };
40 };
41}
42
43const runConfigSetup = (
44 options: DistilledCloudflareOptions | undefined,
45 command: "dev" | "build",
46 config: Record<string, unknown> = {},
47): CapturedSetup => {
48 const integration = distilledCloudflare(options);
49 let captured: CapturedSetup | undefined;
50 const hook = integration.hooks["astro:config:setup"];
51 if (!hook) throw new Error("astro:config:setup hook missing");
52 void hook({
53 command,
54 config: { vite: {}, image: {}, ...config },
55 updateConfig: (update: unknown) => {
56 captured = update as CapturedSetup;
57 return {} as never;
58 },
59 logger: noopLogger,
60 } as never);
61 if (!captured) throw new Error("updateConfig was not called");
62 return captured;
63};
64
65interface ConfigDoneContext {
66 config: {
67 base: string;
68 trailingSlash?: "always" | "never" | "ignore";
69 output?: string;
70 build: {
71 client: URL;
72 server: URL;
73 assets: string;
74 assetsPrefix?: string | undefined;
75 format?: string;
76 };
77 };
78 injected: Array<{ filename: string; content: string }>;
79}
80
81const makeConfigDoneContext = (
82 root: string,
83 base = "/",
84): ConfigDoneContext => ({
85 config: {
86 base,
87 trailingSlash: "ignore",
88 output: "server",
89 build: {
90 client: pathToFileURL(NodePath.join(root, "dist/client") + "/"),
91 server: pathToFileURL(NodePath.join(root, "dist/server") + "/"),
92 assets: "_astro",
93 assetsPrefix: undefined,
94 format: "directory",
95 },
96 },
97 injected: [],
98});
99
100const runConfigDone = (
101 integration: ReturnType<typeof distilledCloudflare>,
102 ctx: ConfigDoneContext,
103 buildOutput: "static" | "server" = "server",
104) => {
105 const hook = integration.hooks["astro:config:done"];
106 if (!hook) throw new Error("astro:config:done hook missing");
107 void hook({
108 config: ctx.config,
109 buildOutput,
110 setAdapter: () => {},
111 injectTypes: (injectedType: { filename: string; content: string }) => {
112 ctx.injected.push(injectedType);
113 return new URL("file:///dev/null");
114 },
115 } as never);
116};
117
118const runBuildDone = async (
119 integration: ReturnType<typeof distilledCloudflare>,
120 ctx: ConfigDoneContext,
121 routes: Array<Record<string, unknown>> = [],
122 root?: string,
123) => {
124 const routesHook = integration.hooks["astro:routes:resolved"];
125 if (!routesHook) throw new Error("astro:routes:resolved hook missing");
126 void routesHook({ routes } as never);
127 const hook = integration.hooks["astro:build:done"];
128 if (!hook) throw new Error("astro:build:done hook missing");
129 await hook({
130 dir: root
131 ? pathToFileURL(NodePath.join(root, "dist") + "/")
132 : new URL("file:///dev/null/"),
133 assets: new Map(),
134 pages: [],
135 logger: noopLogger,
136 } as never);
137};
138
139const tempDirs: Array<string> = [];
140const makeTempRoot = async (): Promise<string> => {
141 const root = await NodeFsPromises.mkdtemp(
142 NodePath.join(NodeOs.tmpdir(), "distilled-astro-test-"),
143 );
144 tempDirs.push(root);
145 return root;
146};
147
148afterEach(async () => {
149 for (const dir of tempDirs.splice(0)) {
150 await NodeFsPromises.rm(dir, { recursive: true, force: true });
151 }
152});
153
154describe("zero-config sessions", () => {
155 it("defaults the session driver to Cloudflare KV when unset", () => {
156 const captured = runConfigSetup(undefined, "build");
157 expect(captured.session?.driver).toBeDefined();
158 expect(usesCloudflareKVSessionDriver(captured.session as never)).toBe(true);
159 expect(captured.session?.driver?.config).toMatchObject({
160 binding: "SESSION",
161 });
162 });
163
164 it("uses the configured sessionKVBindingName", () => {
165 const captured = runConfigSetup(
166 { sessionKVBindingName: "MY_SESSION" },
167 "build",
168 );
169 expect(captured.session?.driver?.config).toMatchObject({
170 binding: "MY_SESSION",
171 });
172 });
173
174 it("leaves a user-configured driver untouched", () => {
175 const userSession = { driver: "redis" };
176 const captured = runConfigSetup(undefined, "build", {
177 session: userSession,
178 });
179 expect(captured.session).toBe(userSession);
180 });
181
182 it("sessions: false leaves the session config untouched", () => {
183 const captured = runConfigSetup({ sessions: false }, "build");
184 expect(captured.session).toBeUndefined();
185 });
186
187 it("usesCloudflareKVSessionDriver matches by name and entrypoint", () => {
188 expect(usesCloudflareKVSessionDriver(undefined)).toBe(false);
189 expect(usesCloudflareKVSessionDriver({})).toBe(false);
190 expect(
191 usesCloudflareKVSessionDriver({ driver: "cloudflareKVBinding" }),
192 ).toBe(true);
193 expect(
194 usesCloudflareKVSessionDriver({ driver: "cloudflare-kv-binding" }),
195 ).toBe(true);
196 expect(usesCloudflareKVSessionDriver({ driver: "redis" })).toBe(false);
197 expect(
198 usesCloudflareKVSessionDriver({
199 driver: { entrypoint: "unstorage/drivers/cloudflare-kv-binding" },
200 }),
201 ).toBe(true);
202 expect(
203 usesCloudflareKVSessionDriver({
204 driver: { entrypoint: "unstorage/drivers/redis" },
205 }),
206 ).toBe(false);
207 });
208
209 it("withDevSessionKv appends the local KV binding hook", () => {
210 const marker = Effect.succeed({ name: "OTHER", text: "x" }) as never;
211 const withExisting = withDevSessionKv(
212 { worker: { name: "w", bindings: [marker] } as never },
213 "SESSION",
214 );
215 expect(withExisting.worker?.name).toBe("w");
216 expect(withExisting.worker?.bindings).toHaveLength(2);
217 expect(withExisting.worker?.bindings?.[0]).toBe(marker);
218
219 const fromScratch = withDevSessionKv(undefined, "SESSION");
220 expect(fromScratch.worker?.bindings).toHaveLength(1);
221 });
222
223 it("withPrerenderSessionKv injects the local KV only when sessions need it", () => {
224 const injected = withPrerenderSessionKv(undefined, {
225 needsSessionKVBinding: true,
226 sessionDevKV: true,
227 binding: "SESSION",
228 });
229 expect(injected?.worker?.bindings).toHaveLength(1);
230
231 const optedOut = withPrerenderSessionKv(undefined, {
232 needsSessionKVBinding: true,
233 sessionDevKV: false,
234 binding: "SESSION",
235 });
236 expect(optedOut).toBeUndefined();
237
238 const noSessions = withPrerenderSessionKv(
239 { worker: { name: "w" } as never },
240 { needsSessionKVBinding: false, sessionDevKV: true, binding: "SESSION" },
241 );
242 expect(noSessions?.worker?.bindings).toBeUndefined();
243 });
244});
245
246describe("unsupported upstream options", () => {
247 it("rejects upstream adapter options with a clear error", () => {
248 expect(() =>
249 distilledCloudflare({ imageService: "compile" } as never),
250 ).toThrowError(
251 /unsupported @astrojs\/cloudflare option[\s\S]*imageService[\s\S]*passthrough/,
252 );
253 expect(() =>
254 distilledCloudflare({ configPath: "wrangler.json" } as never),
255 ).toThrowError(/configPath/);
256 });
257
258 it("accepts the supported options", () => {
259 expect(() =>
260 distilledCloudflare({
261 sessions: false,
262 sessionDevKV: false,
263 sessionKVBindingName: "S",
264 }),
265 ).not.toThrow();
266 });
267});
268
269describe("astro:config:done", () => {
270 it("injects the cloudflare.d.ts types reference", async () => {
271 const root = await makeTempRoot();
272 const integration = distilledCloudflare();
273 const ctx = makeConfigDoneContext(root);
274 runConfigDone(integration, ctx);
275 expect(ctx.injected).toHaveLength(1);
276 expect(ctx.injected[0]?.filename).toBe("cloudflare.d.ts");
277 expect(ctx.injected[0]?.content).toContain(
278 "@alchemy.run/frontend-frameworks/astro/types.d.ts",
279 );
280 });
281
282 it("keeps build.client untouched for base '/'", async () => {
283 const root = await makeTempRoot();
284 const integration = distilledCloudflare();
285 const ctx = makeConfigDoneContext(root, "/");
286 const before = ctx.config.build.client.href;
287 runConfigDone(integration, ctx);
288 expect(ctx.config.build.client.href).toBe(before);
289 });
290
291 it("nests build.client under the base and reports the original directory", async () => {
292 const root = await makeTempRoot();
293 const reported: Array<string> = [];
294 const integration = distilledCloudflare({
295 onOriginalClientDir: (dir) => reported.push(dir),
296 });
297 const ctx = makeConfigDoneContext(root, "/docs");
298 const original = ctx.config.build.client.href;
299 runConfigDone(integration, ctx);
300 expect(ctx.config.build.client.href).toBe(
301 new URL("./docs/", original).href,
302 );
303 expect(reported).toHaveLength(1);
304 expect(NodePath.resolve(reported[0]!)).toBe(
305 NodePath.join(root, "dist/client"),
306 );
307 });
308});
309
310describe("astro:build:done", () => {
311 const redirectRoute = {
312 type: "redirect",
313 pattern: "/old-about",
314 pathname: "/old-about",
315 entrypoint: undefined,
316 redirect: "/about/",
317 segments: [[{ content: "old-about", dynamic: false, spread: false }]],
318 };
319
320 it("writes _redirects for redirect routes and injects immutable _headers", async () => {
321 const root = await makeTempRoot();
322 const clientDir = NodePath.join(root, "dist/client");
323 await NodeFsPromises.mkdir(clientDir, { recursive: true });
324 const integration = distilledCloudflare();
325 const ctx = makeConfigDoneContext(root);
326 runConfigDone(integration, ctx);
327 await runBuildDone(integration, ctx, [redirectRoute], root);
328
329 const redirects = await NodeFsPromises.readFile(
330 NodePath.join(clientDir, "_redirects"),
331 "utf-8",
332 );
333 expect(redirects).toContain("/old-about");
334 expect(redirects).toContain("/about/");
335 expect(redirects).toContain("301");
336
337 const headers = await NodeFsPromises.readFile(
338 NodePath.join(clientDir, "_headers"),
339 "utf-8",
340 );
341 expect(headers).toContain("/_astro/*");
342 expect(headers).toContain(
343 "Cache-Control: public, max-age=31536000, immutable",
344 );
345 });
346
347 it("appends redirects to an existing _redirects and preserves user _headers rules", async () => {
348 const root = await makeTempRoot();
349 const clientDir = NodePath.join(root, "dist/client");
350 await NodeFsPromises.mkdir(clientDir, { recursive: true });
351 await NodeFsPromises.writeFile(
352 NodePath.join(clientDir, "_redirects"),
353 "/legacy / 302\n",
354 );
355 await NodeFsPromises.writeFile(
356 NodePath.join(clientDir, "_headers"),
357 "/api/*\n X-Custom: yes\n",
358 );
359 const integration = distilledCloudflare();
360 const ctx = makeConfigDoneContext(root);
361 runConfigDone(integration, ctx);
362 await runBuildDone(integration, ctx, [redirectRoute], root);
363
364 const redirects = await NodeFsPromises.readFile(
365 NodePath.join(clientDir, "_redirects"),
366 "utf-8",
367 );
368 expect(redirects).toContain("/legacy / 302");
369 expect(redirects).toContain("/old-about");
370
371 const headers = await NodeFsPromises.readFile(
372 NodePath.join(clientDir, "_headers"),
373 "utf-8",
374 );
375 expect(headers).toContain("X-Custom: yes");
376 expect(headers).toContain("/_astro/*");
377 });
378
379 it("leaves _headers alone when a matching Cache-Control rule already exists", async () => {
380 const root = await makeTempRoot();
381 const clientDir = NodePath.join(root, "dist/client");
382 await NodeFsPromises.mkdir(clientDir, { recursive: true });
383 const existing = "/_astro/*\n Cache-Control: no-store\n";
384 await NodeFsPromises.writeFile(
385 NodePath.join(clientDir, "_headers"),
386 existing,
387 );
388 const integration = distilledCloudflare();
389 const ctx = makeConfigDoneContext(root);
390 runConfigDone(integration, ctx);
391 await runBuildDone(integration, ctx, [], root);
392 const headers = await NodeFsPromises.readFile(
393 NodePath.join(clientDir, "_headers"),
394 "utf-8",
395 );
396 expect(headers).toBe(existing);
397 });
398
399 it("skips the Cache-Control injection when assetsPrefix is set", async () => {
400 const root = await makeTempRoot();
401 const clientDir = NodePath.join(root, "dist/client");
402 await NodeFsPromises.mkdir(clientDir, { recursive: true });
403 const integration = distilledCloudflare();
404 const ctx = makeConfigDoneContext(root);
405 ctx.config.build.assetsPrefix = "https://cdn.example.com";
406 runConfigDone(integration, ctx);
407 await runBuildDone(integration, ctx, [], root);
408 await expect(
409 NodeFsPromises.readFile(NodePath.join(clientDir, "_headers"), "utf-8"),
410 ).rejects.toThrow();
411 });
412
413 it("with base !== '/', moves the special files up to the original client dir", async () => {
414 const root = await makeTempRoot();
415 const clientDir = NodePath.join(root, "dist/client");
416 const nestedDir = NodePath.join(clientDir, "docs");
417 await NodeFsPromises.mkdir(nestedDir, { recursive: true });
418 // Astro writes the special files into the (remapped) client dir.
419 await NodeFsPromises.writeFile(
420 NodePath.join(nestedDir, "_redirects"),
421 "/nested / 302\n",
422 );
423 await NodeFsPromises.writeFile(
424 NodePath.join(nestedDir, ".assetsignore"),
425 "secret.txt\n",
426 );
427 const integration = distilledCloudflare();
428 const ctx = makeConfigDoneContext(root, "/docs");
429 runConfigDone(integration, ctx);
430 await runBuildDone(integration, ctx, [], root);
431
432 const redirects = await NodeFsPromises.readFile(
433 NodePath.join(clientDir, "_redirects"),
434 "utf-8",
435 );
436 expect(redirects).toContain("/nested / 302");
437 const ignore = await NodeFsPromises.readFile(
438 NodePath.join(clientDir, ".assetsignore"),
439 "utf-8",
440 );
441 expect(ignore).toContain("secret.txt");
442 // The base-prefixed headers rule lands at the original client dir root.
443 const headers = await NodeFsPromises.readFile(
444 NodePath.join(clientDir, "_headers"),
445 "utf-8",
446 );
447 expect(headers).toContain("/docs/_astro/*");
448 });
449
450 it("with base !== '/', 404.html survives the client-dir nesting (stays under the base)", async () => {
451 const root = await makeTempRoot();
452 const clientDir = NodePath.join(root, "dist/client");
453 const nestedDir = NodePath.join(clientDir, "docs");
454 await NodeFsPromises.mkdir(nestedDir, { recursive: true });
455 // Astro writes the prerendered 404 page into the (remapped) client dir.
456 await NodeFsPromises.writeFile(
457 NodePath.join(nestedDir, "404.html"),
458 "<h1>not found</h1>",
459 );
460 const integration = distilledCloudflare();
461 const ctx = makeConfigDoneContext(root, "/docs");
462 runConfigDone(integration, ctx, "static");
463 await runBuildDone(integration, ctx, [], root);
464
465 // Only the asset-server special files move up; the 404 page stays nested
466 // under the base, where Cloudflare's `not_found_handling: "404-page"`
467 // nearest-parent lookup finds it for every in-base URL.
468 const nested404 = await NodeFsPromises.readFile(
469 NodePath.join(nestedDir, "404.html"),
470 "utf-8",
471 );
472 expect(nested404).toContain("not found");
473 });
474});
475
476describe("cloudflare target finish (base !== '/')", () => {
477 it("points clientDirectory back at the original client dir", async () => {
478 const root = await makeTempRoot();
479 const target = cloudflareTarget({});
480 const integration = target.integration();
481 const ctx = makeConfigDoneContext(root, "/docs");
482 runConfigDone(integration, ctx);
483 const nested = NodePath.join(root, "dist/client/docs");
484 const output = {
485 clientDirectory: nested,
486 serverModules: undefined,
487 externalWorkspaces: new Set<string>(),
488 };
489 const finished = await Effect.runPromise(
490 target.finish!(output as never, {
491 root,
492 framework: "astro",
493 }) as unknown as Effect.Effect<{
494 clientDirectory: string | undefined;
495 }>,
496 );
497 expect(finished.clientDirectory).toBe(NodePath.join(root, "dist/client"));
498 });
499
500 it("leaves the output untouched for base '/'", async () => {
501 const root = await makeTempRoot();
502 const target = cloudflareTarget({});
503 const integration = target.integration();
504 const ctx = makeConfigDoneContext(root, "/");
505 runConfigDone(integration, ctx);
506 const clientDirectory = NodePath.join(root, "dist/client");
507 const output = {
508 clientDirectory,
509 serverModules: undefined,
510 externalWorkspaces: new Set<string>(),
511 };
512 const finished = await Effect.runPromise(
513 target.finish!(output as never, {
514 root,
515 framework: "astro",
516 }) as unknown as Effect.Effect<{
517 clientDirectory: string | undefined;
518 }>,
519 );
520 expect(finished.clientDirectory).toBe(clientDirectory);
521 });
522
523 it("strips serverModules when the resolved buildOutput is static (assets-only)", async () => {
524 const root = await makeTempRoot();
525 const target = cloudflareTarget({});
526 const integration = target.integration();
527 const ctx = makeConfigDoneContext(root, "/");
528 runConfigDone(integration, ctx, "static");
529 const clientDirectory = NodePath.join(root, "dist/client");
530 const output = {
531 clientDirectory,
532 // Astro bundles an SSR entry even for a fully-static build (it drives
533 // prerendering); the finish pass must drop it from the deploy.
534 serverModules: [{ name: "entry.mjs", content: "export default {}" }],
535 externalWorkspaces: new Set<string>(),
536 };
537 const finished = await Effect.runPromise(
538 target.finish!(output as never, {
539 root,
540 framework: "astro",
541 }) as unknown as Effect.Effect<{
542 clientDirectory: string | undefined;
543 serverModules: unknown;
544 }>,
545 );
546 expect(finished.serverModules).toBeUndefined();
547 expect(finished.clientDirectory).toBe(clientDirectory);
548 });
549
550 it("static + base !== '/': assets-only AND clientDirectory back at the original dir", async () => {
551 const root = await makeTempRoot();
552 const target = cloudflareTarget({});
553 const integration = target.integration();
554 const ctx = makeConfigDoneContext(root, "/docs");
555 runConfigDone(integration, ctx, "static");
556 const nested = NodePath.join(root, "dist/client/docs");
557 const output = {
558 clientDirectory: nested,
559 serverModules: [{ name: "entry.mjs", content: "export default {}" }],
560 externalWorkspaces: new Set<string>(),
561 };
562 const finished = await Effect.runPromise(
563 target.finish!(output as never, {
564 root,
565 framework: "astro",
566 }) as unknown as Effect.Effect<{
567 clientDirectory: string | undefined;
568 serverModules: unknown;
569 }>,
570 );
571 expect(finished.serverModules).toBeUndefined();
572 expect(finished.clientDirectory).toBe(NodePath.join(root, "dist/client"));
573 });
574});
575
576describe("headers utility", () => {
577 it("detects existing Cache-Control rules (set and detach forms)", () => {
578 expect(
579 headersFileHasCacheControlForPath(
580 "/_astro/*\n Cache-Control: no-store\n",
581 "/_astro/probe",
582 ),
583 ).toBe(true);
584 expect(
585 headersFileHasCacheControlForPath(
586 "/_astro/*\n ! Cache-Control\n",
587 "/_astro/probe",
588 ),
589 ).toBe(true);
590 expect(
591 headersFileHasCacheControlForPath(
592 "/api/*\n Cache-Control: no-store\n",
593 "/_astro/probe",
594 ),
595 ).toBe(false);
596 expect(
597 headersFileHasCacheControlForPath(
598 "/_astro/*\n X-Custom: 1\n",
599 "/_astro/probe",
600 ),
601 ).toBe(false);
602 expect(
603 headersFileHasCacheControlForPath(
604 "https://example.com/_astro/*\n Cache-Control: no-store\n",
605 "/_astro/probe",
606 ),
607 ).toBe(true);
608 expect(
609 headersFileHasCacheControlForPath(
610 "/:dir/file\n Cache-Control: x\n",
611 "/_astro/file",
612 ),
613 ).toBe(true);
614 });
615
616 it("builds the immutable block, prepending it to existing content", async () => {
617 const fresh = await buildAssetsHeadersContent(
618 { assetsDir: "_astro", basePrefix: "", headersPath: "unused" },
619 async () => {
620 throw new Error("missing");
621 },
622 );
623 expect(fresh?.assetsPattern).toBe("/_astro/*");
624 expect(fresh?.content).toBe(
625 "/_astro/*\n Cache-Control: public, max-age=31536000, immutable\n",
626 );
627
628 const merged = await buildAssetsHeadersContent(
629 { assetsDir: "_astro", basePrefix: "/docs", headersPath: "unused" },
630 async () => "/api/*\n X-Custom: yes",
631 );
632 expect(merged?.assetsPattern).toBe("/docs/_astro/*");
633 expect(merged?.content).toContain("/docs/_astro/*\n Cache-Control:");
634 expect(merged?.content.endsWith("/api/*\n X-Custom: yes\n")).toBe(true);
635 });
636});
637
638describe("applyWorkerEnvToProcess", () => {
639 it("applies literal env values and skips non-literals", async () => {
640 const key = (suffix: string) => `DISTILLED_ASTRO_TEST_${suffix}`;
641 for (const suffix of ["STR", "SECRET", "NUM", "BOOL", "EFFECT"]) {
642 delete process.env[key(suffix)];
643 }
644 await Effect.runPromise(
645 applyWorkerEnvToProcess({
646 [key("STR")]: "plain",
647 [key("SECRET")]: Redacted.make("hidden"),
648 [key("NUM")]: 42,
649 [key("BOOL")]: true,
650 [key("EFFECT")]: Effect.succeed("nope"),
651 }),
652 );
653 expect(process.env[key("STR")]).toBe("plain");
654 expect(process.env[key("SECRET")]).toBe("hidden");
655 expect(process.env[key("NUM")]).toBe("42");
656 expect(process.env[key("BOOL")]).toBe("true");
657 expect(process.env[key("EFFECT")]).toBeUndefined();
658 for (const suffix of ["STR", "SECRET", "NUM", "BOOL"]) {
659 delete process.env[key(suffix)];
660 }
661 });
662});