Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 deletions documentation/docs/25-build-and-deploy/99-writing-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ export default function (options) {
}
},
vite: {
plugins: [
// add plugins here to integrate with Vite
pre_plugins: [
// add plugins here...
],
post_plugins: [
// ...or here to integrate with Vite
]

@teemingc teemingc Aug 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to solve this with order: 'pre' and order: 'post' on the plugin hook itself? If it isn't, I think we can add this public API in a separate PR before Kit 3 is out so that it's not a breaking change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cloudflare/vite-plugin (like sveltekit) consists of a bunch of smaller plugins that are order sensitive, and it needs to go after sveltekit. I tried setting enforce: post on each of them and it broke things.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we add vite.plugins.pre and vite.plugins.post in a new PR?

}
};
Expand Down
186 changes: 181 additions & 5 deletions packages/adapter-cloudflare/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ import {
parse_redirects,
append_headers
} from './utils.js';

const name = '@sveltejs/adapter-cloudflare';
import { ServerResponse } from 'node:http';
import { coupleWebSocket } from 'miniflare';
import { WebSocketServer } from 'ws';
import { cloudflare } from '@cloudflare/vite-plugin';
import { exactRegex } from '@rolldown/pluginutils';
// @ts-expect-error types are private
import { dedent } from '@sveltejs/kit/internal';

/** @type {import('./index.js').default} */
export default function (options = {}) {
const node_ws_server = new WebSocketServer({ noServer: true });
return {
name,
name: '@sveltejs/adapter-cloudflare',
async adapt(builder) {
if (
existsSync('_routes.json') ||
Expand Down Expand Up @@ -182,7 +188,13 @@ export default function (options = {}) {
// we want to invoke `getPlatformProxy` only once, but await it only when it is accessed.
// If we would await it here, it would hang indefinitely because the platform proxy only resolves once a request happens
const get_emulated = async () => {
const proxy = await getPlatformProxy(options.platformProxy);
// TODO - need access to the vite dev server here
// const runner = get_runner(vite, server);
// runner.import('cloudflare:workers');
const proxy = await getPlatformProxy({
configPath: '.svelte-kit/cloudflare-tmp/wrangler.json',
...options.platformProxy
});
const platform = {
env: proxy.env,
ctx: proxy.ctx,
Expand Down Expand Up @@ -215,10 +227,174 @@ export default function (options = {}) {
supports: {
read: () => true,
instrumentation: () => true
},
/**
*
* @param {WebSocketServerResponse} res
* @param {Response & { webSocket?: import('miniflare').WebSocket }} response
*/
setResponse(res, response) {
if (!response.webSocket || !res.socket || !res[WEBSOCKET_HEAD]) return false;

const socket = res.socket;
const worker_socket = response.webSocket;
res.detachSocket(socket);
node_ws_server.handleUpgrade(res.req, socket, res[WEBSOCKET_HEAD], (client_socket) => {
void coupleWebSocket(client_socket, worker_socket);
node_ws_server.emit('connection', client_socket, res.req);
});

return true;
},
vite: {
pre_plugins: [listener_plugin, virtual_modules_plugin(options)],
post_plugins: [
// Cloudflare's plugin needs to be after SvelteKit so sveltekit's middleware
// can read the requests first and ignore them if necessary.
...cloudflare({
configPath: options.config,
config: (user_config) => {
// Assets are handled by SvelteKit
delete user_config.assets;
return user_config;
}
})
]
}
};
}

/**
* @param {import('./index.js').AdapterOptions} options
* @returns {import('vite').Plugin}
* */
function virtual_modules_plugin(options) {
const modules = ['cloudflare:workers', 'virtual:todo-name-cloudflare-handler'];
/** @type {string} */
let out_dir;
const { wrangler_config } = validate_wrangler_config(options.config);
const worker_name = wrangler_config.name ?? 'worker';
// Rename the worker because two workers are running at once
wrangler_config.name = worker_name + '-sveltekit';
// Point all durable objects at the worker ran by @cloudflare/vite-plugin
for (const binding of wrangler_config.durable_objects?.bindings ?? []) {
if (binding.script_name === undefined) {
binding.script_name = worker_name;
}
}
// squash warnings
if (Object.keys(wrangler_config.unsafe).length === 0) {
delete /** @type {{ unsafe?: {} }} */ (wrangler_config).unsafe;
}

return {
name: 'vite-plugin-sveltekit-adapter-cloudflare-virtual-modules',
configResolved(config) {
const plugin = config.plugins.find((plugin) => plugin.name === 'vite-plugin-sveltekit-setup');
const options = plugin?.api?.options;
if (!options) throw new Error('vite-plugin-sveltekit-setup not found');
out_dir = options.kit.outDir;
},
resolveId: {
filter: {
id: modules.map((m) => exactRegex(m))
},
handler(id) {
return '\0' + id;
}
},
load: {
filter: {
id: modules.map((m) => exactRegex('\0' + m))
},
handler(id) {
if (id === '\0cloudflare:workers') {
return dedent`
import { getPlatformProxy } from 'wrangler';
import { writeFileSync, mkdirSync } from 'node:fs';

const tmp_dir = ${JSON.stringify(out_dir + '/cloudflare-tmp')};
mkdirSync(tmp_dir, { recursive: true });
const tmp_config_file = tmp_dir + '/wrangler.json';
writeFileSync(tmp_config_file, ${JSON.stringify(JSON.stringify(wrangler_config))});
const proxy = await getPlatformProxy({
...${JSON.stringify(options.platformProxy ?? {})},
configPath: tmp_config_file
});
export const env = proxy.env;
export const waitUntil = () => {};
// TODO - stub other exports
`;
}
}
}
};
}


const WEBSOCKET_HEAD = Symbol('websocketHead');
/** @typedef {import('http').ServerResponse & { [WEBSOCKET_HEAD]?: Buffer }} WebSocketServerResponse */
/** @type {import('vite').Plugin} */
const listener_plugin = {
name: 'vite-plugin-sveltekit-adapter-cloudflare-listeners',
enforce: 'post',
configureServer(server) {
return () => {
if (server.httpServer) {
const upgrade_listeners = server.httpServer
.listeners('upgrade')
.filter((listener) => listener.name !== 'hmrServerWsListener');

for (const listener of upgrade_listeners) {
server.httpServer.removeListener('upgrade', /** @type {() => void} */ (listener));
}

/**
* @param {import('vite').Connect.IncomingMessage} req
* @param {import('net').Socket} socket
* @param {Buffer} head
*/
const upgrade_handler = (req, socket, head) => {
if (req.headers['x-sveltekit-cloudflare-handle']) {
delete req.headers['x-sveltekit-cloudflare-handle'];
req.originalUrl = req.url;

const res = new ServerResponse(req);
res.assignSocket(socket);
/** @type {WebSocketServerResponse} */ (res)[WEBSOCKET_HEAD] = head;
handler(req, res);
return;
}
for (const listener of upgrade_listeners) {
listener(req, socket, head);
}
};

server.httpServer.on('upgrade', upgrade_handler);
}
const sveltekit_dev_middleware = server.middlewares.stack.find(
(middleware) =>
/** @type {Function} */ (middleware.handle).name === 'sveltekitDevMiddleware'
);
if (!sveltekit_dev_middleware) {
throw new Error('@sveltekit/adapter-cloudflare could not find sveltekitDevMiddleware');
}
const handler = /** @type {import('vite').Connect.SimpleHandleFunction} */ (
sveltekit_dev_middleware.handle
);
/** @type {import('vite').Connect.NextHandleFunction} */
sveltekit_dev_middleware.handle = (req, res, next) => {
if (req.headers['x-sveltekit-cloudflare-handle']) {
delete req.headers['x-sveltekit-cloudflare-handle'];
handler(req, res);
return;
}
next();
};
};
}
};

/**
* @param {string} app_dir
* @param {string | undefined} content existing `_headers` file content
Expand Down Expand Up @@ -277,7 +453,7 @@ _redirects
* building_for_cloudflare_pages: boolean
* }}
*/
function validate_wrangler_config(config_file = undefined) {
function validate_wrangler_config(config_file) {
const wrangler_config = unstable_readConfig({ config: config_file });

const building_for_cloudflare_pages = is_building_for_cloudflare_pages(wrangler_config);
Expand Down
14 changes: 12 additions & 2 deletions packages/adapter-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
"types": "./index.d.ts",
"import": "./index.js"
},
"./worker": {
"types": "./worker.d.ts",
"import": "./worker.js"
},
"./package.json": "./package.json"
},
"types": "index.d.ts",
Expand All @@ -46,13 +50,19 @@
},
"devDependencies": {
"@playwright/test": "catalog:",
"@rolldown/pluginutils": "^1.0.1",
"@sveltejs/kit": "workspace:^",
"@types/node": "catalog:",
"@types/ws": "^8.18.1",
"miniflare": "5.20260801.0-alpha",
"typescript": "catalog:",
"vitest": "catalog:"
"vite": "catalog:",
"vitest": "catalog:",
"ws": "^8.21.2"
},
"peerDependencies": {
"@cloudflare/vite-plugin": "^1.51.0",
"@sveltejs/kit": "^3.0.0-next.0",
"wrangler": "^4.118.0"
"wrangler": "^4.119.0"
}
}
1 change: 1 addition & 0 deletions packages/adapter-cloudflare/test/apps/workers/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ node_modules
# Cloudflare
.wrangler
/dist
worker-configuration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,31 @@
// to test that the adapter still resolves the paths correctly
{
"$schema": "../node_modules/wrangler/config-schema.json",
"main": "../dist/index.js",
"name": "adapter-cloudflare-test",
"main": "../src/worker.ts",
"compatibility_date": "2026-06-24",
"assets": {
"directory": "../dist/public",
"binding": "ASSETS"
}
},
"durable_objects": {
"bindings": [
{
"name": "DO",
"class_name": "DO"
}
]
},
"exports": {
"DO": {
"type": "durable-object",
"storage": "sqlite"
}
},
"kv_namespaces": [
{
"binding": "KV",
"remote": false
}
]
}
2 changes: 1 addition & 1 deletion packages/adapter-cloudflare/test/apps/workers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"dev": "vite dev",
"build": "vite build",
"preview": "wrangler dev dist/index.js --config config/wrangler.jsonc",
"prepare": "svelte-kit sync || echo ''",
"prepare": "svelte-kit sync || echo ''; wrangler types --config config/wrangler.jsonc || echo ''",
"test:dev": "DEV=true playwright test",
"test:build": "playwright test",
"test": "pnpm test:dev && pnpm test:build"
Expand Down
10 changes: 10 additions & 0 deletions packages/adapter-cloudflare/test/apps/workers/src/app.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
declare global {
namespace App {
interface Platform {
env: Cloudflare.Env;
ctx: Cloudflare.ExecutionContext;
}
}
}

export {};
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
<script>
export let data;
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';

const { data } = $props();

let ws_message = $state('');
let ws: WebSocket;
onMount(() => {
ws = new WebSocket(`${page.url.origin}/ws`);
ws.addEventListener('message', (event) => {
ws_message = event.data;
});
return () => ws.close();
})
</script>

<h1>Sum: {data.sum}</h1>

<h2>WebSocket message: {ws_message}</h2>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { env } from 'cloudflare:workers';

export const GET = async ({ request }) => {
const stub = env.DO.getByName('stub');
return stub.fetch(request.url, request);
}
24 changes: 24 additions & 0 deletions packages/adapter-cloudflare/test/apps/workers/src/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { DurableObject } from 'cloudflare:workers';
import { handler } from '../../../../worker.js';

export class DO extends DurableObject {
async fetch(_req: Request): Promise<Response> {
const { 0: client, 1: server } = new WebSocketPair();

this.ctx.acceptWebSocket(server);
setInterval(() => {
server.send(new Date().toISOString());
}, 1000);

return new Response(null, {
status: 101,
webSocket: client,
});
}
}

export default {
async fetch(request) {
return handler(request);
},
} satisfies ExportedHandler<Cloudflare.Env>;
13 changes: 2 additions & 11 deletions packages/adapter-cloudflare/test/apps/workers/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"moduleResolution": "bundler"
},
"extends": "$app/tsconfig"
"extends": "$app/tsconfig",
"include": ["src", "worker-configuration.d.ts"]
}
Loading
Loading