1import { describe, expect, it } from "vitest";
2import {
3 appendHeaders,
4 generateAssetsIgnore,
5 generateHeaders,
6 generateRedirects,
7 generateWorkerShim,
8 makeCloudflareAdapter,
9 makeDevPlatformEmulator,
10 type DevPlatformProxy,
11 type OpenDevPlatformProxy,
12 type OpenDevPlatformProxyOptions,
13} from "../cloudflare.ts";
14
15describe("generateWorkerShim", () => {
16 const shim = generateWorkerShim({
17 serverImport: "./../output/server/index.js",
18 manifestImport: "./../cloudflare-tmp/manifest.js",
19 assetsBinding: "ASSETS",
20 });
21
22 it("imports the server and manifest through real relative paths", () => {
23 expect(shim).toContain(
24 `import { server } from "./../output/server/index.js";`,
25 );
26 expect(shim).toContain(
27 `import { assets, app_path, prerendered, base_path, routes } from "./../cloudflare-tmp/manifest.js";`,
28 );
29 });
30
31 it("uses module-level env from cloudflare:workers", () => {
32 expect(shim).toContain(`import { env } from 'cloudflare:workers';`);
33 });
34
35 it("serves static and prerendered files through the assets binding", () => {
36 expect(shim).toContain("env.ASSETS.fetch(req)");
37 expect(shim).toContain("env.ASSETS.fetch(url)");
38 });
39
40 it("respects a custom assets binding name", () => {
41 const custom = generateWorkerShim({
42 serverImport: "./server/index.js",
43 manifestImport: "./manifest.js",
44 assetsBinding: "STATIC",
45 });
46 expect(custom).toContain("env.STATIC.fetch(req)");
47 expect(custom).not.toContain("env.ASSETS.fetch");
48 });
49
50 it("replaces worktop with a caches.default pragma cache", () => {
51 expect(shim).not.toContain("worktop");
52 expect(shim).toContain("caches.default");
53 expect(shim).toContain("cache_lookup");
54 expect(shim).toContain("cache_save");
55 });
56
57 it("exposes the platform to server.respond", () => {
58 expect(shim).toContain("platform: { env, ctx, caches, cf: req.cf }");
59 });
60
61 describe("SPA not-found deferral", () => {
62 const spa = generateWorkerShim({
63 serverImport: "./../output/server/index.js",
64 manifestImport: "./../cloudflare-tmp/manifest.js",
65 assetsBinding: "ASSETS",
66 notFoundHandling: "single-page-application",
67 });
68
69 it("emits no deferral by default (upstream parity)", () => {
70 expect(shim).not.toContain("matches_kit_route");
71 expect(shim).not.toContain("res.status === 404");
72 });
73
74 it("emits no deferral for 404-page (upstream parity)", () => {
75 const fourOhFour = generateWorkerShim({
76 serverImport: "./server/index.js",
77 manifestImport: "./manifest.js",
78 assetsBinding: "ASSETS",
79 notFoundHandling: "404-page",
80 });
81 expect(fourOhFour).not.toContain("matches_kit_route");
82 expect(fourOhFour).not.toContain("res.status === 404");
83 });
84
85 it("defers router-level 404s to the assets binding for SPA builds", () => {
86 expect(spa).toContain("const matches_kit_route = (pathname)");
87 expect(spa).toContain("res.status === 404");
88 expect(spa).toContain("!matches_kit_route(pathname)");
89 });
90
91 it("only defers navigation-shaped requests (GET/HEAD accepting text/html)", () => {
92 expect(spa).toContain("(req.method === 'GET' || req.method === 'HEAD')");
93 expect(spa).toContain(
94 "(req.headers.get('accept') || '').includes('text/html')",
95 );
96 });
97
98 it("probes kit's route patterns with the base path stripped", () => {
99 expect(spa).toContain("path.slice(base_path.length) || '/'");
100 expect(spa).toContain("route.pattern.test(path)");
101 expect(spa).toContain("for (const route of routes)");
102 });
103
104 it("defers through the configured assets binding", () => {
105 const custom = generateWorkerShim({
106 serverImport: "./server/index.js",
107 manifestImport: "./manifest.js",
108 assetsBinding: "STATIC",
109 notFoundHandling: "single-page-application",
110 });
111 expect(custom).toContain("res = await env.STATIC.fetch(req);");
112 expect(custom).not.toContain("env.ASSETS.fetch");
113 });
114 });
115});
116
117describe("appendHeaders", () => {
118 it("adds a new rule block", () => {
119 const content = appendHeaders("/_app/*", ["X-Robots-Tag: noindex"], "");
120 expect(content).toContain("/_app/*\n X-Robots-Tag: noindex");
121 expect(content).toContain("AUTOGENERATED SVELTE IMMUTABLE HEADERS");
122 });
123
124 it("merges into an existing block for the same URL", () => {
125 const existing = "/_app/*\n X-Existing: yes";
126 const content = appendHeaders(
127 "/_app/*",
128 ["X-Robots-Tag: noindex"],
129 existing,
130 );
131 expect(content).toBe("/_app/*\n X-Robots-Tag: noindex\n X-Existing: yes");
132 });
133});
134
135describe("generateHeaders", () => {
136 it("emits noindex and immutable cache rules for the app dir", () => {
137 const content = generateHeaders("_app");
138 expect(content).toContain(
139 "/_app/*\n X-Robots-Tag: noindex\n Cache-Control: no-cache",
140 );
141 expect(content).toContain(
142 "/_app/immutable/*\n ! Cache-Control\n Cache-Control: public, immutable, max-age=31536000",
143 );
144 });
145
146 it("preserves user-authored headers", () => {
147 const content = generateHeaders("_app", "/api/*\n X-Fixture: yes");
148 expect(content).toContain("/api/*\n X-Fixture: yes");
149 expect(content).toContain("/_app/immutable/*");
150 });
151});
152
153describe("generateRedirects", () => {
154 it("returns undefined when there is nothing to write", () => {
155 expect(generateRedirects(new Map())).toBeUndefined();
156 });
157
158 it("emits prerendered redirect rules", () => {
159 const content = generateRedirects(
160 new Map([["/old", { status: 301, location: "/new" }]]),
161 );
162 expect(content).toContain("/old /new 301");
163 expect(content).toContain("AUTOGENERATED SVELTE PRERENDERED REDIRECTS");
164 });
165
166 it("prepends user-authored redirects", () => {
167 const content = generateRedirects(
168 new Map([["/old", { status: 301, location: "/new" }]]),
169 "/legacy /modern 302",
170 );
171 expect(content?.startsWith("/legacy /modern 302")).toBe(true);
172 expect(content).toContain("/old /new 301");
173 });
174});
175
176describe("generateAssetsIgnore", () => {
177 it("excludes the worker entry and metadata files from assets", () => {
178 const content = generateAssetsIgnore();
179 expect(content.split("\n").filter(Boolean)).toEqual([
180 "_worker.js",
181 "_routes.json",
182 "_headers",
183 "_redirects",
184 ]);
185 });
186});
187
188interface FakeProxyHarness {
189 readonly openProxy: OpenDevPlatformProxy;
190 readonly opens: Array<OpenDevPlatformProxyOptions>;
191 readonly disposals: () => number;
192}
193
194const makeFakeProxy = (env: Record<string, unknown>): FakeProxyHarness => {
195 const opens: Array<OpenDevPlatformProxyOptions> = [];
196 let disposed = 0;
197 const proxy: DevPlatformProxy = {
198 env,
199 cf: { colo: "TST", country: "US" },
200 ctx: { waitUntil: (_promise: Promise<unknown>) => {} },
201 caches: { default: "fake-cache" },
202 dispose: async () => {
203 disposed += 1;
204 },
205 };
206 return {
207 openProxy: async (options) => {
208 opens.push(options);
209 return proxy;
210 },
211 opens,
212 disposals: () => disposed,
213 };
214};
215
216type PlatformShape = {
217 env: Record<string, unknown>;
218 ctx: { waitUntil: (promise: Promise<unknown>) => void };
219 caches: unknown;
220 cf: Record<string, unknown>;
221};
222
223describe("makeDevPlatformEmulator", () => {
224 it("opens the proxy lazily and serves env, ctx, caches, and cf from it", async () => {
225 const fake = makeFakeProxy({ FROM_PROXY: "proxied" });
226 const emulator = makeDevPlatformEmulator({
227 bindings: ["binding-hook"],
228 compatibilityDate: "2026-03-10",
229 compatibilityFlags: ["nodejs_compat"],
230 openProxy: fake.openProxy,
231 });
232 // nothing opened until the first platform() call
233 expect(fake.opens).toHaveLength(0);
234 const platform = (await emulator.platform?.({
235 config: {},
236 prerender: false,
237 })) as PlatformShape;
238 expect(fake.opens).toHaveLength(1);
239 expect(fake.opens[0]).toMatchObject({
240 name: "sveltekit-dev-platform-proxy",
241 compatibilityDate: "2026-03-10",
242 compatibilityFlags: ["nodejs_compat"],
243 bindings: ["binding-hook"],
244 });
245 expect(platform.env["FROM_PROXY"]).toBe("proxied");
246 expect(platform.cf["colo"]).toBe("TST");
247 expect(platform.caches).toEqual({ default: "fake-cache" });
248 expect(() => platform.ctx.waitUntil(Promise.resolve())).not.toThrow();
249 });
250
251 it("opens a single proxy across platform() calls", async () => {
252 const fake = makeFakeProxy({});
253 const emulator = makeDevPlatformEmulator({ openProxy: fake.openProxy });
254 await emulator.platform?.({ config: {}, prerender: false });
255 await emulator.platform?.({ config: {}, prerender: true });
256 await emulator.platform?.({ config: {}, prerender: false });
257 expect(fake.opens).toHaveLength(1);
258 });
259
260 it("applies literal env overrides on top of proxied values", async () => {
261 const fake = makeFakeProxy({ SHARED: "proxied", FROM_PROXY: "proxied" });
262 const emulator = makeDevPlatformEmulator({
263 env: { SHARED: "literal-wins", LITERAL_ONLY: "literal" },
264 openProxy: fake.openProxy,
265 });
266 const platform = (await emulator.platform?.({
267 config: {},
268 prerender: false,
269 })) as PlatformShape;
270 expect(platform.env).toEqual({
271 FROM_PROXY: "proxied",
272 SHARED: "literal-wins",
273 LITERAL_ONLY: "literal",
274 });
275 });
276
277 it("throws on env access in prerenderable routes", async () => {
278 const fake = makeFakeProxy({ FROM_PROXY: "proxied" });
279 const emulator = makeDevPlatformEmulator({
280 env: { LITERAL: "value" },
281 openProxy: fake.openProxy,
282 });
283 const platform = (await emulator.platform?.({
284 config: {},
285 prerender: true,
286 })) as PlatformShape;
287 expect(() => platform.env["FROM_PROXY"]).toThrow(
288 "Cannot access platform.env.FROM_PROXY in a prerenderable route",
289 );
290 expect(() => platform.env["LITERAL"]).toThrow(
291 "Cannot access platform.env.LITERAL in a prerenderable route",
292 );
293 });
294
295 it("dispose tears down an opened proxy and is a no-op before open", async () => {
296 const untouched = makeFakeProxy({});
297 const idle = makeDevPlatformEmulator({ openProxy: untouched.openProxy });
298 await idle.dispose();
299 expect(untouched.opens).toHaveLength(0);
300 expect(untouched.disposals()).toBe(0);
301
302 const fake = makeFakeProxy({});
303 const emulator = makeDevPlatformEmulator({ openProxy: fake.openProxy });
304 await emulator.platform?.({ config: {}, prerender: false });
305 await emulator.dispose();
306 expect(fake.disposals()).toBe(1);
307 });
308});
309
310describe("makeCloudflareAdapter emulation", () => {
311 it("serves the proxy-backed platform when dev platform options are present", async () => {
312 const fake = makeFakeProxy({ FROM_PROXY: "proxied" });
313 const adapter = makeCloudflareAdapter({
314 platform: { bindings: ["hook"], openProxy: fake.openProxy },
315 });
316 const emulator = await adapter.emulate?.();
317 const platform = (await emulator?.platform?.({
318 config: {},
319 prerender: false,
320 })) as PlatformShape;
321 expect(platform.env["FROM_PROXY"]).toBe("proxied");
322 await adapter.dispose();
323 expect(fake.disposals()).toBe(1);
324 });
325
326 it("memoizes the emulator across emulate() calls", async () => {
327 const fake = makeFakeProxy({});
328 const adapter = makeCloudflareAdapter({
329 platform: { openProxy: fake.openProxy },
330 });
331 const first = await adapter.emulate?.();
332 const second = await adapter.emulate?.();
333 expect(first).toBe(second);
334 await first?.platform?.({ config: {}, prerender: false });
335 await second?.platform?.({ config: {}, prerender: false });
336 expect(fake.opens).toHaveLength(1);
337 });
338
339 it("serves an inert empty platform for production builds (no proxy)", async () => {
340 const adapter = makeCloudflareAdapter({});
341 const emulator = await adapter.emulate?.();
342 const platform = (await emulator?.platform?.({
343 config: {},
344 prerender: false,
345 })) as PlatformShape & {
346 caches: { default: { match: () => Promise<undefined> } };
347 };
348 expect(platform.env).toEqual({});
349 expect(platform.cf).toEqual({});
350 await expect(platform.caches.default.match()).resolves.toBeUndefined();
351 // disposing without a proxy is a no-op
352 await adapter.dispose();
353 });
354});