| 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 | */ |
| 55 | export 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 | */ |
| 84 | const 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). |
| 88 | const 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 | |
| 101 | const 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 | |
| 117 | export const generateWorkerShim = (options: WorkerShimOptions): string => |
| 118 | /* js */ ` |
| 119 | import { server } from ${JSON.stringify(options.serverImport)}; |
| 120 | import { assets, app_path, prerendered, base_path, routes } from ${JSON.stringify(options.manifestImport)}; |
| 121 | import { env } from 'cloudflare:workers'; |
| 122 | |
| 123 | const app_dir = \`/\${app_path}\`; |
| 124 | const immutable = \`\${app_dir}/immutable/\`; |
| 125 | const version_file = \`\${app_dir}/version.json\`; |
| 126 | |
| 127 | // Inline pragma-cache over \`caches.default\` (replaces the upstream |
| 128 | // adapter's external cache dependency). |
| 129 | const cache = caches.default; |
| 130 | |
| 131 | const 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 | |
| 140 | const 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 | |
| 148 | const 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 | */ |
| 164 | let origin; |
| 165 | |
| 166 | const 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 | |
| 178 | export 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(); |