1// Alchemy modifications are licensed under Apache-2.0.
2// This file includes third-party code; see /THIRD_PARTY_LICENSES.md.
3/**
4 * The generated Cloudflare Worker entry (`_worker.js`) for a SvelteKit build.
5 *
6 * A fork of `@sveltejs/adapter-cloudflare`'s `src/worker.js` with three
7 * differences:
8 *
9 * - The `SERVER` / `MANIFEST` / `ASSETS` placeholders are templated directly
10 * with real relative import paths, so there is no publish-time prebundle or
11 * `builder.copy` string replacement.
12 * - `worktop/cfw.cache` is replaced with an inline `caches.default` wrapper
13 * (same lookup/save semantics), dropping the dependency.
14 * - With `notFoundHandling: "single-page-application"`, router-level
15 * not-founds on navigation-shaped requests are deferred to the assets
16 * layer (see below) so the configured `not_found_handling` governs.
17 *
18 * ## SPA not-found deferral (divergence from upstream)
19 *
20 * Upstream's `worker.js` never defers: kit's `server.respond` renders its
21 * own 404 error page for unmatched routes, and the assets-layer
22 * `not_found_handling` only applies to requests the worker never sees
23 * (asset-layer navigations under the `Sec-Fetch-Mode: navigate` preference
24 * flag) or to `env.ASSETS.fetch` misses. That leaves the configured
25 * `single-page-application` fallback unreachable for navigations that DO
26 * invoke the worker (e.g. plain `fetch` without `Sec-Fetch-Mode`).
27 *
28 * With `notFoundHandling: "single-page-application"` the shim therefore
29 * defers to `env.ASSETS.fetch(req)` — where the asset worker applies the
30 * configured `not_found_handling` and serves the generated `index.html`
31 * fallback — when ALL of:
32 *
33 * - `server.respond` returned a 404, AND
34 * - the request is navigation-shaped: `GET`/`HEAD` with `Accept`
35 * containing `text/html` (deliberately excluding kit data/route-resolution
36 * requests and API `fetch`es), AND
37 * - no kit route *pattern* matches the pathname — the most conservative
38 * available signal that the 404 is kit's router-level "no route" error
39 * rather than an intentional endpoint- or page-produced 404 (upstream has
40 * no deferral, so there are no upstream semantics to mirror; kit marks
41 * router 404s with no header). Endpoints only exist on matched routes, so
42 * endpoint 404s are never deferred. A pattern match whose param matchers
43 * fail is treated as matched (kit's own 404 page renders), as is a 404
44 * produced by a user `handle` hook on a path that happens to match a
45 * route pattern.
46 *
47 * `notFoundHandling: "404-page"` keeps exact upstream behavior (no
48 * deferral): upstream documents the `404.html` page as covering asset-layer
49 * misses only, with router-level 404s still rendered by kit.
50 *
51 * The emitted module is *unbundled* — its imports reach into
52 * `.svelte-kit/output/server/**` — and is the input for the rolldown pass
53 * that produces the final workerd-ready modules.
54 */
55export interface WorkerShimOptions {
56 /**
57 * Relative import path to the generated server-instance module (kit's
58 * `generateServerInstance` output, exporting a pre-built `server`).
59 */
60 readonly serverImport: string;
61 /** Relative import path to the generated route-manifest module. */
62 readonly manifestImport: string;
63 /** Name of the static-assets binding. */
64 readonly assetsBinding: string;
65 /**
66 * The assets-layer `not_found_handling` the build was produced for. With
67 * `"single-page-application"` the shim defers router-level not-founds on
68 * navigation-shaped requests to the assets binding (see the module doc);
69 * any other value emits upstream-parity behavior (no deferral).
70 * @default "none"
71 */
72 readonly notFoundHandling?:
73 | "none"
74 | "404-page"
75 | "single-page-application"
76 | undefined;
77}
78
79/**
80 * The SPA-deferral helper emitted ahead of the fetch handler: kit's
81 * `find_route` pattern probe, minus param matchers (a pattern match with
82 * failing matchers is conservatively treated as matched).
83 */
84const spaRouteProbe = /* js */ `
85// Router-level match probe for the SPA not-found deferral: mirrors kit's
86// find_route pattern test (without param matchers — a pattern match whose
87// matchers fail keeps kit's own 404 rendering).
88const matches_kit_route = (pathname) => {
89 let path = pathname;
90 if (base_path) {
91 if (!path.startsWith(base_path)) return false;
92 path = path.slice(base_path.length) || '/';
93 }
94 for (const route of routes) {
95 if (route.pattern.test(path)) return true;
96 }
97 return false;
98};
99`;
100
101const spaDeferral = (assetsBinding: string): string => /* js */ `
102 // not_found_handling: "single-page-application" — defer router-level
103 // not-founds on navigation-shaped requests (GET/HEAD accepting
104 // text/html) to the assets layer, which serves the generated
105 // index.html fallback. Intentional endpoint/page 404s (matched
106 // routes) are never deferred.
107 if (
108 res.status === 404 &&
109 (req.method === 'GET' || req.method === 'HEAD') &&
110 (req.headers.get('accept') || '').includes('text/html') &&
111 !matches_kit_route(pathname)
112 ) {
113 res = await env.${assetsBinding}.fetch(req);
114 }
115`;
116
117export const generateWorkerShim = (options: WorkerShimOptions): string =>
118 /* js */ `
119import { server } from ${JSON.stringify(options.serverImport)};
120import { assets, app_path, prerendered, base_path, routes } from ${JSON.stringify(options.manifestImport)};
121import { env } from 'cloudflare:workers';
122
123const app_dir = \`/\${app_path}\`;
124const immutable = \`\${app_dir}/immutable/\`;
125const version_file = \`\${app_dir}/version.json\`;
126
127// Inline pragma-cache over \`caches.default\` (replaces the upstream
128// adapter's external cache dependency).
129const cache = caches.default;
130
131const is_cacheable = (res) => {
132 if (res.status === 206) return false;
133 const vary = res.headers.get('vary') || '';
134 if (vary.includes('*')) return false;
135 const control = res.headers.get('cache-control') || '';
136 if (/(private|no-cache|no-store)/i.test(control)) return false;
137 return true;
138};
139
140const cache_lookup = async (req) => {
141 const is_head = req.method === 'HEAD';
142 if (is_head) req = new Request(req, { method: 'GET' });
143 let res = await cache.match(req);
144 if (is_head && res) res = new Response(null, res);
145 return res;
146};
147
148const cache_save = (req, res, ctx) => {
149 if ((req.method === 'GET' || req.method === 'HEAD') && is_cacheable(res)) {
150 if (res.headers.has('set-cookie')) {
151 res = new Response(res.body, res);
152 res.headers.append('cache-control', 'private=Set-Cookie');
153 }
154 ctx.waitUntil(cache.put(req, res.clone()));
155 }
156 return res;
157};
158
159${options.notFoundHandling === "single-page-application" ? spaRouteProbe : ""}
160/**
161 * We don't know the origin until we receive a request, but that's guaranteed
162 * to happen before we call \`read\`.
163 */
164let origin;
165
166const initialized = server.init({
167 env,
168 read: async (file) => {
169 const url = \`\${origin}/\${file}\`;
170 const response = await env.${options.assetsBinding}.fetch(url);
171 if (!response.ok) {
172 throw new Error(\`read(...) failed: could not fetch \${url} (\${response.status})\`);
173 }
174 return response.body;
175 },
176});
177
178export default {
179 async fetch(req, env, ctx) {
180 if (!origin) {
181 origin = new URL(req.url).origin;
182 }
183
184 // always await initialization to prevent race condition with concurrent requests
185 await initialized;
186
187 // skip cache if "cache-control: no-cache" in request
188 let pragma = req.headers.get('cache-control') || '';
189 let res = !pragma.includes('no-cache') && (await cache_lookup(req));
190 if (res) return res;
191
192 let { pathname, search } = new URL(req.url);
193 try {
194 pathname = decodeURIComponent(pathname);
195 } catch {
196 // ignore invalid URI
197 }
198
199 const stripped_pathname = pathname.replace(/\\/$/, '');
200
201 // files in /static, the service worker, and Vite-imported server assets
202 let is_static_asset = false;
203 const filename = stripped_pathname.slice(base_path.length + 1);
204 if (filename) {
205 is_static_asset =
206 assets.has(filename) || assets.has(filename + '/index.html');
207 }
208
209 let location = pathname.at(-1) === '/' ? stripped_pathname : pathname + '/';
210
211 if (
212 is_static_asset ||
213 prerendered.has(pathname) ||
214 pathname === version_file ||
215 pathname.startsWith(immutable)
216 ) {
217 res = await env.${options.assetsBinding}.fetch(req);
218 } else if (location && prerendered.has(location)) {
219 // trailing slash redirect for prerendered pages
220 if (search) location += search;
221 res = new Response('', { status: 308, headers: { location } });
222 } else {
223 // dynamically-generated pages
224 res = await server.respond(req, {
225 platform: { env, ctx, caches, cf: req.cf },
226 getClientAddress() {
227 return req.headers.get('cf-connecting-ip');
228 },
229 });
230${options.notFoundHandling === "single-page-application" ? spaDeferral(options.assetsBinding) : ""} }
231
232 // write to the cache only if the response is not an error;
233 // \`cache_save\` handles the Cache-Control and Vary headers
234 pragma = res.headers.get('cache-control') || '';
235 return pragma && res.status < 400 ? cache_save(req, res, ctx) : res;
236 },
237};
238`.trimStart();