diff --git a/.changeset/thirty-ghosts-cover.md b/.changeset/thirty-ghosts-cover.md new file mode 100644 index 000000000000..a740728fdbcd --- /dev/null +++ b/.changeset/thirty-ghosts-cover.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-cloudflare-workers': minor +--- + +feat: allow additional handlers to be included in generated Cloudflare Worker 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..66d769856856 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 @@ -30,6 +30,29 @@ export default { Path to your [Wrangler configuration file](https://developers.cloudflare.com/workers/wrangler/configuration/). If you would like to use a Wrangler configuration filename other than `wrangler.jsonc`, you can specify it using this option. +### handlers + +Path to a file with additional [handlers](https://developers.cloudflare.com/workers/runtime-apis/handlers/) export alongside the SvelteKit-generated `fetch()` handler. Enables integration of, for example, `scheduled()` or `queue()` handlers with your SvelteKit app. + +Default: `undefined`- no additional handlers are exported. + +The handlers file should export a default object with any additional handlers. Example below: + +```js +// @errors: 2307 2377 7006 +/// file: src/handlers.js +export default { + async scheduled(event, env, ctx) { + console.log("Scheduled trigger!"); + }, + // additional handlers go here +} +``` + +> [!NOTE] The adapter expects the `handlers` file to have a default export. + +> [!NOTE] The adapter will overwrite any [fetch handler](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/) exported from the `handlers` file in the generated worker. Most uses for a fetch handler are covered by endpoints or server hooks, so you should use those instead. + ### platformProxy 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. diff --git a/packages/adapter-cloudflare-workers/files/entry.js b/packages/adapter-cloudflare-workers/files/entry.js index 5f022e5096b9..fad7dffbe98d 100644 --- a/packages/adapter-cloudflare-workers/files/entry.js +++ b/packages/adapter-cloudflare-workers/files/entry.js @@ -1,7 +1,10 @@ import { Server } from 'SERVER'; import { manifest, prerendered, base_path } from 'MANIFEST'; +import handlers from 'HANDLERS'; import { getAssetFromKV, mapRequestToAsset } from '@cloudflare/kv-asset-handler'; import static_asset_manifest_json from '__STATIC_CONTENT_MANIFEST'; +import { WorkerEntrypoint } from 'cloudflare:workers'; + const static_asset_manifest = JSON.parse(static_asset_manifest_json); const server = new Server(manifest); @@ -11,100 +14,112 @@ 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' - } - }); - } +/** + * @param {Request} req + * @param {any} env + * @param {any} context + */ +async function 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 - } + 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; - } + 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 - } - }); - } + 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); + } - // 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'); + 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'); + } + }); +} + +export default 'prototype' in handlers && handlers.prototype instanceof WorkerEntrypoint + ? Object.defineProperty(handlers.prototype, 'fetch', { + value: fetch, + writable: true, + enumerable: false, + configurable: true + }) + : Object.defineProperty(handlers, 'fetch', { + value: fetch, + writable: true, + enumerable: true, + configurable: true + }); /** * @param {Request} req diff --git a/packages/adapter-cloudflare-workers/index.d.ts b/packages/adapter-cloudflare-workers/index.d.ts index 878b48649d5e..ab071e3e8649 100644 --- a/packages/adapter-cloudflare-workers/index.d.ts +++ b/packages/adapter-cloudflare-workers/index.d.ts @@ -9,6 +9,10 @@ export interface AdapterOptions { * Path to your {@link https://developers.cloudflare.com/workers/wrangler/configuration/ | Wrangler configuration file}. */ config?: string; + /** + * Path to a file with additional {@link https://developers.cloudflare.com/workers/runtime-apis/handlers/ | handlers} and (optionally) {@link https://developers.cloudflare.com/durable-objects/ | Durable Objects} to be exported from the file the adapter generates. + */ + handlers?: string; /** * Config object passed to {@link https://developers.cloudflare.com/workers/wrangler/api/#getplatformproxy | getPlatformProxy} * during development and preview. diff --git a/packages/adapter-cloudflare-workers/index.js b/packages/adapter-cloudflare-workers/index.js index 5d13539cd915..a380491e1675 100644 --- a/packages/adapter-cloudflare-workers/index.js +++ b/packages/adapter-cloudflare-workers/index.js @@ -1,6 +1,7 @@ import { execSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; -import { posix, dirname } from 'node:path'; +import { posix, dirname, resolve } from 'node:path'; +import { cwd } from 'node:process'; import { fileURLToPath } from 'node:url'; import esbuild from 'esbuild'; import { getPlatformProxy, unstable_readConfig } from 'wrangler'; @@ -21,7 +22,7 @@ const compatible_node_modules = [ ]; /** @type {import('./index.js').default} */ -export default function ({ config, platformProxy = {} } = {}) { +export default function ({ config, platformProxy = {}, handlers } = {}) { return { name: '@sveltejs/adapter-cloudflare-workers', @@ -47,7 +48,8 @@ export default function ({ config, platformProxy = {} } = {}) { builder.copy(`${files}/entry.js`, `${tmp}/entry.js`, { replace: { SERVER: `${relativePath}/index.js`, - MANIFEST: './manifest.js' + MANIFEST: './manifest.js', + HANDLERS: './_handlers.js' } }); @@ -67,6 +69,18 @@ export default function ({ config, platformProxy = {} } = {}) { `export const base_path = ${JSON.stringify(builder.config.kit.paths.base)};\n` ); + if (handlers) { + // TODO: find a more robust way to resolve files relative to svelte.config.js + const handlers_file = resolve(cwd(), handlers); + writeFileSync( + `${tmp}/_handlers.js`, + `import handlers from "${handlers_file}";\n\n` + 'export default handlers;' + ); + } else { + // The handlers file must export a plain object as its default export. + writeFileSync(`${tmp}/_handlers.js`, 'export default {};'); + } + const external = ['__STATIC_CONTENT_MANIFEST', 'cloudflare:*']; if (compatibility_flags && compatibility_flags.includes('nodejs_compat')) { external.push(...compatible_node_modules.map((id) => `node:${id}`)); diff --git a/packages/adapter-cloudflare-workers/internal.d.ts b/packages/adapter-cloudflare-workers/internal.d.ts index 3877ad52f4a5..d78b8bb9ce4c 100644 --- a/packages/adapter-cloudflare-workers/internal.d.ts +++ b/packages/adapter-cloudflare-workers/internal.d.ts @@ -14,3 +14,12 @@ declare module '__STATIC_CONTENT_MANIFEST' { const json: string; export default json; } + +declare module 'HANDLERS' { + import { ExportedHandler } from '@cloudflare/workers-types'; + import { WorkerEntrypoint } from 'cloudflare:workers'; + + const handlers: Omit<ExportedHandler, 'fetch'> | WorkerEntrypoint; + + export default handlers; +}