diff --git a/.changeset/thirty-ghosts-cover.md b/.changeset/thirty-ghosts-cover.md new file mode 100644 index 000000000000..e9e064f4d0e8 --- /dev/null +++ b/.changeset/thirty-ghosts-cover.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-cloudflare-workers': minor +--- + +feat: allow additional handlers and Durable Objects 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 ad820327463a..33fa5961d4bb 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 @@ -19,6 +19,7 @@ export default { kit: { adapter: adapter({ config: 'wrangler.toml', + handlers: './src/handlers.js', platformProxy: { configPath: 'wrangler.toml', environment: undefined, @@ -36,6 +37,38 @@ export default { Path to your custom `wrangler.toml` or `wrangler.json` config file. +### handlers + +Path to a file with additional [handlers](https://developers.cloudflare.com/workers/runtime-apis/handlers/) and (optionally) [Durable Objects](https://developers.cloudflare.com/durable-objects/) to be exported from the file the adapter generates. This allows you to, for example, include handlers for scheduled or queue triggers alongside the fetch handler your SvelteKit app. + +The handlers file should export a default object with any additional handlers, and any Durable Objects as named exports. Example below: + +```js +// @errors: 2307 2377 7006 +/// file: src/handlers.js +// export your durable objects here +import { DurableObject } from "cloudflare:workers"; + +export class MyDurableObject extends DurableObject { + constructor(state, env) {} + + async sayHello() { + return "Hello, World!"; + } +} + +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. If you only want to export a Durable Object, add `export default {};` to the file. + +> [!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/#syntax) 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..9acf3ce68308 100644 --- a/packages/adapter-cloudflare-workers/files/entry.js +++ b/packages/adapter-cloudflare-workers/files/entry.js @@ -1,7 +1,12 @@ 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'; + +export * from 'HANDLERS'; + const static_asset_manifest = JSON.parse(static_asset_manifest_json); const server = new Server(manifest); @@ -11,100 +16,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 b76003735910..72f979c42b9b 100644 --- a/packages/adapter-cloudflare-workers/index.d.ts +++ b/packages/adapter-cloudflare-workers/index.d.ts @@ -6,6 +6,10 @@ export default function plugin(options?: AdapterOptions): Adapter; export interface AdapterOptions { 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 be1d98245cbd..3950ccae1ee4 100644 --- a/packages/adapter-cloudflare-workers/index.js +++ b/packages/adapter-cloudflare-workers/index.js @@ -1,6 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { posix, dirname } from 'node:path'; +import { posix, dirname, resolve } from 'node:path'; import { execSync } from 'node:child_process'; +import { cwd } from 'node:process'; import esbuild from 'esbuild'; import toml from '@iarna/toml'; import { fileURLToPath } from 'node:url'; @@ -32,7 +33,7 @@ const compatible_node_modules = [ ]; /** @type {import('./index.js').default} */ -export default function ({ config = 'wrangler.toml', platformProxy = {} } = {}) { +export default function ({ config = 'wrangler.toml', platformProxy = {}, handlers } = {}) { return { name: '@sveltejs/adapter-cloudflare-workers', @@ -58,7 +59,8 @@ export default function ({ config = 'wrangler.toml', platformProxy = {} } = {}) builder.copy(`${files}/entry.js`, `${tmp}/entry.js`, { replace: { SERVER: `${relativePath}/index.js`, - MANIFEST: './manifest.js' + MANIFEST: './manifest.js', + HANDLERS: './_handlers.js' } }); @@ -78,6 +80,20 @@ export default function ({ config = 'wrangler.toml', 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 * 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 | WorkerEntrypoint; + + export default handlers; +}