diff --git a/.changeset/green-students-lay.md b/.changeset/green-students-lay.md new file mode 100644 index 000000000000..cbd3f456f863 --- /dev/null +++ b/.changeset/green-students-lay.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-cloudflare-workers': major +--- + +feat: remove esbuild step diff --git a/.changeset/new-kings-talk.md b/.changeset/new-kings-talk.md new file mode 100644 index 000000000000..8827bee988af --- /dev/null +++ b/.changeset/new-kings-talk.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-cloudflare': patch +--- + +fix: change `esbuild` to a dev dependency diff --git a/.changeset/ninety-fans-end.md b/.changeset/ninety-fans-end.md new file mode 100644 index 000000000000..b617cc09d02f --- /dev/null +++ b/.changeset/ninety-fans-end.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-cloudflare': patch +--- + +fix: serve files from `_app/*` from the worker if the `_app/*` route was not excluded diff --git a/.changeset/smart-owls-trade.md b/.changeset/smart-owls-trade.md new file mode 100644 index 000000000000..ec9ca1b94e74 --- /dev/null +++ b/.changeset/smart-owls-trade.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-cloudflare-workers': major +--- + +feat: use Workers Static Assets instead of Workers Sites diff --git a/.changeset/weak-chairs-think.md b/.changeset/weak-chairs-think.md new file mode 100644 index 000000000000..0fc95fa48b0b --- /dev/null +++ b/.changeset/weak-chairs-think.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-cloudflare-workers': minor +--- + +feat: optimize cache usage with `worktop` diff --git a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md index c963795b61da..72fd22a1eb3a 100644 --- a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md +++ b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md @@ -6,11 +6,7 @@ To deploy to [Cloudflare Pages](https://pages.cloudflare.com/), use [`adapter-cl This adapter will be installed by default when you use [`adapter-auto`](adapter-auto). If you plan on staying with Cloudflare Pages, you can switch from [`adapter-auto`](adapter-auto) to using this adapter directly so that `event.platform` is emulated during local development, type declarations are automatically applied, and the ability to set Cloudflare-specific options is provided. -## Comparisons - -- `adapter-cloudflare` – supports all SvelteKit features; builds for [Cloudflare Pages](https://blog.cloudflare.com/cloudflare-pages-goes-full-stack/) -- `adapter-cloudflare-workers` – supports all SvelteKit features; builds for Cloudflare Workers -- `adapter-static` – only produces client-side static assets; compatible with Cloudflare Pages +> [!NOTE] Unless you have a specific reason to use `adapter-cloudflare`, it's recommended that you use [`adapter-cloudflare-workers`](adapter-cloudflare-workers) instead since Cloudflare plans to deprecate Cloudflare Pages in favour of Cloudflare Workers. Refer to the [compatibility matrix](https://developers.cloudflare.com/workers/static-assets/compatibility-matrix/) for more information. ## Usage @@ -62,7 +58,7 @@ Preferences for the emulated `platform.env` local bindings. See the [getPlatform Please follow the [Get Started Guide](https://developers.cloudflare.com/pages/get-started/) for Cloudflare Pages to begin. -When configuring your project settings, you must use the following settings: +If you're using the [Git integration](https://developers.cloudflare.com/pages/get-started/git-integration/), your build settings should look like this: - **Framework preset** – SvelteKit - **Build command** – `npm run build` or `vite build` @@ -101,7 +97,7 @@ declare global { export {}; ``` -### Testing Locally +### Testing locally Cloudflare Workers specific values in the `platform` property are emulated during dev and preview modes. Local [bindings](https://developers.cloudflare.com/pages/functions/bindings/) are created based on your [Wrangler configuration file](https://developers.cloudflare.com/pages/functions/wrangler-configuration/#local-development) and are used to populate `platform.env` during development and preview. Use the adapter config [`platformProxy` option](#Options-platformProxy) to change your preferences for the bindings. diff --git a/documentation/docs/25-build-and-deploy/70-adapter-cloudflare-workers.md b/documentation/docs/25-build-and-deploy/70-adapter-cloudflare-workers.md index 71197d9daabe..551a5f1d27a8 100644 --- a/documentation/docs/25-build-and-deploy/70-adapter-cloudflare-workers.md +++ b/documentation/docs/25-build-and-deploy/70-adapter-cloudflare-workers.md @@ -4,7 +4,11 @@ title: Cloudflare Workers To deploy to [Cloudflare Workers](https://workers.cloudflare.com/), use [`adapter-cloudflare-workers`](https://github.com/sveltejs/kit/tree/main/packages/adapter-cloudflare-workers). -> [!NOTE] Unless you have a specific reason to use `adapter-cloudflare-workers`, it's recommended that you use [`adapter-cloudflare`](adapter-cloudflare) instead. Both adapters have equivalent functionality, but Cloudflare Pages offers features like GitHub integration with automatic builds and deploys, preview deployments, instant rollback and so on. +## Comparisons + +- `adapter-cloudflare-workers` – supports all SvelteKit features; builds for Cloudflare Workers +- `adapter-cloudflare` – supports all SvelteKit features; builds for Cloudflare Pages +- `adapter-static` – only produces client-side static assets; compatible with Cloudflare Pages and Cloudflare Workers ## Usage @@ -34,7 +38,7 @@ Path to your [Wrangler configuration file](https://developers.cloudflare.com/wor Preferences for the emulated `platform.env` local bindings. See the [getPlatformProxy](https://developers.cloudflare.com/workers/wrangler/api/#parameters-1) Wrangler API documentation for a full list of options. -## Basic Configuration +## Basic configuration This adapter expects to find a [Wrangler configuration file](https://developers.cloudflare.com/workers/configuration/sites/configuration/) in the project root. It should look something like this: @@ -42,38 +46,18 @@ This adapter expects to find a [Wrangler configuration file](https://developers. /// file: wrangler.jsonc { "name": "", - "account_id": "", - "main": "./.cloudflare/worker.js", - "site": { - "bucket": "./.cloudflare/public" - }, - "build": { - "command": "npm run build" - }, - "compatibility_date": "2021-11-12" + "main": ".svelte-kit/cloudflare/_worker.js", + "compatibility_date": "2025-01-01", + "assets": { + "binding": "ASSETS", + "directory": ".svelte-kit/cloudflare", + } } ``` -`` can be anything. `` can be found by running `wrangler whoami` using the Wrangler CLI tool or by logging into your [Cloudflare dashboard](https://dash.cloudflare.com) and grabbing it from the end of the URL: +## Deployment -``` -https://dash.cloudflare.com//home -``` - -> [!NOTE] You should add the `.cloudflare` directory (or whichever directories you specified for `main` and `site.bucket`) and the `.wrangler` directory to your `.gitignore`. - -You will need to install [Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/) and log in, if you haven't already: - -```sh -npm i -D wrangler -wrangler login -``` - -Then, you can build your app and deploy it: - -```sh -wrangler deploy -``` +Please follow the [framework guide](https://developers.cloudflare.com/workers/frameworks/framework-guides/svelte/) for Cloudflare Workers to begin. ## Runtime APIs @@ -108,12 +92,16 @@ declare global { export {}; ``` -### Testing Locally +### Testing locally Cloudflare Workers specific values in the `platform` property are emulated during dev and preview modes. Local [bindings](https://developers.cloudflare.com/workers/wrangler/configuration/#bindings) are created based on your [Wrangler configuration file](https://developers.cloudflare.com/workers/wrangler/) and are used to populate `platform.env` during development and preview. Use the adapter config [`platformProxy` option](#Options-platformProxy) to change your preferences for the bindings. For testing the build, you should use [Wrangler](https://developers.cloudflare.com/workers/wrangler/) **version 3**. Once you have built your site, run `wrangler dev`. +## Notes + +The [`_headers`](https://developers.cloudflare.com/pages/configuration/headers/) and [`_redirects`](https://developers.cloudflare.com/pages/configuration/redirects/) files specific to Cloudflare Pages can be used for static asset responses (like images) by putting them into the project root folder. + ## Troubleshooting ### Node.js compatibility @@ -134,3 +122,31 @@ When deploying your application, the server generated by SvelteKit is bundled in ### Accessing the file system You can't use `fs` in Cloudflare Workers — you must [prerender](page-options#prerender) the routes in question. + +## Migrating from Workers Sites to Workers Static Assets + +Cloudflare no longer recommends using [Workers Sites](https://developers.cloudflare.com/workers/configuration/sites/configuration/) and instead recommends using [Workers Static Assets](https://developers.cloudflare.com/workers/static-assets/). To migrate, remove all `site` configuration settings from your Wrangler configuration file and add the `assets.directory` and `assets.binding` configuration settings: + +### wrangler.toml + +```toml +/// file: wrangler.toml +---site.bucket = ".cloudflare/public"--- ++++assets.directory = ".cloudflare/public" +assets.binding = "ASSETS"+++ +``` + +### wrangler.jsonc + +```jsonc +/// file: wrangler.jsonc +{ +--- "site": { + "bucket": ".cloudflare/public" + },--- ++++ "assets": { + "directory": ".cloudflare/public", + "binding": "ASSETS" + }+++ +} +``` diff --git a/packages/adapter-cloudflare-workers/.gitignore b/packages/adapter-cloudflare-workers/.gitignore index 9daa8247da45..2c8afebd8eda 100644 --- a/packages/adapter-cloudflare-workers/.gitignore +++ b/packages/adapter-cloudflare-workers/.gitignore @@ -1,2 +1 @@ -.DS_Store -node_modules +/files diff --git a/packages/adapter-cloudflare-workers/README.md b/packages/adapter-cloudflare-workers/README.md index a3805020a4aa..02b6207dfd30 100644 --- a/packages/adapter-cloudflare-workers/README.md +++ b/packages/adapter-cloudflare-workers/README.md @@ -1,8 +1,8 @@ # adapter-cloudflare-workers -SvelteKit adapter that creates a Cloudflare Workers site using a function for dynamic server rendering. +[Adapter](https://svelte.dev/docs/kit/building-your-app) for building SvelteKit applications on [Cloudflare Workers](https://developers.cloudflare.com/workers/) with [static assets](https://developers.cloudflare.com/workers/static-assets/). -**Requires [Wrangler v2](https://developers.cloudflare.com/workers/wrangler/get-started/).** Wrangler v1 is no longer supported. +**Requires [Wrangler v3.91.0 or later](https://developers.cloudflare.com/workers/wrangler/get-started/).**. ## Docs diff --git a/packages/adapter-cloudflare-workers/ambient.d.ts b/packages/adapter-cloudflare-workers/ambient.d.ts index fbd01afd6557..fa404bc98404 100644 --- a/packages/adapter-cloudflare-workers/ambient.d.ts +++ b/packages/adapter-cloudflare-workers/ambient.d.ts @@ -1,13 +1,14 @@ -import { CacheStorage, IncomingRequestCfProperties } from '@cloudflare/workers-types'; +import { CacheStorage, CfProperties } from '@cloudflare/workers-types'; declare global { namespace App { export interface Platform { + env: unknown; context: { waitUntil(promise: Promise): void; }; caches: CacheStorage; - cf?: IncomingRequestCfProperties; + cf?: CfProperties; } } } diff --git a/packages/adapter-cloudflare-workers/files/_package.json b/packages/adapter-cloudflare-workers/files/_package.json deleted file mode 100644 index bc4c8d4aabac..000000000000 --- a/packages/adapter-cloudflare-workers/files/_package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "private": true, - "version": "0.0.1", - "description": "Worker site generated by SvelteKit", - "main": "index.js", - "dependencies": { - "@cloudflare/kv-asset-handler": "~0.1.3" - } -} diff --git a/packages/adapter-cloudflare-workers/files/entry.js b/packages/adapter-cloudflare-workers/files/entry.js deleted file mode 100644 index 5f022e5096b9..000000000000 --- a/packages/adapter-cloudflare-workers/files/entry.js +++ /dev/null @@ -1,136 +0,0 @@ -import { Server } from 'SERVER'; -import { manifest, prerendered, base_path } from 'MANIFEST'; -import { getAssetFromKV, mapRequestToAsset } from '@cloudflare/kv-asset-handler'; -import static_asset_manifest_json from '__STATIC_CONTENT_MANIFEST'; -const static_asset_manifest = JSON.parse(static_asset_manifest_json); - -const server = new Server(manifest); - -const app_path = `/${manifest.appPath}`; - -const immutable = `${app_path}/immutable/`; -const version_file = `${app_path}/version.json`; - -export default { - /** - * @param {Request} req - * @param {any} env - * @param {any} context - */ - async fetch(req, env, context) { - await server.init({ env }); - - const url = new URL(req.url); - - // static assets - if (url.pathname.startsWith(app_path)) { - /** @type {Response} */ - const res = await get_asset_from_kv(req, env, context); - if (is_error(res.status)) return res; - - const cache_control = url.pathname.startsWith(immutable) - ? 'public, immutable, max-age=31536000' - : 'no-cache'; - - return new Response(res.body, { - headers: { - // include original headers, minus cache-control which - // is overridden, and etag which is no longer useful - 'cache-control': cache_control, - 'content-type': res.headers.get('content-type'), - 'x-robots-tag': 'noindex' - } - }); - } - - let { pathname, search } = url; - try { - pathname = decodeURIComponent(pathname); - } catch { - // ignore invalid URI - } - - const stripped_pathname = pathname.replace(/\/$/, ''); - - // prerendered pages and /static files - let is_static_asset = false; - const filename = stripped_pathname.slice(base_path.length + 1); - if (filename) { - is_static_asset = - manifest.assets.has(filename) || - manifest.assets.has(filename + '/index.html') || - filename in manifest._.server_assets || - filename + '/index.html' in manifest._.server_assets; - } - - let location = pathname.at(-1) === '/' ? stripped_pathname : pathname + '/'; - - if ( - is_static_asset || - prerendered.has(pathname) || - pathname === version_file || - pathname.startsWith(immutable) - ) { - return get_asset_from_kv(req, env, context, (request, options) => { - if (prerendered.has(pathname)) { - url.pathname = '/' + prerendered.get(pathname).file; - return new Request(url.toString(), request); - } - - return mapRequestToAsset(request, options); - }); - } else if (location && prerendered.has(location)) { - if (search) location += search; - return new Response('', { - status: 308, - headers: { - location - } - }); - } - - // dynamically-generated pages - return await server.respond(req, { - platform: { - env, - context, - // @ts-expect-error lib.dom is interfering with workers-types - caches, - // @ts-expect-error req is actually a Cloudflare request not a standard request - cf: req.cf - }, - getClientAddress() { - return req.headers.get('cf-connecting-ip'); - } - }); - } -}; - -/** - * @param {Request} req - * @param {any} env - * @param {any} context - */ -async function get_asset_from_kv(req, env, context, map = mapRequestToAsset) { - return await getAssetFromKV( - { - request: req, - waitUntil(promise) { - return context.waitUntil(promise); - } - }, - { - ASSET_NAMESPACE: env.__STATIC_CONTENT, - ASSET_MANIFEST: static_asset_manifest, - mapRequestToAsset: map - } - ); -} - -/** - * @param {number} status - * @returns {boolean} - */ -function is_error(status) { - return status > 399; -} diff --git a/packages/adapter-cloudflare-workers/index.js b/packages/adapter-cloudflare-workers/index.js index 5d13539cd915..78e7e704cd8a 100644 --- a/packages/adapter-cloudflare-workers/index.js +++ b/packages/adapter-cloudflare-workers/index.js @@ -1,141 +1,83 @@ -import { execSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; -import { posix, dirname } from 'node:path'; +import { copyFileSync, existsSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import esbuild from 'esbuild'; import { getPlatformProxy, unstable_readConfig } from 'wrangler'; -// list from https://developers.cloudflare.com/workers/runtime-apis/nodejs/ -const compatible_node_modules = [ - 'assert', - 'async_hooks', - 'buffer', - 'crypto', - 'diagnostics_channel', - 'events', - 'path', - 'process', - 'stream', - 'string_decoder', - 'util' -]; - /** @type {import('./index.js').default} */ export default function ({ config, platformProxy = {} } = {}) { return { name: '@sveltejs/adapter-cloudflare-workers', - async adapt(builder) { - const { main, site, compatibility_flags } = validate_config(builder, config); + adapt(builder) { + if (existsSync(`${builder.config.kit.files.assets}/_headers`)) { + throw new Error( + `The _headers file should be placed in the project root rather than the ${builder.config.kit.files.assets} directory` + ); + } - const files = fileURLToPath(new URL('./files', import.meta.url).href); - const tmp = builder.getBuildDirectory('cloudflare-workers-tmp'); + if (existsSync(`${builder.config.kit.files.assets}/_redirects`)) { + throw new Error( + `The _redirects file should be placed in the project root rather than the ${builder.config.kit.files.assets} directory` + ); + } - builder.rimraf(site.bucket); - builder.rimraf(dirname(main)); + const { main, assets } = validate_config(builder, config); + const files = fileURLToPath(new URL('./files', import.meta.url).href); + const tmp = builder.getBuildDirectory('cloudflare-tmp'); - builder.log.info('Installing worker dependencies...'); - builder.copy(`${files}/_package.json`, `${tmp}/package.json`); + // Clear out old files + builder.rimraf(assets.directory); + builder.rimraf(main); - // TODO would be cool if we could make this step unnecessary somehow - const stdout = execSync('npm install', { cwd: tmp }); - builder.log.info(stdout.toString()); + builder.mkdirp(tmp); builder.log.minor('Generating worker...'); - const relativePath = posix.relative(tmp, builder.getServerDirectory()); - builder.copy(`${files}/entry.js`, `${tmp}/entry.js`, { + // Create the entry-point for the Worker + const relative_path = path.posix.relative(main, builder.getServerDirectory()); + builder.copy(`${files}/worker.js`, main, { replace: { - SERVER: `${relativePath}/index.js`, - MANIFEST: './manifest.js' + SERVER: `${relative_path}/index.js`, + MANIFEST: `${path.posix.relative(main, tmp)}/manifest.js`, + ASSETS: assets.binding || 'ASSETS' } }); - let prerendered_entries = Array.from(builder.prerendered.pages.entries()); - - if (builder.config.kit.paths.base) { - prerendered_entries = prerendered_entries.map(([path, { file }]) => [ - path, - { file: `${builder.config.kit.paths.base}/${file}` } - ]); - } - + // Create the manifest for the Worker writeFileSync( `${tmp}/manifest.js`, - `export const manifest = ${builder.generateManifest({ relativePath })};\n\n` + - `export const prerendered = new Map(${JSON.stringify(prerendered_entries)});\n\n` + + `export const manifest = ${builder.generateManifest({ relativePath: relative_path })};\n\n` + + `export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});\n\n` + `export const base_path = ${JSON.stringify(builder.config.kit.paths.base)};\n` ); - const external = ['__STATIC_CONTENT_MANIFEST', 'cloudflare:*']; - if (compatibility_flags && compatibility_flags.includes('nodejs_compat')) { - external.push(...compatible_node_modules.map((id) => `node:${id}`)); + // client assets and prerendered pages + builder.log.minor('Copying assets...'); + const assets_dir = `${assets.directory}${builder.config.kit.paths.base}`; + builder.writeClient(assets_dir); + builder.writePrerendered(assets_dir); + + // _headers + const headers_file = `${assets.directory}/_headers`; + if (existsSync('_headers')) { + copyFileSync('_headers', headers_file); } + writeFileSync(headers_file, generate_headers(builder.getAppPath()), { + flag: 'a' + }); - try { - const result = await esbuild.build({ - platform: 'browser', - // https://github.com/cloudflare/workers-sdk/blob/a12b2786ce745f24475174bcec994ad691e65b0f/packages/wrangler/src/deployment-bundle/bundle.ts#L35-L36 - conditions: ['workerd', 'worker', 'browser'], - sourcemap: 'linked', - target: 'es2022', - entryPoints: [`${tmp}/entry.js`], - outfile: main, - bundle: true, - external, - alias: Object.fromEntries(compatible_node_modules.map((id) => [id, `node:${id}`])), - format: 'esm', - loader: { - '.wasm': 'copy', - '.woff': 'copy', - '.woff2': 'copy', - '.ttf': 'copy', - '.eot': 'copy', - '.otf': 'copy' - }, - logLevel: 'silent' - }); - - if (result.warnings.length > 0) { - const formatted = await esbuild.formatMessages(result.warnings, { - kind: 'warning', - color: true - }); - - console.error(formatted.join('\n')); - } - } catch (error) { - for (const e of error.errors) { - for (const node of e.notes) { - const match = - /The package "(.+)" wasn't found on the file system but is built into node/.exec( - node.text - ); - - if (match) { - node.text = `Cannot use "${match[1]}" when deploying to Cloudflare.`; - } - } - } - - const formatted = await esbuild.formatMessages(error.errors, { - kind: 'error', - color: true + // _redirects + const redirects_file = `${assets.directory}/_redirects`; + if (existsSync('_redirects')) { + copyFileSync('_redirects', redirects_file); + } + if (builder.prerendered.redirects.size > 0) { + writeFileSync(redirects_file, generate_redirects(builder.prerendered.redirects), { + flag: 'a' }); - - console.error(formatted.join('\n')); - - throw new Error( - `Bundling with esbuild failed with ${error.errors.length} ${ - error.errors.length === 1 ? 'error' : 'errors' - }` - ); } - builder.log.minor('Copying assets...'); - const bucket_dir = `${site.bucket}${builder.config.kit.paths.base}`; - builder.writeClient(bucket_dir); - builder.writePrerendered(bucket_dir); + writeFileSync(`${assets.directory}/.assetsignore`, generate_assetsignore(), { flag: 'a' }); }, emulate() { @@ -182,42 +124,87 @@ export default function ({ config, platformProxy = {} } = {}) { */ function validate_config(builder, config_file = undefined) { const wrangler_config = unstable_readConfig({ config: config_file }); - if (!wrangler_config.configPath) { builder.log.error( - 'Consult https://developers.cloudflare.com/workers/platform/sites/configuration on how to setup your site' + 'Consult https://developers.cloudflare.com/workers/static-assets/ on how to setup your configuration' ); - builder.log( + builder.log.error( ` Sample wrangler.jsonc: { - "name": "", - "account_id": "", - "main": "./.cloudflare/worker.js", - "site": { - "bucket": "./.cloudflare/public" - }, - "build": { - "command": "npm run build" - }, - "compatibility_date": "2021-11-12" + "name": "", + "main": ".svelte-kit/cloudflare/_worker.js", + "compatibility_date": "2025-01-01", + "assets": { + "binding": "ASSETS", + "directory": ".svelte-kit/cloudflare" + } } `.trim() ); throw new Error('Missing a Wrangler configuration file'); } - if (!wrangler_config.site?.bucket) { + if (!wrangler_config.main) { throw new Error( - `You must specify the \`site.bucket\` key in ${wrangler_config.configPath}. Consult https://developers.cloudflare.com/workers/platform/sites/configuration` + `You must specify the \`main\` key in ${wrangler_config.configPath}. Consult https://developers.cloudflare.com/workers/static-assets/` ); } - if (!wrangler_config.main) { + if (wrangler_config.site) { throw new Error( - `You must specify the \`main\` key in ${wrangler_config.configPath}. Consult https://developers.cloudflare.com/workers/platform/sites/configuration` + `You must remove all \`site\` keys in ${wrangler_config.configPath}. Consult https://svelte.dev/docs/kit/adapter-cloudflare-workers#Migrating-from-Workers-Sites-to-Workers-Static-Assets` + ); + } + + if (!wrangler_config.assets?.directory) { + throw new Error( + `You must specify the \`assets.directory\` key in ${wrangler_config.configPath}. Consult https://developers.cloudflare.com/workers/static-assets/binding/#directory` + ); + } + + if (!wrangler_config.assets?.binding) { + throw new Error( + `You must specify the \`assets.binding\` key in ${wrangler_config.configPath}. Consult https://developers.cloudflare.com/workers/static-assets/binding/#binding` ); } return wrangler_config; } + +/** @param {string} app_dir */ +function generate_headers(app_dir) { + return ` +# === START AUTOGENERATED SVELTE IMMUTABLE HEADERS === +/${app_dir}/* + X-Robots-Tag: noindex + Cache-Control: no-cache +/${app_dir}/immutable/* + ! Cache-Control + Cache-Control: public, immutable, max-age=31536000 +# === END AUTOGENERATED SVELTE IMMUTABLE HEADERS === +`.trimEnd(); +} + +/** @param {Map} redirects */ +function generate_redirects(redirects) { + const rules = Array.from( + redirects.entries(), + ([path, redirect]) => `${path} ${redirect.location} ${redirect.status}` + ).join('\n'); + + return ` +# === START AUTOGENERATED SVELTE PRERENDERED REDIRECTS === +${rules} +# === END AUTOGENERATED SVELTE PRERENDERED REDIRECTS === +`.trimEnd(); +} + +// this list comes from https://developers.cloudflare.com/workers/static-assets/binding/#ignoring-assets +function generate_assetsignore() { + return ` +_worker.js +_headers +_redirects +`; +} diff --git a/packages/adapter-cloudflare-workers/internal.d.ts b/packages/adapter-cloudflare-workers/internal.d.ts index 3877ad52f4a5..3a390e5cf2a0 100644 --- a/packages/adapter-cloudflare-workers/internal.d.ts +++ b/packages/adapter-cloudflare-workers/internal.d.ts @@ -6,11 +6,6 @@ declare module 'MANIFEST' { import { SSRManifest } from '@sveltejs/kit'; export const manifest: SSRManifest; - export const prerendered: Map; + export const prerendered: Set; export const base_path: string; } - -declare module '__STATIC_CONTENT_MANIFEST' { - const json: string; - export default json; -} diff --git a/packages/adapter-cloudflare-workers/package.json b/packages/adapter-cloudflare-workers/package.json index 581343d9ed3d..6366641ec733 100644 --- a/packages/adapter-cloudflare-workers/package.json +++ b/packages/adapter-cloudflare-workers/package.json @@ -33,18 +33,20 @@ "index.d.ts" ], "scripts": { + "build": "esbuild src/worker.js --bundle --outfile=files/worker.js --external:SERVER --external:MANIFEST --format=esm", "lint": "prettier --check .", "format": "pnpm lint --write", - "check": "tsc --skipLibCheck" + "check": "tsc --skipLibCheck", + "prepublishOnly": "pnpm build" }, "dependencies": { "@cloudflare/workers-types": "^4.20250312.0", - "esbuild": "^0.24.0" + "worktop": "0.8.0-next.18" }, "devDependencies": { - "@cloudflare/kv-asset-handler": "^0.4.0", "@sveltejs/kit": "workspace:^", "@types/node": "^18.19.48", + "esbuild": "^0.24.0", "typescript": "^5.3.3" }, "peerDependencies": { diff --git a/packages/adapter-cloudflare-workers/src/worker.js b/packages/adapter-cloudflare-workers/src/worker.js new file mode 100644 index 000000000000..a92bfa4d4684 --- /dev/null +++ b/packages/adapter-cloudflare-workers/src/worker.js @@ -0,0 +1,104 @@ +import { Server } from 'SERVER'; +import { manifest, prerendered, base_path } from 'MANIFEST'; +import * as Cache from 'worktop/cfw.cache'; + +const server = new Server(manifest); + +const app_path = `/${manifest.appPath}`; + +const immutable = `${app_path}/immutable/`; + +export default { + /** + * @param {Request} req + * @param {{ ASSETS: { fetch: typeof fetch } }} env + * @param {ExecutionContext} context + * @returns {Promise} + */ + async fetch(req, env, context) { + await server.init({ + // @ts-expect-error env contains environment variables and bindings + env + }); + + // skip cache if "cache-control: no-cache" in request + let pragma = req.headers.get('cache-control') || ''; + let res = !pragma.includes('no-cache') && (await Cache.lookup(req)); + if (res) return res; + + let { pathname, search } = new URL(req.url); + try { + pathname = decodeURIComponent(pathname); + } catch { + // ignore invalid URI + } + + // immutable assets and version.json + if (pathname.startsWith(app_path)) { + res = await env.ASSETS.fetch(req); + if (is_error(res.status)) return res; + + const cache_control = pathname.startsWith(immutable) + ? 'public, immutable, max-age=31536000' + : 'no-cache'; + + res.headers.set('cache-control', cache_control); + res.headers.set('x-robots-tag', 'noindex'); + } else { + const stripped_pathname = pathname.replace(/\/$/, ''); + + // /static files, the service worker, and Vite imported server assets + let is_static_asset = false; + const filename = stripped_pathname.slice(base_path.length + 1); + if (filename) { + is_static_asset = + manifest.assets.has(filename) || + manifest.assets.has(filename + '/index.html') || + filename in manifest._.server_assets || + filename + '/index.html' in manifest._.server_assets; + } + + let location = pathname.at(-1) === '/' ? stripped_pathname : pathname + '/'; + + if (is_static_asset || prerendered.has(pathname)) { + res = await env.ASSETS.fetch(req); + } else if (location && prerendered.has(location)) { + // trailing slash redirect for prerendered pages + if (search) location += search; + res = new Response('', { + status: 308, + headers: { + location + } + }); + } else { + // dynamically-generated pages + res = await server.respond(req, { + platform: { + env, + context, + // @ts-expect-error webworker types from worktop are not compatible with Cloudflare Workers types + caches, + cf: req.cf + }, + getClientAddress() { + return req.headers.get('cf-connecting-ip'); + } + }); + } + } + + // write to `Cache` only if response is not an error, + // let `Cache.save` handle the Cache-Control and Vary headers + pragma = res.headers.get('cache-control') || ''; + return pragma && !is_error(res.status) ? Cache.save(req, res, context) : res; + } +}; + +/** + * @param {number} status + * @returns {boolean} + */ +function is_error(status) { + return status > 399; +} diff --git a/packages/adapter-cloudflare-workers/tsconfig.json b/packages/adapter-cloudflare-workers/tsconfig.json index e4cdc5abd1b7..75fb3c2b4665 100644 --- a/packages/adapter-cloudflare-workers/tsconfig.json +++ b/packages/adapter-cloudflare-workers/tsconfig.json @@ -7,11 +7,13 @@ "target": "es2022", "module": "node16", "moduleResolution": "node16", - "allowSyntheticDefaultImports": true, "baseUrl": ".", "paths": { "@sveltejs/kit": ["../kit/types/index"] - } + }, + // taken from the Cloudflare Workers TypeScript template https://github.com/cloudflare/workers-sdk/blob/main/packages/create-cloudflare/templates/hello-world/ts/tsconfig.json + "lib": ["es2021"], + "types": ["@cloudflare/workers-types"] }, - "include": ["**/*.js", "internal.d.ts"] + "include": ["index.js", "internal.d.ts", "src/worker.js"] } diff --git a/packages/adapter-cloudflare/README.md b/packages/adapter-cloudflare/README.md index fa00adbf1c94..ed61c6e65dad 100644 --- a/packages/adapter-cloudflare/README.md +++ b/packages/adapter-cloudflare/README.md @@ -1,6 +1,6 @@ # adapter-cloudflare -[Adapter](https://svelte.dev/docs/kit/building-your-app) for building SvelteKit applications on [Cloudflare Pages](https://developers.cloudflare.com/pages/) with [Workers integration](https://developers.cloudflare.com/pages/platform/functions). +[Adapter](https://svelte.dev/docs/kit/building-your-app) for building SvelteKit applications on [Cloudflare Pages](https://developers.cloudflare.com/pages/) with [Workers integration](https://developers.cloudflare.com/pages/functions/). ## Docs diff --git a/packages/adapter-cloudflare/ambient.d.ts b/packages/adapter-cloudflare/ambient.d.ts index fbd01afd6557..fa404bc98404 100644 --- a/packages/adapter-cloudflare/ambient.d.ts +++ b/packages/adapter-cloudflare/ambient.d.ts @@ -1,13 +1,14 @@ -import { CacheStorage, IncomingRequestCfProperties } from '@cloudflare/workers-types'; +import { CacheStorage, CfProperties } from '@cloudflare/workers-types'; declare global { namespace App { export interface Platform { + env: unknown; context: { waitUntil(promise: Promise): void; }; caches: CacheStorage; - cf?: IncomingRequestCfProperties; + cf?: CfProperties; } } } diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index 085aa8b26c80..da6735783748 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -43,45 +43,53 @@ export default function (options = {}) { writeFileSync(fallback, 'Not Found'); } + // client assets and prerendered pages const dest_dir = `${dest}${builder.config.kit.paths.base}`; const written_files = builder.writeClient(dest_dir); builder.writePrerendered(dest_dir); - const relativePath = path.posix.relative(dest, builder.getServerDirectory()); + // _worker.js + const relative_path = path.posix.relative(dest, builder.getServerDirectory()); + builder.copy(`${files}/worker.js`, `${dest}/_worker.js`, { + replace: { + SERVER: `${relative_path}/index.js`, + MANIFEST: `${path.posix.relative(dest, tmp)}/manifest.js` + } + }); writeFileSync( `${tmp}/manifest.js`, - `export const manifest = ${builder.generateManifest({ relativePath })};\n\n` + + `export const manifest = ${builder.generateManifest({ relativePath: relative_path })};\n\n` + `export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});\n\n` + `export const base_path = ${JSON.stringify(builder.config.kit.paths.base)};\n` ); builder.copy(`${files}/worker.js`, `${dest}/_worker.js`, { replace: { - SERVER: `${relativePath}/index.js`, + SERVER: `${relative_path}/index.js`, MANIFEST: `${path.posix.relative(dest, tmp)}/manifest.js` } }); - writeFileSync( - `${dest}/_routes.json`, - JSON.stringify(get_routes_json(builder, written_files, options.routes ?? {}), null, '\t') - ); - + // _headers if (existsSync('_headers')) { copyFileSync('_headers', `${dest}/_headers`); } - writeFileSync(`${dest}/_headers`, generate_headers(builder.getAppPath()), { flag: 'a' }); + // _redirects if (existsSync('_redirects')) { copyFileSync('_redirects', `${dest}/_redirects`); } - if (builder.prerendered.redirects.size > 0) { writeFileSync(`${dest}/_redirects`, generate_redirects(builder.prerendered.redirects), { flag: 'a' }); } + writeFileSync( + `${dest}/_routes.json`, + JSON.stringify(get_routes_json(builder, written_files, options.routes ?? {}), null, '\t') + ); + writeFileSync(`${dest}/.assetsignore`, generate_assetsignore(), { flag: 'a' }); }, emulate() { @@ -188,7 +196,7 @@ function generate_headers(app_dir) { return ` # === START AUTOGENERATED SVELTE IMMUTABLE HEADERS === /${app_dir}/* - X-Robots-Tag: noindex + X-Robots-Tag: noindex Cache-Control: no-cache /${app_dir}/immutable/* ! Cache-Control diff --git a/packages/adapter-cloudflare/package.json b/packages/adapter-cloudflare/package.json index 113c512af3dc..4c787c346e61 100644 --- a/packages/adapter-cloudflare/package.json +++ b/packages/adapter-cloudflare/package.json @@ -41,13 +41,12 @@ }, "dependencies": { "@cloudflare/workers-types": "^4.20250312.0", - "esbuild": "^0.24.0", "worktop": "0.8.0-next.18" }, "devDependencies": { "@sveltejs/kit": "workspace:^", "@types/node": "^18.19.48", - "@types/ws": "^8.5.10", + "esbuild": "^0.24.0", "typescript": "^5.3.3" }, "peerDependencies": { diff --git a/packages/adapter-cloudflare/src/worker.js b/packages/adapter-cloudflare/src/worker.js index c3c27a0b041f..a92bfa4d4684 100644 --- a/packages/adapter-cloudflare/src/worker.js +++ b/packages/adapter-cloudflare/src/worker.js @@ -7,13 +7,20 @@ const server = new Server(manifest); const app_path = `/${manifest.appPath}`; const immutable = `${app_path}/immutable/`; -const version_file = `${app_path}/version.json`; -/** @type {import('worktop/cfw').Module.Worker<{ ASSETS: import('worktop/cfw.durable').Durable.Object }>} */ -const worker = { +export default { + /** + * @param {Request} req + * @param {{ ASSETS: { fetch: typeof fetch } }} env + * @param {ExecutionContext} context + * @returns {Promise} + */ async fetch(req, env, context) { - // @ts-ignore - await server.init({ env }); + await server.init({ + // @ts-expect-error env contains environment variables and bindings + env + }); + // skip cache if "cache-control: no-cache" in request let pragma = req.headers.get('cache-control') || ''; let res = !pragma.includes('no-cache') && (await Cache.lookup(req)); @@ -26,52 +33,72 @@ const worker = { // ignore invalid URI } - const stripped_pathname = pathname.replace(/\/$/, ''); - - // prerendered pages and /static files - let is_static_asset = false; - const filename = stripped_pathname.slice(base_path.length + 1); - if (filename) { - is_static_asset = - manifest.assets.has(filename) || - manifest.assets.has(filename + '/index.html') || - filename in manifest._.server_assets || - filename + '/index.html' in manifest._.server_assets; - } + // immutable assets and version.json + if (pathname.startsWith(app_path)) { + res = await env.ASSETS.fetch(req); + if (is_error(res.status)) return res; - let location = pathname.at(-1) === '/' ? stripped_pathname : pathname + '/'; + const cache_control = pathname.startsWith(immutable) + ? 'public, immutable, max-age=31536000' + : 'no-cache'; - if ( - is_static_asset || - prerendered.has(pathname) || - pathname === version_file || - pathname.startsWith(immutable) - ) { - res = await env.ASSETS.fetch(req); - } else if (location && prerendered.has(location)) { - if (search) location += search; - res = new Response('', { - status: 308, - headers: { - location - } - }); + res.headers.set('cache-control', cache_control); + res.headers.set('x-robots-tag', 'noindex'); } else { - // dynamically-generated pages - res = await server.respond(req, { - // @ts-ignore - platform: { env, context, caches, cf: req.cf }, - getClientAddress() { - return req.headers.get('cf-connecting-ip'); - } - }); + const stripped_pathname = pathname.replace(/\/$/, ''); + + // /static files, the service worker, and Vite imported server assets + let is_static_asset = false; + const filename = stripped_pathname.slice(base_path.length + 1); + if (filename) { + is_static_asset = + manifest.assets.has(filename) || + manifest.assets.has(filename + '/index.html') || + filename in manifest._.server_assets || + filename + '/index.html' in manifest._.server_assets; + } + + let location = pathname.at(-1) === '/' ? stripped_pathname : pathname + '/'; + + if (is_static_asset || prerendered.has(pathname)) { + res = await env.ASSETS.fetch(req); + } else if (location && prerendered.has(location)) { + // trailing slash redirect for prerendered pages + if (search) location += search; + res = new Response('', { + status: 308, + headers: { + location + } + }); + } else { + // dynamically-generated pages + res = await server.respond(req, { + platform: { + env, + context, + // @ts-expect-error webworker types from worktop are not compatible with Cloudflare Workers types + caches, + cf: req.cf + }, + getClientAddress() { + return req.headers.get('cf-connecting-ip'); + } + }); + } } // write to `Cache` only if response is not an error, // let `Cache.save` handle the Cache-Control and Vary headers pragma = res.headers.get('cache-control') || ''; - return pragma && res.status < 400 ? Cache.save(req, res, context) : res; + return pragma && !is_error(res.status) ? Cache.save(req, res, context) : res; } }; -export default worker; +/** + * @param {number} status + * @returns {boolean} + */ +function is_error(status) { + return status > 399; +} diff --git a/packages/adapter-cloudflare/tsconfig.json b/packages/adapter-cloudflare/tsconfig.json index b258035a3555..75fb3c2b4665 100644 --- a/packages/adapter-cloudflare/tsconfig.json +++ b/packages/adapter-cloudflare/tsconfig.json @@ -10,7 +10,10 @@ "baseUrl": ".", "paths": { "@sveltejs/kit": ["../kit/types/index"] - } + }, + // taken from the Cloudflare Workers TypeScript template https://github.com/cloudflare/workers-sdk/blob/main/packages/create-cloudflare/templates/hello-world/ts/tsconfig.json + "lib": ["es2021"], + "types": ["@cloudflare/workers-types"] }, "include": ["index.js", "internal.d.ts", "src/worker.js"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb5fae117180..99f5f50ef64d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,9 +60,6 @@ importers: '@cloudflare/workers-types': specifier: ^4.20250312.0 version: 4.20250312.0 - esbuild: - specifier: ^0.24.0 - version: 0.24.2 worktop: specifier: 0.8.0-next.18 version: 0.8.0-next.18 @@ -76,9 +73,9 @@ importers: '@types/node': specifier: ^18.19.48 version: 18.19.50 - '@types/ws': - specifier: ^8.5.10 - version: 8.5.10 + esbuild: + specifier: ^0.24.0 + version: 0.24.2 typescript: specifier: ^5.3.3 version: 5.6.3 @@ -88,22 +85,22 @@ importers: '@cloudflare/workers-types': specifier: ^4.20250312.0 version: 4.20250312.0 - esbuild: - specifier: ^0.24.0 - version: 0.24.2 + worktop: + specifier: 0.8.0-next.18 + version: 0.8.0-next.18 wrangler: specifier: ^3.91.0 || ^4.0.0 version: 4.0.0(@cloudflare/workers-types@4.20250312.0) devDependencies: - '@cloudflare/kv-asset-handler': - specifier: ^0.4.0 - version: 0.4.0 '@sveltejs/kit': specifier: workspace:^ version: link:../kit '@types/node': specifier: ^18.19.48 version: 18.19.50 + esbuild: + specifier: ^0.24.0 + version: 0.24.2 typescript: specifier: ^5.3.3 version: 5.6.3 @@ -1888,9 +1885,6 @@ packages: '@types/set-cookie-parser@2.4.7': resolution: {integrity: sha512-+ge/loa0oTozxip6zmhRIk8Z/boU51wl9Q6QdLZcokIGMzY5lFXYy/x7Htj2HTC6/KZP1hUbZ1ekx8DYXICvWg==} - '@types/ws@8.5.10': - resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} - '@typescript-eslint/eslint-plugin@8.26.0': resolution: {integrity: sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4199,10 +4193,6 @@ snapshots: dependencies: '@types/node': 18.19.50 - '@types/ws@8.5.10': - dependencies: - '@types/node': 18.19.50 - '@typescript-eslint/eslint-plugin@8.26.0(@typescript-eslint/parser@8.26.0(eslint@9.6.0)(typescript@5.6.3))(eslint@9.6.0)(typescript@5.6.3)': dependencies: '@eslint-community/regexpp': 4.12.1