Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow additional handlers in adapter-cloudflare-workers #13207

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b580a8d
Implement Cloudflare Workers exports in adapter-cloudflare
thomasfosterau Dec 20, 2024
a7c9854
Update implementation to correctly resolve paths.
thomasfosterau Dec 20, 2024
2337ab4
Add additional exports functionality to adapter-cloudflare-workers.
thomasfosterau Dec 20, 2024
05b8ad9
Rename 'exports' to 'handlers'.
thomasfosterau Dec 20, 2024
2d15411
Add changeset.
thomasfosterau Dec 20, 2024
8a418d9
Remove handlers from adapter-cloudflare
thomasfosterau Dec 23, 2024
69c2ff0
Export named exports as well as default export form handlers file.
thomasfosterau Dec 23, 2024
4989a48
Document `handlers` option.
thomasfosterau Dec 23, 2024
b9333f3
Fix missing semicolon and linebreaks.
thomasfosterau Dec 23, 2024
c5c3a99
Update changeset
thomasfosterau Dec 29, 2024
78a6177
Add handlers option to the example config
thomasfosterau Dec 29, 2024
5596bec
Update 70-adapter-cloudflare-workers.md
thomasfosterau Dec 31, 2024
33f0fab
Add missing backtick.
thomasfosterau Dec 31, 2024
615909a
Update documentation/docs/25-build-and-deploy/70-adapter-cloudflare-w…
eltigerchino Dec 31, 2024
d6bdb75
Correctly add fetch method to Cloudflare RPC workers.
thomasfosterau Jan 14, 2025
14ca427
Merge branch 'main' into pr/thomasfosterau/13207
eltigerchino Jan 21, 2025
3c6b08e
Merge branch 'main' into cloudflare-exports
thomasfosterau Feb 27, 2025
4cb65d1
Fix lint errors
thomasfosterau Feb 27, 2025
641c8fc
Remove Durable Objects support.
thomasfosterau Feb 27, 2025
497d1dd
Clarify documentation.
thomasfosterau Feb 27, 2025
e71366e
Make documentation more concise.
thomasfosterau Feb 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thirty-ghosts-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-cloudflare-workers': minor
---

feat: allow additional handlers and Durable Objects to be included in generated Cloudflare Worker
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default {
kit: {
adapter: adapter({
config: 'wrangler.toml',
handlers: './src/handlers.js',
platformProxy: {
configPath: 'wrangler.toml',
environment: undefined,
Expand All @@ -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.
Expand Down
191 changes: 104 additions & 87 deletions packages/adapter-cloudflare-workers/files/entry.js
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/adapter-cloudflare-workers/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 19 additions & 3 deletions packages/adapter-cloudflare-workers/index.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',

Expand All @@ -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'
}
});

Expand All @@ -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}`));
Expand Down
9 changes: 9 additions & 0 deletions packages/adapter-cloudflare-workers/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading