diff --git a/.changeset/tricky-drinks-develop.md b/.changeset/tricky-drinks-develop.md
new file mode 100644
index 000000000000..132ec31b0e64
--- /dev/null
+++ b/.changeset/tricky-drinks-develop.md
@@ -0,0 +1,7 @@
+---
+'@sveltejs/adapter-cloudflare': minor
+'@sveltejs/adapter-node': minor
+'@sveltejs/kit': minor
+---
+
+feat: add support for WebSockets
diff --git a/.changeset/two-islands-sleep.md b/.changeset/two-islands-sleep.md
new file mode 100644
index 000000000000..c3275023188e
--- /dev/null
+++ b/.changeset/two-islands-sleep.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/adapter-auto': patch
+---
+
+fix: add error message when using WebSockets as they are unsupported
diff --git a/documentation/docs/25-build-and-deploy/40-adapter-node.md b/documentation/docs/25-build-and-deploy/40-adapter-node.md
index ce0a5bccf95f..3007b837c4d7 100644
--- a/documentation/docs/25-build-and-deploy/40-adapter-node.md
+++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md
@@ -241,12 +241,12 @@ WantedBy=sockets.target
The adapter creates two files in your build directory — `index.js` and `handler.js`. Running `index.js` — e.g. `node build`, if you use the default build directory — will start a server on the configured port.
-Alternatively, you can import the `handler.js` file, which exports a handler suitable for use with [Express](https://github.com/expressjs/express), [Connect](https://github.com/senchalabs/connect) or [Polka](https://github.com/lukeed/polka) (or even just the built-in [`http.createServer`](https://nodejs.org/dist/latest/docs/api/http.html#httpcreateserveroptions-requestlistener)) and set up your own server:
+Alternatively, you can import the `handler.js` file, which exports handlers suitable for use with [Express](https://github.com/expressjs/express), [Connect](https://github.com/senchalabs/connect) or [Polka](https://github.com/lukeed/polka) (or even just the built-in [`http.createServer`](https://nodejs.org/dist/latest/docs/api/http.html#httpcreateserveroptions-requestlistener)) and set up your own server:
```js
// @errors: 2307 7006
/// file: my-server.js
-import { handler } from './build/handler.js';
+import { handler, upgradeHandler } from './build/handler.js';
import express from 'express';
const app = express();
@@ -256,10 +256,15 @@ app.get('/healthcheck', (req, res) => {
res.end('ok');
});
-// let SvelteKit handle everything else, including serving prerendered pages and static assets
+// let SvelteKit handle serving prerendered pages, static assets, and SSR
app.use(handler);
-app.listen(3000, () => {
+const server = app.listen(3000, () => {
console.log('listening on port 3000');
});
+
+// let SvelteKit handle upgrades for WebSocket connections
+server.on('upgrade', upgradeHandler);
```
+
+If you're manually handling the `SIGTERM` and `SIGINT` signal events to implement your own graceful shutdown, you must use the `closeAllWebSockets` and the `terminateAllWebSockets` helpers imported from the `handler.js` file to gracefully close or immediately terminate all active WebSocket connections.
diff --git a/documentation/docs/25-build-and-deploy/99-writing-adapters.md b/documentation/docs/25-build-and-deploy/99-writing-adapters.md
index a2bfb50cd7b2..f3759acf70a7 100644
--- a/documentation/docs/25-build-and-deploy/99-writing-adapters.md
+++ b/documentation/docs/25-build-and-deploy/99-writing-adapters.md
@@ -34,6 +34,23 @@ export default function (options) {
// Return `true` if the route with the given `config` can use `read`
// from `$app/server` in production, return `false` if it can't.
// Or throw a descriptive error describing how to configure the deployment
+ },
+ webSockets: {
+ socket: () => {
+ // Return `true` if the production environment supports WebSockets,
+ // return `false` if it can't.
+ // Or throw a descriptive error describing how to configure the deployment
+ },
+ getPeers: ({ route }) => {
+ // Return `true` if the production environment supports WebSockets,
+ // return `false` if it can't.
+ // Or throw a descriptive error describing how to configure the deployment
+ },
+ publish: ({ route }) => {
+ // Return `true` if the production environment supports coordination among
+ // multiple WebSockets, return `false` if it can't.
+ // Or throw a descriptive error describing how to configure the deployment
+ }
}
}
};
@@ -51,6 +68,7 @@ Within the `adapt` method, there are a number of things that an adapter should d
- Output code that:
- Imports `Server` from `${builder.getServerDirectory()}/index.js`
- Instantiates the app with a manifest generated with `builder.generateManifest({ relativePath })`
+ - Initialises the server by calling the `server.init({ env })` function
- Listens for requests from the platform, converts them to a standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) if necessary, calls the `server.respond(request, { getClientAddress })` function to generate a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) and responds with it
- expose any platform-specific information to SvelteKit via the `platform` option passed to `server.respond`
- Globally shims `fetch` to work on the target platform, if necessary. SvelteKit provides a `@sveltejs/kit/node/polyfills` helper for platforms that can use `undici`
@@ -58,3 +76,8 @@ Within the `adapt` method, there are a number of things that an adapter should d
- Put the user's static files and the generated JS/CSS in the correct location for the target platform
Where possible, we recommend putting the adapter output under the `build/` directory with any intermediate output placed under `.svelte-kit/[adapter-name]`.
+
+If your environment supports WebSockets, you will need to configure the `supports.webSockets` property returned by the adapter and integrate [`crossws`](https://crossws.unjs.io/adapters) into your adapter by outputting code that:
+
+- Initialises the server with the `crossws` adapter's `peers` and `publish` utilities by calling the `server.init({ env, peers, publish })` function
+- Listens for requests from the platform that have an `Upgrade: websocket` header, converts them to a standard `Request` if necessary, calls the `server.resolveWebSocketHooks(request, { getClientAddress })` function to resolve the WebSocket hooks, and passes it to the `crossws` adapter's `resolve` option.
diff --git a/documentation/docs/30-advanced/15-websockets.md b/documentation/docs/30-advanced/15-websockets.md
new file mode 100644
index 000000000000..ef615670bb36
--- /dev/null
+++ b/documentation/docs/30-advanced/15-websockets.md
@@ -0,0 +1,137 @@
+---
+title: WebSockets
+---
+
+[WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) provide a way to open a bidirectional communication channel between a client and a server. SvelteKit uses [`crossws`](https://crossws.unjs.io/) to provide a consistent interface across different platforms.
+
+## Hooks
+
+A `+server.js` file can export a `socket` object with [hooks](https://crossws.unjs.io/guide/hooks), all optional, to handle the different stages of the WebSocket lifecycle.
+
+```js
+/** @type {import('./$types').Socket} **/
+export const socket = {
+ upgrade(event) {
+ // ...
+ },
+ open(peer) {
+ // ...
+ },
+ message(peer, message) {
+ // ...
+ },
+ close(peer, details) {
+ // ...
+ },
+ error(peer, error) {
+ // ...
+ }
+};
+```
+
+### upgrade
+
+The `upgrade` hook is called before a WebSocket connection is established. It receives a [`RequestEvent`](@sveltejs-kit#RequestEvent) object that has an additional [`context`](https://crossws.unjs.io/guide/peer#peercontext) property to store abitrary information that is shared with that connection's [`Peer`](https://crossws.unjs.io/guide/peer) object.
+
+You can use the [`error`](@sveltejs-kit#error) function imported from `@sveltejs/kit` to easily reject connections. Requests will be auto-accepted if the `upgrade` hook is not defined or does not `error`.
+
+```js
+import { error } from "@sveltejs/kit";
+
+/** @type {import('./$types').Socket} **/
+export const socket = {
+ upgrade({ request }) {
+ if (request.headers.get('origin') !== 'my_allowed_origin') {
+ // Reject the WebSocket connection by throwing an error
+ error(403, 'Forbidden');
+ }
+ }
+};
+```
+
+### open
+
+The `open` hook is called when a WebSocket connection is opened. It receives a [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients.
+
+```js
+/** @type {import('./$types').Socket} **/
+export const socket = {
+ open(peer) {
+ // ...
+ }
+};
+```
+
+### message
+
+The `message` hook is called when a message is received from the client. It receives the [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients and the [`Message`](https://crossws.unjs.io/guide/message) object which contains data from the client.
+
+```js
+/** @type {import('./$types').Socket} **/
+export const socket = {
+ message(peer, message) {
+ // ...
+ }
+};
+```
+
+### close
+
+The `close` hook is called when a WebSocket connection is closed. It receives the [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients and the close details object which contains the [WebSocket connection close code](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code#value) and reason.
+
+```js
+/** @type {import('./$types').Socket} **/
+export const socket = {
+ close(peer, details) {
+ // ...
+ }
+};
+```
+
+### error
+
+The `error` hook is called when a connection with a WebSocket has been closed due to an error. It receives the [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients and the error.
+
+```js
+/** @type {import('./$types').Socket} **/
+export const socket = {
+ error(peer, error) {
+ // ...
+ }
+};
+```
+
+## Accessing `RequestEvent` through `Peer`
+
+The [`Peer`](https://crossws.unjs.io/guide/peer) object has been extended to include the [`RequestEvent`](@sveltejs-kit#RequestEvent) object from the initial upgrade request. It can be accessed through the `peer.event` property.
+
+## `getPeers` and `publish`
+
+The [`getPeers`]($app-server#getPeers) and [`publish`]($app-server#publish) functions from `$app/server` can be used to interact with your WebSocket connections from anywhere on the server.
+
+## Connecting from the client
+
+To connect to a WebSocket endpoint, you can use the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket) constructor in the browser.
+
+```svelte
+
+```
+
+See [the WebSocket documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) for more details.
+
+## Compatibility
+
+Please refer to the crossws [`Peer` object compatibility table](https://crossws.unjs.io/guide/peer#compatibility) and [`Message` object compatibility table](https://crossws.unjs.io/guide/message#adapter-support) to know what is supported in different runtime environments.
diff --git a/eslint.config.js b/eslint.config.js
index 8444424cd723..8942562f7efb 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -31,8 +31,9 @@ export default [
},
ignores: [
'packages/adapter-cloudflare/test/apps/**/*',
+ 'packages/adapter-node/test/apps/**/*',
'packages/adapter-node/rollup.config.js',
- 'packages/adapter-node/tests/smoke.spec_disabled.js',
+ 'packages/adapter-node/smoke.spec_disabled.js',
'packages/adapter-static/test/apps/**/*',
'packages/create-svelte/shared/**/*',
'packages/create-svelte/templates/**/*',
diff --git a/packages/adapter-auto/index.js b/packages/adapter-auto/index.js
index ccd20f600e56..0714f135e15c 100644
--- a/packages/adapter-auto/index.js
+++ b/packages/adapter-auto/index.js
@@ -125,6 +125,21 @@ export default () => ({
supports_error(
'The read function imported from $app/server only works in certain environments'
);
+ },
+ webSockets: {
+ socket: () => {
+ supports_error('The socket export only works in environments that support WebSockets');
+ },
+ getPeers: () => {
+ supports_error(
+ 'The getPeers function imported from $app/server only works in environments that support WebSockets'
+ );
+ },
+ publish: () => {
+ supports_error(
+ 'The publish function imported from $app/server only works in environments that support WebSockets'
+ );
+ }
}
}
});
diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js
index af99452ef8fd..5d4b10556df1 100644
--- a/packages/adapter-cloudflare/index.js
+++ b/packages/adapter-cloudflare/index.js
@@ -4,10 +4,12 @@ import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { getPlatformProxy, unstable_readConfig } from 'wrangler';
+const name = '@sveltejs/adapter-cloudflare';
+
/** @type {import('./index.js').default} */
export default function (options = {}) {
return {
- name: '@sveltejs/adapter-cloudflare',
+ name,
async adapt(builder) {
if (existsSync('_routes.json')) {
throw new Error(
@@ -160,6 +162,18 @@ export default function (options = {}) {
return prerender ? emulated.prerender_platform : emulated.platform;
}
};
+ },
+ supports: {
+ webSockets: {
+ socket: () => true,
+ getPeers: () => true,
+ publish: ({ route }) => {
+ // TODO: allow WebSocket integration with Durable Objects using crossws/adapters/cloudflare-durable
+ throw new Error(
+ `${name}: Cannot use \`publish\` from \`$app/server\` in route \`${route.id}\` because Cloudflare Workers cannot coordinate among multiple WebSocket connections without Durable Objects`
+ );
+ }
+ }
}
};
}
diff --git a/packages/adapter-cloudflare/package.json b/packages/adapter-cloudflare/package.json
index fa24270f8faa..dab585127c78 100644
--- a/packages/adapter-cloudflare/package.json
+++ b/packages/adapter-cloudflare/package.json
@@ -42,6 +42,7 @@
},
"dependencies": {
"@cloudflare/workers-types": "^4.20250415.0",
+ "crossws": "^0.3.4",
"worktop": "0.8.0-next.18"
},
"devDependencies": {
diff --git a/packages/adapter-cloudflare/src/worker.js b/packages/adapter-cloudflare/src/worker.js
index a2ea423c1653..f3d753218399 100644
--- a/packages/adapter-cloudflare/src/worker.js
+++ b/packages/adapter-cloudflare/src/worker.js
@@ -1,6 +1,7 @@
import { Server } from 'SERVER';
import { manifest, prerendered, base_path } from 'MANIFEST';
import * as Cache from 'worktop/cfw.cache';
+import crossws from 'crossws/adapters/cloudflare';
const server = new Server(manifest);
@@ -9,6 +10,17 @@ const app_path = `/${manifest.appPath}`;
const immutable = `${app_path}/immutable/`;
const version_file = `${app_path}/version.json`;
+/** @type {import('crossws').ResolveHooks} */
+let resolve_websocket_hooks;
+/** @type {import('crossws/adapters/cloudflare').CloudflareAdapter} */
+let ws;
+
+if (server.resolveWebSocketHooks) {
+ ws = crossws({
+ resolve: (req) => resolve_websocket_hooks(req)
+ });
+}
+
export default {
/**
* @param {Request} req
@@ -17,11 +29,38 @@ export default {
* @returns {Promise}
*/
async fetch(req, env, context) {
+ const options = /** @satisfies {Parameters[1]} */ ({
+ platform: {
+ env,
+ context,
+ // @ts-expect-error webworker types from worktop are not compatible with Cloudflare Workers types
+ caches,
+ // @ts-expect-error the type is correct but ts is confused because platform.cf uses the type from index.ts while req.cf uses the type from index.d.ts
+ cf: req.cf
+ },
+ getClientAddress() {
+ return req.headers.get('cf-connecting-ip');
+ }
+ });
+
await server.init({
// @ts-expect-error env contains environment variables and bindings
- env
+ env,
+ peers: ws?.peers,
+ publish: ws?.publish
});
+ if (req.headers.get('upgrade') === 'websocket' && ws) {
+ const hooks = await server.resolveWebSocketHooks(
+ req,
+ // @ts-ignore
+ options
+ );
+ resolve_websocket_hooks = () => hooks;
+ // @ts-ignore wtf is Cloudflare doing to these types
+ return ws.handleUpgrade(req, env, context);
+ }
+
// 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));
@@ -67,19 +106,11 @@ export default {
});
} 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,
- // @ts-expect-error the type is correct but ts is confused because platform.cf uses the type from index.ts while req.cf uses the type from index.d.ts
- cf: req.cf
- },
- getClientAddress() {
- return req.headers.get('cf-connecting-ip');
- }
- });
+ res = await server.respond(
+ req,
+ // @ts-ignore wtf is Cloudflare doing to these types
+ options
+ );
}
// write to `Cache` only if response is not an error,
diff --git a/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+page.svelte b/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+page.svelte
new file mode 100644
index 000000000000..59ca583f5c51
--- /dev/null
+++ b/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+page.svelte
@@ -0,0 +1,14 @@
+
+
+{message}
diff --git a/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+server.js b/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+server.js
new file mode 100644
index 000000000000..0d64dc2a0713
--- /dev/null
+++ b/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+server.js
@@ -0,0 +1,6 @@
+/** @type {import('./$types').Socket} */
+export const socket = {
+ open(peer) {
+ peer.send('connected');
+ }
+};
diff --git a/packages/adapter-cloudflare/test/apps/pages/test/test.js b/packages/adapter-cloudflare/test/apps/pages/test/test.js
index aab7cbca568a..c32d4f63a294 100644
--- a/packages/adapter-cloudflare/test/apps/pages/test/test.js
+++ b/packages/adapter-cloudflare/test/apps/pages/test/test.js
@@ -4,3 +4,8 @@ test('worker works', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('Sum: 3');
});
+
+test('WebSockets work', async ({ page }) => {
+ await page.goto('/ws');
+ await expect(page.locator('p')).toContainText('connected');
+});
diff --git a/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+page.svelte b/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+page.svelte
new file mode 100644
index 000000000000..59ca583f5c51
--- /dev/null
+++ b/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+page.svelte
@@ -0,0 +1,14 @@
+
+
+{message}
diff --git a/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+server.js b/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+server.js
new file mode 100644
index 000000000000..0d64dc2a0713
--- /dev/null
+++ b/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+server.js
@@ -0,0 +1,6 @@
+/** @type {import('./$types').Socket} */
+export const socket = {
+ open(peer) {
+ peer.send('connected');
+ }
+};
diff --git a/packages/adapter-cloudflare/test/apps/workers/test/test.js b/packages/adapter-cloudflare/test/apps/workers/test/test.js
index aab7cbca568a..c32d4f63a294 100644
--- a/packages/adapter-cloudflare/test/apps/workers/test/test.js
+++ b/packages/adapter-cloudflare/test/apps/workers/test/test.js
@@ -4,3 +4,8 @@ test('worker works', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('Sum: 3');
});
+
+test('WebSockets work', async ({ page }) => {
+ await page.goto('/ws');
+ await expect(page.locator('p')).toContainText('connected');
+});
diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js
index 9b0b3158ab82..9a841751e519 100644
--- a/packages/adapter-node/index.js
+++ b/packages/adapter-node/index.js
@@ -92,7 +92,12 @@ export default function (opts = {}) {
},
supports: {
- read: () => true
+ read: () => true,
+ webSockets: {
+ socket: () => true,
+ getPeers: () => true,
+ publish: () => true
+ }
}
};
}
diff --git a/packages/adapter-node/internal.d.ts b/packages/adapter-node/internal.d.ts
index fed0584d1851..7b7515109fa9 100644
--- a/packages/adapter-node/internal.d.ts
+++ b/packages/adapter-node/internal.d.ts
@@ -4,6 +4,9 @@ declare module 'ENV' {
declare module 'HANDLER' {
export const handler: import('polka').Middleware;
+ export const upgradeHandler: import('crossws/adapters/node').NodeAdapter['handleUpgrade'];
+ export const closeAllWebSockets: () => void;
+ export const terminateAllWebSockets: () => void;
}
declare module 'MANIFEST' {
diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json
index 7f5f72c60d02..79a976d4bfb9 100644
--- a/packages/adapter-node/package.json
+++ b/packages/adapter-node/package.json
@@ -35,17 +35,20 @@
"scripts": {
"dev": "node -e \"fs.rmSync('files', { force: true, recursive: true })\" && rollup -cw",
"build": "node -e \"fs.rmSync('files', { force: true, recursive: true })\" && rollup -c",
- "test": "vitest run",
"check": "tsc",
"lint": "prettier --check .",
"format": "pnpm lint --write",
- "prepublishOnly": "pnpm build"
+ "prepublishOnly": "pnpm build",
+ "test": "pnpm test:unit && pnpm test:integration",
+ "test:unit": "vitest run",
+ "test:integration": "pnpm build && pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test"
},
"devDependencies": {
"@polka/url": "^1.0.0-next.28",
"@sveltejs/kit": "workspace:^",
"@sveltejs/vite-plugin-svelte": "^5.0.1",
"@types/node": "^18.19.48",
+ "crossws": "^0.3.4",
"polka": "^1.0.0-next.28",
"sirv": "^3.0.0",
"typescript": "^5.3.3",
diff --git a/packages/adapter-node/tests/smoke.spec_disabled.js b/packages/adapter-node/smoke.spec_disabled.js
similarity index 96%
rename from packages/adapter-node/tests/smoke.spec_disabled.js
rename to packages/adapter-node/smoke.spec_disabled.js
index 58809bf6cb59..c4e104a4ab01 100644
--- a/packages/adapter-node/tests/smoke.spec_disabled.js
+++ b/packages/adapter-node/smoke.spec_disabled.js
@@ -1,6 +1,6 @@
import process from 'node:process';
import { assert, test } from 'vitest';
-import { create_kit_middleware } from '../src/handler.js';
+import { create_kit_middleware } from './src/handler.js';
import fetch from 'node-fetch';
import polka from 'polka';
diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js
index 37827e64042c..411497828955 100644
--- a/packages/adapter-node/src/handler.js
+++ b/packages/adapter-node/src/handler.js
@@ -2,6 +2,7 @@ import 'SHIMS';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
+import crossws from 'crossws/adapters/node';
import sirv from 'sirv';
import { fileURLToPath } from 'node:url';
import { parse as polka_url_parser } from '@polka/url';
@@ -34,9 +35,29 @@ const dir = path.dirname(fileURLToPath(import.meta.url));
const asset_dir = `${dir}/client${base}`;
+/** @type {import('crossws').ResolveHooks} */
+let resolve_websocket_hooks;
+/** @type {import('crossws/adapters/node').NodeAdapter} */
+let ws;
+
+if (server.resolveWebSocketHooks) {
+ ws = crossws({
+ resolve: (req) => resolve_websocket_hooks(req),
+ serverOptions: {
+ // we need to disable the `ws` package's default behaviour of automatically
+ // returning the request's sec-websocket-protocol header in the response
+ // to avoid sending that header multiple times if the user also returns that header.
+ // TODO: we could remove this if https://github.com/unjs/crossws/pull/142 standardises this behaviour
+ handleProtocols: () => false
+ }
+ });
+}
+
await server.init({
env: process.env,
- read: (file) => createReadableStream(`${asset_dir}/${file}`)
+ read: (file) => createReadableStream(`${asset_dir}/${file}`),
+ peers: ws?.peers,
+ publish: ws?.publish
});
/**
@@ -91,6 +112,55 @@ function serve_prerendered() {
};
}
+/**
+ * @param {import('node:http').IncomingMessage} req
+ */
+function get_options(req) {
+ return /** @satisfies {Parameters[1]} */ ({
+ platform: { req },
+ /**
+ * @returns {string}
+ */
+ getClientAddress: () => {
+ if (address_header) {
+ if (!(address_header in req.headers)) {
+ throw new Error(
+ `Address header was specified with ${ENV_PREFIX + 'ADDRESS_HEADER'}=${address_header} but is absent from request`
+ );
+ }
+
+ const value = /** @type {string} */ (req.headers[address_header]) || '';
+
+ if (address_header === 'x-forwarded-for') {
+ const addresses = value.split(',');
+
+ if (xff_depth < 1) {
+ throw new Error(`${ENV_PREFIX + 'XFF_DEPTH'} must be a positive integer`);
+ }
+
+ if (xff_depth > addresses.length) {
+ throw new Error(
+ `${ENV_PREFIX + 'XFF_DEPTH'} is ${xff_depth}, but only found ${addresses.length} addresses`
+ );
+ }
+ return addresses[addresses.length - xff_depth].trim();
+ }
+
+ return value;
+ }
+
+ return (
+ req.connection?.remoteAddress ||
+ // @ts-expect-error
+ req.connection?.socket?.remoteAddress ||
+ req.socket?.remoteAddress ||
+ // @ts-expect-error
+ req.info?.remoteAddress
+ );
+ }
+ });
+}
+
/** @type {import('polka').Middleware} */
const ssr = async (req, res) => {
/** @type {Request} */
@@ -108,53 +178,7 @@ const ssr = async (req, res) => {
return;
}
- await setResponse(
- res,
- await server.respond(request, {
- platform: { req },
- getClientAddress: () => {
- if (address_header) {
- if (!(address_header in req.headers)) {
- throw new Error(
- `Address header was specified with ${
- ENV_PREFIX + 'ADDRESS_HEADER'
- }=${address_header} but is absent from request`
- );
- }
-
- const value = /** @type {string} */ (req.headers[address_header]) || '';
-
- if (address_header === 'x-forwarded-for') {
- const addresses = value.split(',');
-
- if (xff_depth < 1) {
- throw new Error(`${ENV_PREFIX + 'XFF_DEPTH'} must be a positive integer`);
- }
-
- if (xff_depth > addresses.length) {
- throw new Error(
- `${ENV_PREFIX + 'XFF_DEPTH'} is ${xff_depth}, but only found ${
- addresses.length
- } addresses`
- );
- }
- return addresses[addresses.length - xff_depth].trim();
- }
-
- return value;
- }
-
- return (
- req.connection?.remoteAddress ||
- // @ts-expect-error
- req.connection?.socket?.remoteAddress ||
- req.socket?.remoteAddress ||
- // @ts-expect-error
- req.info?.remoteAddress
- );
- }
- })
- );
+ await setResponse(res, await server.respond(request, get_options(req)));
};
/** @param {import('polka').Middleware[]} handlers */
@@ -200,3 +224,56 @@ export const handler = sequence(
ssr
].filter(Boolean)
);
+
+/**
+ * @param {import('node:http').IncomingMessage} req
+ * @param {import('node:stream').Duplex} socket
+ * @param {Buffer} head
+ */
+export async function upgradeHandler(req, socket, head) {
+ if (req.headers.upgrade === 'websocket' && ws) {
+ /** @type {Request} */
+ let request;
+
+ // the crossws Node adapter doesn't actually pass a Request object, so we need to create one
+ // see https://github.com/unjs/crossws/issues/137
+ try {
+ request = await getRequest({
+ base: origin || get_origin(req.headers),
+ request: req,
+ bodySizeLimit: body_size_limit
+ });
+ } catch {
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
+ socket.end();
+ return;
+ }
+
+ const hooks = await server.resolveWebSocketHooks(request, get_options(req));
+ resolve_websocket_hooks = () => hooks;
+
+ // eslint-disable-next-line @typescript-eslint/await-thenable -- this function call is awaitable but the crossws type fix hasn't been released yet
+ await ws.handleUpgrade(req, socket, head);
+ // TODO: remove this block once https://github.com/unjs/crossws/pull/140 is merged
+ if (socket.writableFinished) {
+ socket.destroy();
+ } else {
+ socket.once('finish', socket.destroy);
+ }
+ }
+}
+
+export function closeAllWebSockets() {
+ if (ws) {
+ ws.closeAll();
+ }
+}
+
+export function terminateAllWebSockets() {
+ if (ws) {
+ // TODO: replace this once https://github.com/unjs/crossws/issues/145 is resolved
+ ws.peers.forEach((peer) => {
+ peer.terminate();
+ });
+ }
+}
diff --git a/packages/adapter-node/src/index.js b/packages/adapter-node/src/index.js
index ef1ab701a2a3..41fbcf19a90f 100644
--- a/packages/adapter-node/src/index.js
+++ b/packages/adapter-node/src/index.js
@@ -1,5 +1,5 @@
import process from 'node:process';
-import { handler } from 'HANDLER';
+import { handler, upgradeHandler, closeAllWebSockets, terminateAllWebSockets } from 'HANDLER';
import { env } from 'ENV';
import polka from 'polka';
@@ -43,6 +43,9 @@ if (socket_activation) {
});
}
+// Register the upgrade handler after the listen call, when the internal server is available
+server.server.on('upgrade', upgradeHandler);
+
/** @param {'SIGINT' | 'SIGTERM' | 'IDLE'} reason */
function graceful_shutdown(reason) {
if (shutdown_timeout_id) return;
@@ -67,11 +70,13 @@ function graceful_shutdown(reason) {
process.emit('sveltekit:shutdown', reason);
});
- shutdown_timeout_id = setTimeout(
+ closeAllWebSockets();
+
+ shutdown_timeout_id = setTimeout(() => {
// @ts-expect-error this was added in 18.2.0 but is not reflected in the types
- () => server.server.closeAllConnections(),
- shutdown_timeout * 1000
- );
+ server.server.closeAllConnections();
+ terminateAllWebSockets();
+ }, shutdown_timeout * 1000);
}
server.server.on(
diff --git a/packages/adapter-node/test/apps/basic/.gitignore b/packages/adapter-node/test/apps/basic/.gitignore
new file mode 100644
index 000000000000..de1a7689bb6b
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/.gitignore
@@ -0,0 +1,4 @@
+.DS_Store
+node_modules
+/.svelte-kit
+/build
\ No newline at end of file
diff --git a/packages/adapter-node/test/apps/basic/package.json b/packages/adapter-node/test/apps/basic/package.json
new file mode 100644
index 000000000000..28da8b15eb3f
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "test-node",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "ORIGIN=http://localhost:3000 node build",
+ "prepare": "svelte-kit sync || echo ''",
+ "test": "playwright test"
+ },
+ "devDependencies": {
+ "@sveltejs/kit": "workspace:^",
+ "@sveltejs/vite-plugin-svelte": "^5.0.1",
+ "svelte": "^5.23.1",
+ "vite": "^6.0.11"
+ },
+ "type": "module"
+}
diff --git a/packages/adapter-node/test/apps/basic/playwright.config.js b/packages/adapter-node/test/apps/basic/playwright.config.js
new file mode 100644
index 000000000000..33d36b651014
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/playwright.config.js
@@ -0,0 +1 @@
+export { config as default } from '../../utils.js';
diff --git a/packages/adapter-node/test/apps/basic/src/app.html b/packages/adapter-node/test/apps/basic/src/app.html
new file mode 100644
index 000000000000..d533c5e31716
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/src/app.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/packages/adapter-node/test/apps/basic/src/routes/ws/+page.svelte b/packages/adapter-node/test/apps/basic/src/routes/ws/+page.svelte
new file mode 100644
index 000000000000..3b490a863ce2
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/src/routes/ws/+page.svelte
@@ -0,0 +1,15 @@
+
+
+{message}
diff --git a/packages/adapter-node/test/apps/basic/src/routes/ws/+server.js b/packages/adapter-node/test/apps/basic/src/routes/ws/+server.js
new file mode 100644
index 000000000000..0d64dc2a0713
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/src/routes/ws/+server.js
@@ -0,0 +1,6 @@
+/** @type {import('./$types').Socket} */
+export const socket = {
+ open(peer) {
+ peer.send('connected');
+ }
+};
diff --git a/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.server.js b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.server.js
new file mode 100644
index 000000000000..7df4ecb6cbaf
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.server.js
@@ -0,0 +1,13 @@
+import { publish, getPeers } from '$app/server';
+
+export const actions = {
+ publish: async () => {
+ publish('users', 'created a new user');
+ },
+ peers: async () => {
+ const peers = getPeers();
+ peers.forEach((peer) => {
+ peer.send('sent to each peer');
+ });
+ }
+};
diff --git a/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.svelte b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.svelte
new file mode 100644
index 000000000000..6ec87ca0d71f
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.svelte
@@ -0,0 +1,33 @@
+
+
+
+ {#each messages as message}
+ {message}
+ {/each}
+
+
+
+
+
diff --git a/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+server.js b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+server.js
new file mode 100644
index 000000000000..92e26abafe0b
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+server.js
@@ -0,0 +1,6 @@
+/** @type {import('./$types').Socket} */
+export const socket = {
+ open(peer) {
+ peer.subscribe('users');
+ }
+};
diff --git a/packages/adapter-node/test/apps/basic/svelte.config.js b/packages/adapter-node/test/apps/basic/svelte.config.js
new file mode 100644
index 000000000000..20cd2b3ff5b8
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/svelte.config.js
@@ -0,0 +1,10 @@
+import adapter from '../../../index.js';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ kit: {
+ adapter: adapter()
+ }
+};
+
+export default config;
diff --git a/packages/adapter-node/test/apps/basic/test/test.js b/packages/adapter-node/test/apps/basic/test/test.js
new file mode 100644
index 000000000000..682cf4686e1b
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/test/test.js
@@ -0,0 +1,20 @@
+import { expect, test } from '@playwright/test';
+
+test('WebSockets work', async ({ page }) => {
+ await page.goto('/ws');
+ await expect(page.locator('p')).toContainText('connected');
+});
+
+test('WebSockets getPeers helper', async ({ page }) => {
+ await page.goto('/ws/helpers');
+ await expect(page.getByText('connected')).toBeVisible();
+ await page.locator('button', { hasText: 'message all peers' }).click();
+ await expect(page.getByText('sent to each peer')).toBeVisible();
+});
+
+test('WebSockets publish helper', async ({ page }) => {
+ await page.goto('/ws/helpers');
+ await expect(page.getByText('connected')).toBeVisible();
+ await page.locator('button', { hasText: 'create user' }).click();
+ await expect(page.getByText('created a new user')).toBeVisible();
+});
diff --git a/packages/adapter-node/test/apps/basic/tsconfig.json b/packages/adapter-node/test/apps/basic/tsconfig.json
new file mode 100644
index 000000000000..34380ebc986e
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ },
+ "extends": "./.svelte-kit/tsconfig.json"
+}
diff --git a/packages/adapter-node/test/apps/basic/vite.config.js b/packages/adapter-node/test/apps/basic/vite.config.js
new file mode 100644
index 000000000000..29ad08debe6a
--- /dev/null
+++ b/packages/adapter-node/test/apps/basic/vite.config.js
@@ -0,0 +1,11 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+
+/** @type {import('vite').UserConfig} */
+const config = {
+ build: {
+ minify: false
+ },
+ plugins: [sveltekit()]
+};
+
+export default config;
diff --git a/packages/adapter-node/test/utils.js b/packages/adapter-node/test/utils.js
new file mode 100644
index 000000000000..b4bdc92a77d4
--- /dev/null
+++ b/packages/adapter-node/test/utils.js
@@ -0,0 +1,28 @@
+import { devices } from '@playwright/test';
+import process from 'node:process';
+
+/** @type {import('@playwright/test').PlaywrightTestConfig} */
+export const config = {
+ forbidOnly: !!process.env.CI,
+ // generous timeouts on CI
+ timeout: process.env.CI ? 45000 : 15000,
+ webServer: {
+ command: 'pnpm build && pnpm preview',
+ port: 3000
+ },
+ retries: process.env.CI ? 2 : 0,
+ projects: [
+ {
+ name: 'chromium'
+ }
+ ],
+ use: {
+ ...devices['Desktop Chrome'],
+ screenshot: 'only-on-failure',
+ trace: 'retain-on-failure'
+ },
+ workers: process.env.CI ? 2 : undefined,
+ reporter: 'list',
+ testDir: 'test',
+ testMatch: /(.+\.)?(test|spec)\.[jt]s/
+};
diff --git a/packages/adapter-node/tsconfig.json b/packages/adapter-node/tsconfig.json
index 895d76f908cf..8658c9b8b344 100644
--- a/packages/adapter-node/tsconfig.json
+++ b/packages/adapter-node/tsconfig.json
@@ -13,6 +13,12 @@
"@sveltejs/kit": ["../kit/types/index"]
}
},
- "include": ["index.js", "src/**/*.js", "tests/**/*.js", "internal.d.ts", "utils.js"],
- "exclude": ["tests/smoke.spec_disabled.js"]
+ "include": [
+ "index.js",
+ "src/**/*.js",
+ "internal.d.ts",
+ "utils.js",
+ "utils.spec.js",
+ "test/utils.js"
+ ]
}
diff --git a/packages/adapter-node/tests/utils.spec.js b/packages/adapter-node/utils.spec.js
similarity index 91%
rename from packages/adapter-node/tests/utils.spec.js
rename to packages/adapter-node/utils.spec.js
index 06a495fde9ba..3f359ccaaba0 100644
--- a/packages/adapter-node/tests/utils.spec.js
+++ b/packages/adapter-node/utils.spec.js
@@ -1,5 +1,5 @@
import { expect, test, describe } from 'vitest';
-import { parse_as_bytes } from '../utils.js';
+import { parse_as_bytes } from './utils.js';
describe('parse_as_bytes', () => {
test('parses correctly', () => {
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 2273ec079873..41f4296aa3d6 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -20,6 +20,7 @@
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^0.6.0",
+ "crossws": "^0.3.4",
"devalue": "^5.1.0",
"esm-env": "^1.2.2",
"import-meta-resolve": "^4.1.0",
diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js
index 25bd403f1eb8..186bc25b091e 100644
--- a/packages/kit/src/core/postbuild/analyse.js
+++ b/packages/kit/src/core/postbuild/analyse.js
@@ -57,6 +57,8 @@ async function analyse({
internal.set_safe_public_env(public_env);
internal.set_manifest(manifest);
internal.set_read_implementation((file) => createReadableStream(`${server_root}/server/${file}`));
+ internal.set_peers(new Set());
+ internal.set_publish_implementation(() => {});
/** @type {import('types').ServerMetadata} */
const metadata = {
@@ -96,6 +98,14 @@ async function analyse({
const endpoint = route.endpoint && analyse_endpoint(route, await route.endpoint());
+ // we need to perform this check ourselves because `list_features` only includes
+ // chunks that have imported a feature, but using WebSockets doesn't involve any imports
+ if (endpoint?.socket && !config.adapter?.supports?.webSockets?.socket()) {
+ throw new Error(
+ `Cannot export \`socket\` in ${route.id} when using ${config.adapter?.name}. Please ensure that your adapter is up to date and supports this feature.`
+ );
+ }
+
if (page?.prerender && endpoint?.prerender) {
throw new Error(`Cannot prerender a route with both +page and +server files (${route.id})`);
}
@@ -153,9 +163,9 @@ async function analyse({
function analyse_endpoint(route, mod) {
validate_server_exports(mod, route.id);
- if (mod.prerender && (mod.POST || mod.PATCH || mod.PUT || mod.DELETE)) {
+ if (mod.prerender && (mod.POST || mod.PATCH || mod.PUT || mod.DELETE || mod.socket)) {
throw new Error(
- `Cannot prerender a +server file with POST, PATCH, PUT, or DELETE (${route.id})`
+ `Cannot prerender a +server file with POST, PATCH, PUT, DELETE, or socket (${route.id})`
);
}
@@ -174,6 +184,7 @@ function analyse_endpoint(route, mod) {
config: mod.config,
entries: mod.entries,
methods,
+ socket: !!mod.socket,
prerender: mod.prerender ?? false
};
}
diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js
index 5e93d5c1cd25..cf72b01e2ac3 100644
--- a/packages/kit/src/core/sync/write_server.js
+++ b/packages/kit/src/core/sync/write_server.js
@@ -31,7 +31,7 @@ const server_template = ({
import root from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}';
import { set_building, set_prerendering } from '__sveltekit/environment';
import { set_assets } from '__sveltekit/paths';
-import { set_manifest, set_read_implementation } from '__sveltekit/server';
+import { set_manifest, set_read_implementation, set_peers, set_publish_implementation } from '__sveltekit/server';
import { set_private_env, set_public_env, set_safe_public_env } from '${runtime_directory}/shared-server.js';
export const options = {
@@ -84,7 +84,18 @@ export async function get_hooks() {
};
}
-export { set_assets, set_building, set_manifest, set_prerendering, set_private_env, set_public_env, set_read_implementation, set_safe_public_env };
+export {
+ set_assets,
+ set_building,
+ set_manifest,
+ set_peers,
+ set_prerendering,
+ set_private_env,
+ set_public_env,
+ set_publish_implementation,
+ set_read_implementation,
+ set_safe_public_env
+};
`;
// TODO need to re-run this whenever src/app.html or src/error.html are
diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js
index 308d566606f8..08970b0df3db 100644
--- a/packages/kit/src/core/sync/write_types/index.js
+++ b/packages/kit/src/core/sync/write_types/index.js
@@ -347,6 +347,9 @@ function update_types(config, routes, route, to_delete = new Set()) {
if (route.endpoint) {
exports.push('export type RequestHandler = Kit.RequestHandler;');
+ exports.push('export type Socket = Kit.Socket;');
+ exports.push('export type Peer = Kit.Peer;');
+ exports.push('export type Message = Kit.Message;');
}
if (route.leaf?.server || route.layout?.server || route.endpoint) {
diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts
index 684f18981610..4ec7752bf304 100644
--- a/packages/kit/src/exports/public.d.ts
+++ b/packages/kit/src/exports/public.d.ts
@@ -44,6 +44,20 @@ export interface Adapter {
* @param details.config The merged route config
*/
read?: (details: { config: any; route: { id: string } }) => boolean;
+ webSockets?: {
+ /**
+ * Test support for the `socket` export from a `+server.js` file.
+ */
+ socket: () => boolean;
+ /**
+ * Test support for `getPeers` from `$app/server`.
+ */
+ getPeers: (details: { route: { id: string } }) => boolean;
+ /**
+ * Test support for `publish` from `$app/server`.
+ */
+ publish: (details: { route: { id: string } }) => boolean;
+ };
};
/**
* Creates an `Emulator`, which allows the adapter to influence the environment
@@ -1304,6 +1318,10 @@ export class Server {
constructor(manifest: SSRManifest);
init(options: ServerInitOptions): Promise;
respond(request: Request, options: RequestOptions): Promise;
+ resolveWebSocketHooks(
+ request: Request,
+ options: RequestOptions
+ ): Promise>;
}
export interface ServerInitOptions {
@@ -1311,6 +1329,10 @@ export interface ServerInitOptions {
env: Record;
/** A function that turns an asset filename into a `ReadableStream`. Required for the `read` export from `$app/server` to work. */
read?: (file: string) => ReadableStream;
+ /** A `Set` of WebSocket `Peer` instances. Required for the `getPeers` export from `$app/server` to work. */
+ peers?: import('crossws').AdapterInstance['peers'];
+ /** A function that publishes a message to WebSocket subscribers of a topic. Required for the `publish` export from `$app/server` to work. */
+ publish?: import('crossws').AdapterInstance['publish'];
}
export interface SSRManifest {
@@ -1494,7 +1516,82 @@ export type SubmitFunction<
>;
/**
- * The type of `export const snapshot` exported from a page or layout component.
+ * Shape of the `export const socket = {...}` object in `+server.js`.
+ * See [WebSockets](https://svelte.dev/docs/kit/websockets) for more information.
+ * @since 2.21.0
+ */
+export interface Socket<
+ Params extends Partial> = Partial>,
+ RouteId extends string | null = string | null
+> {
+ /**
+ * The [upgrade](https://svelte.dev/docs/kit/websockets#Hooks-upgrade) hook runs
+ * every time a request is attempting to upgrade to a WebSocket connection.
+ */
+ upgrade?: (
+ event: RequestEvent & { context: import('crossws').Peer['context'] }
+ ) => MaybePromise;
+ /**
+ * The [open](https://svelte.dev/docs/kit/websockets#Hooks-open) hook runs
+ * every time a WebSocket connection is opened.
+ */
+ open?: (peer: Peer) => MaybePromise;
+ /**
+ * The [message](https://svelte.dev/docs/kit/websockets#Hooks-message) hook
+ * runs every time a message is received from a WebSocket client.
+ */
+ message?: (peer: Peer, message: Message) => MaybePromise;
+ /**
+ * The [close](https://svelte.dev/docs/kit/websockets#Hooks-close) hook runs
+ * every time a WebSocket connection is closed.
+ */
+ close?: (
+ peer: Peer,
+ details: { code?: number; reason?: string }
+ ) => MaybePromise;
+ /**
+ * The [error](https://svelte.dev/docs/kit/websockets#Hooks-error) hook runs
+ * every time a WebSocket error occurs.
+ */
+ error?: (peer: Peer, error: import('crossws').WSError) => MaybePromise;
+}
+
+/**
+ * When a new [WebSocket](https://svelte.dev/docs/kit/websockets) client connects
+ * to the server, `crossws` creates a [`Peer`](https://crossws.unjs.io/guide/peer)
+ * object that allows interacting with the connected client.
+ * @since 2.21.0
+ */
+export type Peer<
+ Params extends Partial> = Partial>,
+ RouteId extends string | null = string | null
+> = import('crossws').Peer & {
+ /** The original request object before upgrading to a WebSocket connection. */
+ request: Request;
+ /** Represents the initial request before upgrading to a WebSocket connection. */
+ event: RequestEvent;
+};
+
+/**
+ * During a WebSocket [`message`](https://svelte.dev/docs/kit/websockets#Hooks-message)
+ * hook, you'll receive a [`Message`](https://crossws.unjs.io/guide/message)
+ * object containing data from the client.
+ * @since 2.21.0
+ */
+export type Message<
+ Params extends Partial> = Partial>,
+ RouteId extends string | null = string | null
+> = import('crossws').Message & {
+ /** Access to the `Peer` that emitted the message. */
+ peer: Peer;
+};
+
+/**
+ * Shape of the `export const snapshot = {...}` object in a page or layout component.
+ * You should import these from `./$types` (see [generated types](https://svelte.dev/docs/kit/types#Generated-types))
+ * rather than using `Snapshot` directly.
+ * See [snapshots](https://svelte.dev/docs/kit/snapshots) for more information.
+ * @since 1.5.0
*/
export interface Snapshot {
capture: () => T;
diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js
index ded4685f527c..b96b69e0e34e 100644
--- a/packages/kit/src/exports/vite/dev/index.js
+++ b/packages/kit/src/exports/vite/dev/index.js
@@ -3,6 +3,7 @@ import path from 'node:path';
import process from 'node:process';
import { URL } from 'node:url';
import { AsyncLocalStorage } from 'node:async_hooks';
+import crossws from 'crossws/adapters/node';
import colors from 'kleur';
import sirv from 'sirv';
import { isCSSRequest, loadEnv, buildErrorMessage } from 'vite';
@@ -418,6 +419,72 @@ export async function dev(vite, vite_config, svelte_config) {
const env = loadEnv(vite_config.mode, svelte_config.kit.env.dir, '');
const emulator = await svelte_config.kit.adapter?.emulate?.();
+ /**
+ * @param {import('node:http').IncomingMessage} req
+ */
+ function get_base(req) {
+ return `${vite.config.server.https ? 'https' : 'http'}://${
+ req.headers[':authority'] || req.headers.host
+ }`;
+ }
+
+ async function init_server() {
+ // we have to import `Server` before calling `set_assets`
+ const { Server } = /** @type {import('types').ServerModule} */ (
+ await vite.ssrLoadModule(`${runtime_base}/server/index.js`, { fixStacktrace: true })
+ );
+
+ const { set_fix_stack_trace } = await vite.ssrLoadModule(`${runtime_base}/shared-server.js`);
+ set_fix_stack_trace(fix_stack_trace);
+
+ const { set_assets } = await vite.ssrLoadModule('__sveltekit/paths');
+ set_assets(assets);
+
+ const server = new Server(manifest);
+
+ await server.init({
+ env,
+ read: (file) => createReadableStream(from_fs(file)),
+ peers: ws.peers,
+ publish: ws.publish
+ });
+
+ return server;
+ }
+
+ /**
+ * @param {string} file
+ */
+ function read(file) {
+ if (file in manifest._.server_assets) {
+ return fs.readFileSync(from_fs(file));
+ }
+
+ return fs.readFileSync(path.join(svelte_config.kit.files.assets, file));
+ }
+
+ /**
+ * @param {import('@sveltejs/kit').RequestEvent} event
+ * @param {any} config
+ * @param {import('types').PrerenderOption} prerender
+ */
+ function before_handle(event, config, prerender) {
+ async_local_storage.enterWith({ event, config, prerender });
+ }
+
+ /** @type {import('crossws').ResolveHooks} */
+ let resolve_websocket_hooks;
+ const ws = crossws({
+ resolve: (req) => resolve_websocket_hooks(req),
+ serverOptions: {
+ // we need to disable the `ws` package's default behaviour of automatically
+ // returning the request's sec-websocket-protocol header in the response
+ // to avoid sending that header multiple times if the user also returns that header.
+ // TODO: we could remove this if https://github.com/unjs/crossws/pull/142 standardises this behaviour
+ handleProtocols: () => false
+ }
+ });
+
return () => {
const serve_static_middleware = vite.middlewares.stack.find(
(middleware) =>
@@ -428,14 +495,79 @@ export async function dev(vite, vite_config, svelte_config) {
// serving routes with those names. See https://github.com/vitejs/vite/issues/7363
remove_static_middlewares(vite.middlewares);
+ vite.httpServer?.on(
+ 'upgrade',
+ /**
+ * @param {import('node:http').IncomingMessage} req
+ * @param {import('node:stream').Duplex} socket
+ * @param {Buffer} head
+ */
+ async (req, socket, head) => {
+ if (
+ req.headers['sec-websocket-protocol'] !== 'vite-hmr' &&
+ req.headers.upgrade === 'websocket'
+ ) {
+ try {
+ const base = get_base(req);
+ const decoded = decodeURI(new URL(base + req.url).pathname);
+
+ if (!decoded.startsWith(svelte_config.kit.paths.base)) {
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
+ socket.end(
+ `The server is configured with a public base URL of ${escape_html(
+ svelte_config.kit.paths.base
+ )} - did you mean to visit ${escape_html(svelte_config.kit.paths.base + req.url)} instead?`
+ );
+ return;
+ }
+
+ const server = await init_server();
+
+ if (manifest_error) {
+ console.error(colors.bold().red(manifest_error.message));
+ socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
+ socket.end(manifest_error.message ?? 'Invalid routes');
+ return;
+ }
+
+ // the crossws Node adapter doesn't actually pass a Request object, so we need to create one
+ // see https://github.com/unjs/crossws/issues/137
+ const request = await getRequest({
+ base,
+ request: req
+ });
+
+ const hooks = await server.resolveWebSocketHooks(request, {
+ getClientAddress: get_client_address(req),
+ read,
+ before_handle,
+ emulator
+ });
+ resolve_websocket_hooks = () => hooks;
+
+ // eslint-disable-next-line @typescript-eslint/await-thenable -- this function call is awaitable but the crossws type fix hasn't been released yet
+ await ws.handleUpgrade(req, socket, head);
+ // TODO: remove this block once https://github.com/unjs/crossws/pull/140 is merged
+ if (socket.writableFinished) {
+ socket.destroy();
+ } else {
+ socket.once('finish', socket.destroy);
+ }
+ } catch (e) {
+ const error = coalesce_to_error(e);
+ socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
+ socket.end(fix_stack_trace(error));
+ }
+ }
+ }
+ );
+
vite.middlewares.use(async (req, res) => {
// Vite's base middleware strips out the base path. Restore it
const original_url = req.url;
req.url = req.originalUrl;
try {
- const base = `${vite.config.server.https ? 'https' : 'http'}://${
- req.headers[':authority'] || req.headers.host
- }`;
+ const base = get_base(req);
const decoded = decodeURI(new URL(base + req.url).pathname);
const file = posixify(path.resolve(decoded.slice(svelte_config.kit.paths.base.length + 1)));
@@ -471,25 +603,7 @@ export async function dev(vite, vite_config, svelte_config) {
return;
}
- // we have to import `Server` before calling `set_assets`
- const { Server } = /** @type {import('types').ServerModule} */ (
- await vite.ssrLoadModule(`${runtime_base}/server/index.js`, { fixStacktrace: true })
- );
-
- const { set_fix_stack_trace } = await vite.ssrLoadModule(
- `${runtime_base}/shared-server.js`
- );
- set_fix_stack_trace(fix_stack_trace);
-
- const { set_assets } = await vite.ssrLoadModule('__sveltekit/paths');
- set_assets(assets);
-
- const server = new Server(manifest);
-
- await server.init({
- env,
- read: (file) => createReadableStream(from_fs(file))
- });
+ const server = await init_server();
const request = await getRequest({
base,
@@ -519,21 +633,9 @@ export async function dev(vite, vite_config, svelte_config) {
}
const rendered = await server.respond(request, {
- getClientAddress: () => {
- const { remoteAddress } = req.socket;
- if (remoteAddress) return remoteAddress;
- throw new Error('Could not determine clientAddress');
- },
- read: (file) => {
- if (file in manifest._.server_assets) {
- return fs.readFileSync(from_fs(file));
- }
-
- return fs.readFileSync(path.join(svelte_config.kit.files.assets, file));
- },
- before_handle: (event, config, prerender) => {
- async_local_storage.enterWith({ event, config, prerender });
- },
+ getClientAddress: get_client_address(req),
+ read,
+ before_handle,
emulator
});
@@ -630,3 +732,14 @@ function has_correct_case(file, assets) {
return false;
}
+
+/**
+ * @param {import('node:http').IncomingMessage} req
+ */
+function get_client_address(req) {
+ return () => {
+ const { remoteAddress } = req.socket;
+ if (remoteAddress) return remoteAddress;
+ throw new Error('Could not determine clientAddress');
+ };
+}
diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js
index c2e445e865d2..157c76b3ca21 100644
--- a/packages/kit/src/exports/vite/index.js
+++ b/packages/kit/src/exports/vite/index.js
@@ -536,6 +536,10 @@ Tips:
export let manifest = null;
+ export let peers = null;
+
+ export let publish_implementation = null;
+
export function set_read_implementation(fn) {
read_implementation = fn;
}
@@ -543,6 +547,14 @@ Tips:
export function set_manifest(_) {
manifest = _;
}
+
+ export function set_peers(_) {
+ peers = _;
+ }
+
+ export function set_publish_implementation(fn) {
+ publish_implementation = fn;
+ }
`;
}
}
diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js
index efff12bbd3b7..bb67397cbe3a 100644
--- a/packages/kit/src/exports/vite/preview/index.js
+++ b/packages/kit/src/exports/vite/preview/index.js
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
+import crossws from 'crossws/adapters/node';
import { lookup } from 'mrmime';
import sirv from 'sirv';
import { loadEnv, normalizePath } from 'vite';
@@ -45,14 +46,48 @@ export async function preview(vite, vite_config, svelte_config) {
set_assets(assets);
+ /** @type {import('crossws').ResolveHooks} */
+ let resolve_websocket_hooks;
+ const ws = crossws({
+ resolve: (req) => resolve_websocket_hooks(req),
+ serverOptions: {
+ // we need to disable the `ws` package's default behaviour of automatically
+ // returning the request's sec-websocket-protocol header in the response
+ // to avoid sending that header multiple times if the user also returns that header.
+ // TODO: we could remove this if https://github.com/unjs/crossws/pull/142 standardises this behaviour
+ handleProtocols: () => false
+ }
+ });
+
const server = new Server(manifest);
await server.init({
env: loadEnv(vite_config.mode, svelte_config.kit.env.dir, ''),
- read: (file) => createReadableStream(`${dir}/${file}`)
+ read: (file) => createReadableStream(`${dir}/${file}`),
+ peers: ws.peers,
+ publish: ws.publish
});
const emulator = await svelte_config.kit.adapter?.emulate?.();
+ /**
+ * @param {import('node:http').IncomingMessage} req
+ */
+ function get_base(req) {
+ const host = req.headers[':authority'] || req.headers.host;
+ return `${protocol}://${host}`;
+ }
+
+ /**
+ * @param {string} file
+ */
+ function read(file) {
+ if (file in manifest._.server_assets) {
+ return fs.readFileSync(join(dir, file));
+ }
+
+ return fs.readFileSync(join(svelte_config.kit.files.assets, file));
+ }
+
return () => {
// Remove the base middleware. It screws with the URL.
// It also only lets through requests beginning with the base path, so that requests beginning
@@ -183,30 +218,52 @@ export async function preview(vite, vite_config, svelte_config) {
})
);
+ vite.httpServer.on(
+ 'upgrade',
+ /**
+ * @param {import('node:http').IncomingMessage} req
+ * @param {import('node:stream').Duplex} socket
+ * @param {Buffer} head
+ */
+ async (req, socket, head) => {
+ if (req.headers.upgrade === 'websocket') {
+ const request = await getRequest({
+ base: get_base(req),
+ request: req
+ });
+
+ const hooks = await server.resolveWebSocketHooks(request, {
+ getClientAddress: get_client_address(req),
+ read,
+ emulator
+ });
+
+ resolve_websocket_hooks = () => hooks;
+
+ // eslint-disable-next-line @typescript-eslint/await-thenable -- this function call is awaitable but the crossws type fix hasn't been released yet
+ await ws.handleUpgrade(req, socket, head);
+ // TODO: remove this block once https://github.com/unjs/crossws/pull/140 is merged
+ if (socket.writableFinished) {
+ socket.destroy();
+ } else {
+ socket.once('finish', socket.destroy);
+ }
+ }
+ }
+ );
+
// SSR
vite.middlewares.use(async (req, res) => {
- const host = req.headers[':authority'] || req.headers.host;
-
const request = await getRequest({
- base: `${protocol}://${host}`,
+ base: get_base(req),
request: req
});
await setResponse(
res,
await server.respond(request, {
- getClientAddress: () => {
- const { remoteAddress } = req.socket;
- if (remoteAddress) return remoteAddress;
- throw new Error('Could not determine clientAddress');
- },
- read: (file) => {
- if (file in manifest._.server_assets) {
- return fs.readFileSync(join(dir, file));
- }
-
- return fs.readFileSync(join(svelte_config.kit.files.assets, file));
- },
+ getClientAddress: get_client_address(req),
+ read,
emulator
})
);
@@ -252,3 +309,14 @@ function scoped(scope, handler) {
function is_file(path) {
return fs.existsSync(path) && !fs.statSync(path).isDirectory();
}
+
+/**
+ * @param {import('node:http').IncomingMessage} req
+ */
+const get_client_address = (req) => {
+ return () => {
+ const { remoteAddress } = req.socket;
+ if (remoteAddress) return remoteAddress;
+ throw new Error('Could not determine clientAddress');
+ };
+};
diff --git a/packages/kit/src/runtime/app/server/index.js b/packages/kit/src/runtime/app/server/index.js
index 19c384932107..82bcf48dc95e 100644
--- a/packages/kit/src/runtime/app/server/index.js
+++ b/packages/kit/src/runtime/app/server/index.js
@@ -1,10 +1,10 @@
-import { read_implementation, manifest } from '__sveltekit/server';
+import { read_implementation, peers, publish_implementation, manifest } from '__sveltekit/server';
import { base } from '__sveltekit/paths';
import { DEV } from 'esm-env';
import { b64_decode } from '../../utils.js';
/**
- * Read the contents of an imported asset from the filesystem
+ * Read the contents of an imported asset from the filesystem.
* @example
* ```js
* import { read } from '$app/server';
@@ -72,4 +72,58 @@ export function read(asset) {
throw new Error(`Asset does not exist: ${file}`);
}
+/**
+ * Returns a set of connected WebSocket peers.
+ * See [Peer](https://crossws.unjs.io/guide/peer) for more information.
+ * @example
+ * ```js
+ * import { getPeers } from '$app/server';
+ *
+ * const peers = getPeers();
+ * peers.forEach((peer) => {
+ * // ...
+ * });
+ * ```
+ * @returns {import('crossws').AdapterInstance['peers']}
+ * @since 2.21.0
+ */
+export function getPeers() {
+ __SVELTEKIT_TRACK__('$app/server:getPeers');
+
+ if (!peers) {
+ throw new Error(
+ 'No `peers` reference was provided. Please ensure that your adapter is up to date and supports this feature'
+ );
+ }
+
+ return peers;
+}
+
+/**
+ * Send a message to WebSocket peer subscribers of a given topic.
+ * See [Pub / Sub](https://crossws.unjs.io/guide/pubsub) for more information.
+ * @example
+ * ```js
+ * import { publish } from '$app/server';
+ *
+ * publish('chat', { message: 'Hello, world!' });
+ * ```
+ * @param {string} topic
+ * @param {unknown} data
+ * @param {{ compress?: boolean }=} options
+ * @returns {void}
+ * @since 2.21.0
+ */
+export function publish(topic, data, options) {
+ __SVELTEKIT_TRACK__('$app/server:publish');
+
+ if (!publish_implementation) {
+ throw new Error(
+ 'No `publish` implementation was provided. Please ensure that your adapter is up to date and supports this feature'
+ );
+ }
+
+ publish_implementation(topic, data, options);
+}
+
export { getRequestEvent } from './event.js';
diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js
index d480d344036b..ba0fdffcb566 100644
--- a/packages/kit/src/runtime/server/endpoint.js
+++ b/packages/kit/src/runtime/server/endpoint.js
@@ -1,3 +1,4 @@
+import { DEV } from 'esm-env';
import { ENDPOINT_METHODS, PAGE_METHODS } from '../../constants.js';
import { negotiate } from '../../utils/http.js';
import { with_event } from '../app/server/event.js';
@@ -11,8 +12,24 @@ import { method_not_allowed } from './utils.js';
* @returns {Promise}
*/
export async function render_endpoint(event, mod, state) {
+ if (DEV && mod.socket) {
+ __SVELTEKIT_TRACK__('websockets');
+ }
+
const method = /** @type {import('types').HttpMethod} */ (event.request.method);
+ // if we've ended up here, it means the request does not have both the
+ // `Upgrade: websocket` and the `Connect: upgrade` headers
+ if (method === 'GET' && !mod.GET && mod.socket) {
+ return new Response('This service requires use of the websocket protocol.', {
+ status: 426,
+ headers: {
+ upgrade: 'websocket',
+ connect: 'Upgrade'
+ }
+ });
+ }
+
let handler = mod[method] || mod.fallback;
if (method === 'HEAD' && !mod.HEAD && mod.GET) {
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index a2740a8e6aa4..ac618bfb12bc 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -1,10 +1,15 @@
-import { respond } from './respond.js';
+import { respond, resolve_websocket_hooks } from './respond.js';
import { set_private_env, set_public_env, set_safe_public_env } from '../shared-server.js';
import { options, get_hooks } from '__SERVER__/internal.js';
import { DEV } from 'esm-env';
import { filter_private_env, filter_public_env } from '../../utils/env.js';
import { prerendering } from '__sveltekit/environment';
-import { set_read_implementation, set_manifest } from '__sveltekit/server';
+import {
+ set_read_implementation,
+ set_manifest,
+ set_peers,
+ set_publish_implementation
+} from '__sveltekit/server';
/** @type {ProxyHandler<{ type: 'public' | 'private' }>} */
const prerender_env_handler = {
@@ -35,12 +40,9 @@ export class Server {
}
/**
- * @param {{
- * env: Record;
- * read?: (file: string) => ReadableStream;
- * }} opts
+ * @param {import('@sveltejs/kit').ServerInitOptions} opts
*/
- async init({ env, read }) {
+ async init({ env, read, peers, publish }) {
// Take care: Some adapters may have to call `Server.init` per-request to set env vars,
// so anything that shouldn't be rerun should be wrapped in an `if` block to make sure it hasn't
// been done already.
@@ -66,6 +68,14 @@ export class Server {
set_read_implementation(read);
}
+ if (peers) {
+ set_peers(peers);
+ }
+
+ if (publish) {
+ set_publish_implementation(publish);
+ }
+
// During DEV and for some adapters this function might be called in quick succession,
// so we need to make sure we're not invoking this logic (most notably the init hook) multiple times
await (init_promise ??= (async () => {
@@ -112,4 +122,16 @@ export class Server {
depth: 0
});
}
+
+ /**
+ * @param {Request} request
+ * @param {import('types').RequestOptions} options
+ */
+ resolveWebSocketHooks(request, options) {
+ return resolve_websocket_hooks(request, this.#options, this.#manifest, {
+ ...options,
+ error: false,
+ depth: 0
+ });
+ }
}
diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js
index 81b30e0756a5..8e0b4799cc17 100644
--- a/packages/kit/src/runtime/server/respond.js
+++ b/packages/kit/src/runtime/server/respond.js
@@ -59,9 +59,75 @@ const allowed_page_methods = new Set(['GET', 'HEAD', 'OPTIONS']);
* @returns {Promise}
*/
export async function respond(request, options, manifest, state) {
+ return handle_request(request, options, manifest, state);
+}
+
+/**
+ * @param {Request} request
+ * @param {import('types').SSROptions} options
+ * @param {import('@sveltejs/kit').SSRManifest} manifest
+ * @param {import('types').SSRState} state
+ * @returns {Promise}
+ */
+export async function resolve_websocket_hooks(request, options, manifest, state) {
+ const result = await handle_request(request, options, manifest, state, true);
+
+ if (result instanceof Response) {
+ // if the result is a Response instead of WebSocket hooks, it means
+ // we should reject the upgrade
+ return {
+ upgrade: () => {
+ // we have to throw the Response to reject the upgrade
+ throw result;
+ }
+ };
+ }
+
+ return result;
+}
+
+// we need the type overload so that TypeScript knows the return value
+// can only be a Response if the upgrade param was omitted
+/**
+ * @overload
+ * @param {Request} request
+ * @param {import('types').SSROptions} options
+ * @param {import('@sveltejs/kit').SSRManifest} manifest
+ * @param {import('types').SSRState} state
+ * @returns {Promise}
+ */
+/**
+ * @overload
+ * @param {Request} request
+ * @param {import('types').SSROptions} options
+ * @param {import('@sveltejs/kit').SSRManifest} manifest
+ * @param {import('types').SSRState} state
+ * @param {boolean} upgrade
+ * @returns {Promise>}
+ */
+/**
+ * @param {Request} request
+ * @param {import('types').SSROptions} options
+ * @param {import('@sveltejs/kit').SSRManifest} manifest
+ * @param {import('types').SSRState} state
+ * @param {boolean=} upgrade
+ * @returns {Promise>}
+ */
+async function handle_request(request, options, manifest, state, upgrade) {
/** URL but stripped from the potential `/__data.json` suffix and its search param */
const url = new URL(request.url);
+ /**
+ * @param {HttpError} error
+ * @returns {Response}
+ */
+ function text_or_json(error) {
+ if (request.headers.get('accept') === 'application/json') {
+ return json(error.body, { status: error.status });
+ }
+ return text(error.body.message, { status: error.status });
+ }
+
if (options.csrf_check_origin) {
const forbidden =
is_form_content_type(request) &&
@@ -72,14 +138,9 @@ export async function respond(request, options, manifest, state) {
request.headers.get('origin') !== url.origin;
if (forbidden) {
- const csrf_error = new HttpError(
- 403,
- `Cross-site ${request.method} form submissions are forbidden`
+ return text_or_json(
+ new HttpError(403, `Cross-site ${request.method} form submissions are forbidden`)
);
- if (request.headers.get('accept') === 'application/json') {
- return json(csrf_error.body, { status: csrf_error.status });
- }
- return text(csrf_error.body.message, { status: csrf_error.status });
}
}
@@ -284,6 +345,23 @@ export async function respond(request, options, manifest, state) {
preload: default_preload
};
+ /**
+ * @param {unknown} e
+ * @returns {Promise}
+ */
+ async function redirect_or_fatal_error(e) {
+ if (e instanceof Redirect) {
+ const response = is_data_request
+ ? redirect_json_response(e)
+ : route?.page && is_action_json_request(event)
+ ? action_json_redirect(e)
+ : redirect_response(e.status, e.location);
+ add_cookies_to_headers(response.headers, Object.values(new_cookies));
+ return response;
+ }
+ return await handle_fatal_error(event, options, e);
+ }
+
/** @type {import('types').TrailingSlash} */
let trailing_slash = 'never';
@@ -360,32 +438,156 @@ export async function respond(request, options, manifest, state) {
disable_search(url);
}
- const response = await with_event(event, () =>
- options.hooks.handle({
- event,
- resolve: (event, opts) =>
- // counter-intuitively, we need to clear the event, so that it's not
- // e.g. accessible when loading modules needed to handle the request
- with_event(null, () =>
- resolve(event, page_nodes, opts).then((response) => {
- // add headers/cookies here, rather than inside `resolve`, so that we
- // can do it once for all responses instead of once per `return`
- for (const key in headers) {
- const value = headers[key];
- response.headers.set(key, /** @type {string} */ (value));
- }
+ /**
+ * @param {(event: import('@sveltejs/kit').RequestEvent, page_nodes: PageNodes | undefined, opts?: import('@sveltejs/kit').ResolveOptions) => Promise} resolve
+ * @returns {Promise}
+ */
+ const handle_hook = async (resolve) => {
+ return await with_event(event, () => {
+ return options.hooks.handle({
+ event,
+ resolve: (event, opts) => {
+ // counter-intuitively, we need to clear the event, so that it's not
+ // e.g. accessible when loading modules needed to handle the request
+ return with_event(null, () => {
+ return resolve(event, page_nodes, opts).then((response) => {
+ event.cookies.set = () => {
+ throw new Error(
+ 'Cannot use `cookies.set(...)` after the response has been generated'
+ );
+ };
+
+ event.setHeaders = () => {
+ throw new Error(
+ 'Cannot use `setHeaders(...)` after the response has been generated'
+ );
+ };
+
+ // add headers/cookies here, rather than inside `resolve`, so that we
+ // can do it once for all responses instead of once per `return`
+ for (const key in headers) {
+ const value = headers[key];
+ response.headers.set(key, /** @type {string} */ (value));
+ }
+
+ add_cookies_to_headers(response.headers, Object.values(new_cookies));
+
+ if (state.prerendering && event.route.id !== null) {
+ response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id));
+ }
+
+ return response;
+ });
+ });
+ }
+ });
+ });
+ };
- add_cookies_to_headers(response.headers, Object.values(new_cookies));
+ const node = upgrade && route?.endpoint ? await route.endpoint() : undefined;
+ if (node?.socket) {
+ if (DEV) {
+ __SVELTEKIT_TRACK__('websockets');
+ }
+
+ return {
+ upgrade: async ({ context }) => {
+ /** @type {Response} */
+ let response;
+
+ try {
+ response = await handle_hook(async (event) => {
+ /** @type {Response} */
+ let upgrade_response;
+
+ try {
+ /** @type {Response | ResponseInit | undefined} */
+ let result;
+
+ if (node.socket?.upgrade) {
+ Object.defineProperty(event, 'context', {
+ enumerable: true,
+ value: context
+ });
+ result =
+ (await node.socket.upgrade(
+ /** @type {import('@sveltejs/kit').RequestEvent & { context: {} }} */ (event)
+ )) ?? undefined;
+ }
- if (state.prerendering && event.route.id !== null) {
- response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id));
+ upgrade_response =
+ result instanceof Response ? result : new Response(undefined, result);
+ upgrade_response.headers.set('x-sveltekit-upgrade', 'true');
+ } catch (e) {
+ if (e instanceof HttpError) {
+ upgrade_response = text_or_json(e);
+ } else if (e instanceof Response) {
+ // crossws allows throwing a Response to abort the upgrade
+ upgrade_response = e;
+ } else {
+ throw e;
+ }
}
- return response;
- })
- )
- })
- );
+ return upgrade_response;
+ });
+ } catch (e) {
+ return await redirect_or_fatal_error(e);
+ }
+
+ // if the x-sveltekit-upgrade header is missing we know we should
+ // abort the upgrade request because it means a different response
+ // has been thrown from the upgrade hook or returned from the handle hook
+ if (!response.headers.has('x-sveltekit-upgrade')) {
+ throw response;
+ }
+
+ return response;
+ },
+ /**
+ * @param {import('crossws').Peer} peer The Peer object before we modify it.
+ */
+ open: async (peer) => {
+ // `peer.request` is a getter with no setter so this is the only way to override it
+ Object.defineProperty(peer, 'request', {
+ configurable: true,
+ enumerable: true,
+ get() {
+ return event.request;
+ }
+ });
+ /** @type {import('@sveltejs/kit').Peer} */ (peer).event = event;
+ try {
+ await node.socket?.open?.(/** @type {import('@sveltejs/kit').Peer} */ (peer));
+ } catch (e) {
+ await handle_fatal_error(event, options, e);
+ }
+ },
+ message: async (peer, message) => {
+ try {
+ await node.socket?.message?.(peer, message);
+ } catch (e) {
+ await handle_fatal_error(event, options, e);
+ }
+ },
+ close: async (peer, close_event) => {
+ try {
+ await node.socket?.close?.(peer, close_event);
+ } catch (e) {
+ await handle_fatal_error(event, options, e);
+ }
+ },
+ error: async (peer, error) => {
+ try {
+ await node.socket?.error?.(peer, error);
+ } catch (e) {
+ await handle_fatal_error(event, options, e);
+ }
+ }
+ };
+ }
+
+ const response = await handle_hook(resolve);
// respond with 304 if etag matches
if (response.status === 200 && response.headers.has('etag')) {
@@ -432,16 +634,7 @@ export async function respond(request, options, manifest, state) {
return response;
} catch (e) {
- if (e instanceof Redirect) {
- const response = is_data_request
- ? redirect_json_response(e)
- : route?.page && is_action_json_request(event)
- ? action_json_redirect(e)
- : redirect_response(e.status, e.location);
- add_cookies_to_headers(response.headers, Object.values(new_cookies));
- return response;
- }
- return await handle_fatal_error(event, options, e);
+ return await redirect_or_fatal_error(e);
}
/**
@@ -597,14 +790,6 @@ export async function respond(request, options, manifest, state) {
// HttpError from endpoint can end up here - TODO should it be handled there instead?
return await handle_fatal_error(event, options, e);
- } finally {
- event.cookies.set = () => {
- throw new Error('Cannot use `cookies.set(...)` after the response has been generated');
- };
-
- event.setHeaders = () => {
- throw new Error('Cannot use `setHeaders(...)` after the response has been generated');
- };
}
}
}
diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts
index c98af8cb0062..1062aced71d0 100644
--- a/packages/kit/src/types/ambient-private.d.ts
+++ b/packages/kit/src/types/ambient-private.d.ts
@@ -24,6 +24,12 @@ declare module '__sveltekit/server' {
export let manifest: SSRManifest;
export function read_implementation(path: string): ReadableStream;
+ export let peers: import('crossws').AdapterInstance['peers'];
+ export const publish_implementation: import('crossws').AdapterInstance['publish'];
export function set_manifest(manifest: SSRManifest): void;
export function set_read_implementation(fn: (path: string) => ReadableStream): void;
+ export function set_peers(peers: import('crossws').AdapterInstance['peers']): void;
+ export function set_publish_implementation(
+ fn: import('crossws').AdapterInstance['publish']
+ ): void;
}
diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts
index 2d54b37ac145..918aaae55d2a 100644
--- a/packages/kit/src/types/internal.d.ts
+++ b/packages/kit/src/types/internal.d.ts
@@ -9,7 +9,6 @@ import {
RequestHandler,
ResolveOptions,
Server,
- ServerInitOptions,
HandleFetch,
Actions,
HandleClientError,
@@ -20,7 +19,8 @@ import {
Adapter,
ServerInit,
ClientInit,
- Transporter
+ Transporter,
+ Socket
} from '@sveltejs/kit';
import {
HttpMethod,
@@ -42,6 +42,8 @@ export interface ServerInternalModule {
set_private_env(environment: Record): void;
set_public_env(environment: Record): void;
set_read_implementation(implementation: (path: string) => ReadableStream): void;
+ set_peers(peers: import('crossws').AdapterInstance['peers']): void;
+ set_publish_implementation(implementation: import('crossws').AdapterInstance['publish']): void;
set_safe_public_env(environment: Record): void;
set_version(version: string): void;
set_fix_stack_trace(fix_stack_trace: (error: unknown) => string): void;
@@ -166,18 +168,20 @@ export interface Env {
public: Record;
}
+type InternalRequestOptions = RequestOptions & {
+ prerendering?: PrerenderOptions;
+ read: (file: string) => Buffer;
+ /** A hook called before `handle` during dev, so that `AsyncLocalStorage` can be populated */
+ before_handle?: (event: RequestEvent, config: any, prerender: PrerenderOption) => void;
+ emulator?: Emulator;
+};
+
export class InternalServer extends Server {
- init(options: ServerInitOptions): Promise;
- respond(
+ respond(request: Request, options: InternalRequestOptions): Promise;
+ resolveWebSocketHooks(
request: Request,
- options: RequestOptions & {
- prerendering?: PrerenderOptions;
- read: (file: string) => Buffer;
- /** A hook called before `handle` during dev, so that `AsyncLocalStorage` can be populated. */
- before_handle?: (event: RequestEvent, config: any, prerender: PrerenderOption) => void;
- emulator?: Emulator;
- }
- ): Promise;
+ options: InternalRequestOptions
+ ): Promise>;
}
export interface ManifestData {
@@ -447,6 +451,7 @@ export interface PageNodeIndexes {
export type PrerenderEntryGenerator = () => MaybePromise>>;
export type SSREndpoint = Partial> & {
+ socket?: Socket;
prerender?: PrerenderOption;
trailingSlash?: TrailingSlash;
config?: any;
diff --git a/packages/kit/src/utils/exports.js b/packages/kit/src/utils/exports.js
index ed685edb7ded..9cd6e9c9e019 100644
--- a/packages/kit/src/utils/exports.js
+++ b/packages/kit/src/utils/exports.js
@@ -83,7 +83,8 @@ const valid_server_exports = new Set([
'prerender',
'trailingSlash',
'config',
- 'entries'
+ 'entries',
+ 'socket'
]);
export const validate_layout_exports = validator(valid_layout_exports);
diff --git a/packages/kit/src/utils/exports.spec.js b/packages/kit/src/utils/exports.spec.js
index e27817c17b5c..74e403aa697b 100644
--- a/packages/kit/src/utils/exports.spec.js
+++ b/packages/kit/src/utils/exports.spec.js
@@ -174,7 +174,7 @@ test('validates +server.js', () => {
validate_server_exports({
answer: 42
});
- }, "Invalid export 'answer' (valid exports are GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD, fallback, prerender, trailingSlash, config, entries, or anything with a '_' prefix)");
+ }, "Invalid export 'answer' (valid exports are GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD, fallback, prerender, trailingSlash, config, entries, socket, or anything with a '_' prefix)");
check_error(() => {
validate_server_exports({
diff --git a/packages/kit/src/utils/features.js b/packages/kit/src/utils/features.js
index 4a8530d22bbb..d4d7953708bc 100644
--- a/packages/kit/src/utils/features.js
+++ b/packages/kit/src/utils/features.js
@@ -7,6 +7,16 @@
export function check_feature(route_id, config, feature, adapter) {
if (!adapter) return;
+ /**
+ * @param {string} message
+ * @throws {Error}
+ */
+ const error = (message) => {
+ throw new Error(
+ `${message} in ${route_id} when using ${adapter.name}. Please ensure that your adapter is up to date and supports this feature.`
+ );
+ };
+
switch (feature) {
case '$app/server:read': {
const supported = adapter.supports?.read?.({
@@ -15,10 +25,35 @@ export function check_feature(route_id, config, feature, adapter) {
});
if (!supported) {
- throw new Error(
- `Cannot use \`read\` from \`$app/server\` in ${route_id} when using ${adapter.name}. Please ensure that your adapter is up to date and supports this feature.`
- );
+ error('Cannot use `read` from `$app/server`');
+ }
+ break;
+ }
+ case 'websockets': {
+ const supported = adapter.supports?.webSockets?.socket();
+
+ if (!supported) {
+ error('Cannot export `socket`');
}
+ break;
+ }
+ case '$app/server:getPeers': {
+ const supported = adapter.supports?.webSockets?.getPeers({ route: { id: route_id } });
+
+ if (!supported) {
+ error('Cannot use `getPeers` from `$app/server`');
+ }
+
+ break;
+ }
+ case '$app/server:publish': {
+ const supported = adapter.supports?.webSockets?.publish({ route: { id: route_id } });
+
+ if (!supported) {
+ error('Cannot use `publish` from `$app/server`');
+ }
+
+ break;
}
}
}
diff --git a/packages/kit/test/apps/basics/src/hooks.server.js b/packages/kit/test/apps/basics/src/hooks.server.js
index 5976df3f5763..c907bb734829 100644
--- a/packages/kit/test/apps/basics/src/hooks.server.js
+++ b/packages/kit/test/apps/basics/src/hooks.server.js
@@ -158,6 +158,34 @@ export const handle = sequence(
}
return resolve(event);
},
+ async ({ event, resolve }) => {
+ const headers = event.request.headers;
+ const upgrade = headers.get('upgrade') === 'websocket';
+
+ if (
+ upgrade &&
+ event.url.pathname === '/ws/handle' &&
+ event.url.searchParams.has('set-cookie')
+ ) {
+ event.cookies.set('ws', 'test', { path: '/ws' });
+ }
+
+ if (
+ upgrade &&
+ event.url.pathname === '/ws/handle' &&
+ event.url.searchParams.has('set-headers')
+ ) {
+ event.setHeaders({ 'x-sveltekit-ws': 'test' });
+ }
+
+ const response = await resolve(event);
+
+ if (upgrade && event.url.pathname === '/ws/handle' && event.url.searchParams.has('custom')) {
+ return new Response('custom response');
+ }
+
+ return response;
+ },
async ({ event, resolve }) => {
if (event.url.pathname.startsWith('/get-request-event/')) {
const e = getRequestEvent();
diff --git a/packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.ts b/packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.js
similarity index 100%
rename from packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.ts
rename to packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.js
diff --git a/packages/kit/test/apps/basics/src/routes/ws/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/+page.svelte
new file mode 100644
index 000000000000..90197cec9726
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/+page.svelte
@@ -0,0 +1,71 @@
+
+
+ {
+ primary_socket = new WebSocket('/ws');
+
+ primary_socket.onopen = () => {
+ messages = [...messages, 'connected'];
+ };
+
+ primary_socket.onmessage = (event) => {
+ messages = [...messages, event.data];
+ };
+ }}>open
+ {
+ primary_socket = new WebSocket('/ws', ['foo', 'bar']);
+
+ primary_socket.onopen = () => {
+ messages = [...messages, 'connected', `protocol: ${primary_socket.protocol}`];
+ };
+
+ primary_socket.onmessage = (event) => {
+ messages = [...messages, event.data];
+ };
+ }}>with sub-protocols
+ primary_socket.send('ping')}>ping
+ {
+ primary_socket.send('hello');
+ }}>chat
+ {
+ secondary_socket = new WebSocket('/ws');
+
+ secondary_socket.onopen = () => {
+ messages = [...messages, 'joined the chat'];
+ };
+
+ secondary_socket.onmessage = (event) => {
+ messages = [...messages, event.data];
+ };
+
+ secondary_socket.onclose = () => {
+ messages = [...messages, 'left the chat'];
+ };
+ }}>join
+ {
+ secondary_socket.send('close');
+ }}>leave
+
+
+ {#each messages as message}
+ {message}
+ {/each}
+
diff --git a/packages/kit/test/apps/basics/src/routes/ws/+server.js b/packages/kit/test/apps/basics/src/routes/ws/+server.js
new file mode 100644
index 000000000000..c702707a0121
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/+server.js
@@ -0,0 +1,42 @@
+/** @type {import('./$types').Socket} */
+export const socket = {
+ upgrade({ request }) {
+ const protocols = request.headers
+ .get('Sec-WebSocket-Protocol')
+ ?.split(',')
+ .map((s) => s.trim());
+
+ if (protocols?.includes('bar')) {
+ return {
+ headers: {
+ 'Sec-WebSocket-Protocol': 'bar'
+ }
+ };
+ }
+ },
+ open(peer) {
+ peer.send('open hook works');
+ peer.subscribe('chat');
+ },
+ message(peer, message) {
+ const data = message.text();
+
+ if (data === 'ping') {
+ peer.send('pong');
+ return;
+ }
+
+ if (data === 'close') {
+ peer.close(1000, 'test');
+ return;
+ }
+
+ peer.publish('chat', data);
+ },
+ close(peer, event) {
+ if (event.reason === 'test') {
+ peer.publish('chat', `close: ${event.code} ${event.reason}`);
+ }
+ peer.unsubscribe('chat');
+ }
+};
diff --git a/packages/kit/test/apps/basics/src/routes/ws/error/+server.js b/packages/kit/test/apps/basics/src/routes/ws/error/+server.js
new file mode 100644
index 000000000000..4fdc6ca348f8
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/error/+server.js
@@ -0,0 +1,8 @@
+import { error } from '@sveltejs/kit';
+
+/** @type {import('./$types').Socket} */
+export const socket = {
+ upgrade() {
+ error(403, 'Forbidden');
+ }
+};
diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+page.svelte
new file mode 100644
index 000000000000..7d989ea09d50
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+page.svelte
@@ -0,0 +1,25 @@
+
+
+ {
+ socket = new WebSocket('/ws/handle-error/close');
+ socket.onopen = () => {
+ message = 'connected';
+ };
+ socket.onclose = () => {
+ message = 'closed';
+ };
+ }}>open
+
+ {
+ socket.send('close');
+ }}>close
+
+{message}
diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+server.js
new file mode 100644
index 000000000000..a1915e283c2a
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+server.js
@@ -0,0 +1,11 @@
+/** @type {import('./$types').Socket} */
+export const socket = {
+ message(peer) {
+ peer.close(1000, 'test close hook error');
+ },
+ close(peer, details) {
+ if (details.reason === 'test close hook error') {
+ throw new Error('close hook');
+ }
+ }
+};
diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+page.svelte
new file mode 100644
index 000000000000..8b7060b17f3e
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+page.svelte
@@ -0,0 +1,17 @@
+
+
+ {
+ const socket = new WebSocket('/ws/handle-error/message');
+ socket.onopen = () => {
+ socket.send('message');
+ };
+ socket.onmessage = (event) => {
+ message = event.data;
+ };
+ }}>message
+
+{message}
diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+server.js
new file mode 100644
index 000000000000..1a33d92b5561
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+server.js
@@ -0,0 +1,7 @@
+/** @type {import('./$types').Socket} */
+export const socket = {
+ message(peer) {
+ peer.send('message received');
+ throw new Error('message hook');
+ }
+};
diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+page.svelte
new file mode 100644
index 000000000000..d9761e21c472
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+page.svelte
@@ -0,0 +1,14 @@
+
+
+ {
+ const socket = new WebSocket('/ws/handle-error/open');
+ socket.onmessage = (event) => {
+ message = event.data;
+ };
+ }}>open
+
+{message}
diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+server.js
new file mode 100644
index 000000000000..374f95e76258
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+server.js
@@ -0,0 +1,7 @@
+/** @type {import('./$types').Socket} */
+export const socket = {
+ open(peer) {
+ peer.send('opened');
+ throw new Error('open hook');
+ }
+};
diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+page.svelte
new file mode 100644
index 000000000000..ca5260e2f18a
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+page.svelte
@@ -0,0 +1,14 @@
+
+
+ {
+ const socket = new WebSocket('/ws/handle-error/upgrade');
+ socket.onerror = () => {
+ message = 'error';
+ };
+ }}>upgrade
+
+{message}
diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+server.js
new file mode 100644
index 000000000000..1566a47756bf
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+server.js
@@ -0,0 +1,6 @@
+/** @type {import('./$types').Socket} */
+export const socket = {
+ upgrade() {
+ throw new Error('upgrade hook');
+ }
+};
diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle/+server.js
new file mode 100644
index 000000000000..79a0a100affe
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle/+server.js
@@ -0,0 +1,6 @@
+export const socket = {
+ upgrade() {
+ // always abort the upgrade request because we just want to test the handle hook runs
+ throw new Response();
+ }
+};
diff --git a/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.server.js b/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.server.js
new file mode 100644
index 000000000000..7df4ecb6cbaf
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.server.js
@@ -0,0 +1,13 @@
+import { publish, getPeers } from '$app/server';
+
+export const actions = {
+ publish: async () => {
+ publish('users', 'created a new user');
+ },
+ peers: async () => {
+ const peers = getPeers();
+ peers.forEach((peer) => {
+ peer.send('sent to each peer');
+ });
+ }
+};
diff --git a/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.svelte
new file mode 100644
index 000000000000..a07a79fea6db
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.svelte
@@ -0,0 +1,32 @@
+
+
+
+ {#each messages as message}
+ {message}
+ {/each}
+
+
+
+
+
diff --git a/packages/kit/test/apps/basics/src/routes/ws/helpers/+server.js b/packages/kit/test/apps/basics/src/routes/ws/helpers/+server.js
new file mode 100644
index 000000000000..92e26abafe0b
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/helpers/+server.js
@@ -0,0 +1,6 @@
+/** @type {import('./$types').Socket} */
+export const socket = {
+ open(peer) {
+ peer.subscribe('users');
+ }
+};
diff --git a/packages/kit/test/apps/basics/src/routes/ws/no-socket/+server.js b/packages/kit/test/apps/basics/src/routes/ws/no-socket/+server.js
new file mode 100644
index 000000000000..67d0afb10478
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/no-socket/+server.js
@@ -0,0 +1,3 @@
+// this empty file ensures a server node exists but no socket or GET handler is
+// defined to test that it returns a 405 GET method not allowed when a request goes
+// through the upgrade event listener
diff --git a/packages/kit/test/apps/basics/src/routes/ws/redirect/+server.js b/packages/kit/test/apps/basics/src/routes/ws/redirect/+server.js
new file mode 100644
index 000000000000..fab5be44cfa7
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/redirect/+server.js
@@ -0,0 +1,7 @@
+import { redirect } from '@sveltejs/kit';
+
+export const socket = {
+ upgrade() {
+ redirect(303, '/ws?me');
+ }
+};
diff --git a/packages/kit/test/apps/basics/src/routes/ws/request-event/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/request-event/+page.svelte
new file mode 100644
index 000000000000..bdadd752c5f3
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/request-event/+page.svelte
@@ -0,0 +1,17 @@
+
+
+{message}
+
+ ws.send('message')}>send message
diff --git a/packages/kit/test/apps/basics/src/routes/ws/request-event/+server.js b/packages/kit/test/apps/basics/src/routes/ws/request-event/+server.js
new file mode 100644
index 000000000000..7e6edcfe5e53
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/request-event/+server.js
@@ -0,0 +1,13 @@
+/** @type {import('./$types').Socket} */
+export const socket = {
+ open(peer) {
+ peer.send(`open: ${peer.event.url.pathname}`);
+ },
+ message(peer, message) {
+ if (message.text() === 'close') {
+ peer.close();
+ return;
+ }
+ peer.send(`message: ${peer.event.url.pathname}`);
+ }
+};
diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js
index d2193940f0ab..82a8958d5c7a 100644
--- a/packages/kit/test/apps/basics/svelte.config.js
+++ b/packages/kit/test/apps/basics/svelte.config.js
@@ -14,7 +14,12 @@ const config = {
};
},
supports: {
- read: () => true
+ read: () => true,
+ webSockets: {
+ socket: () => true,
+ getPeers: () => true,
+ publish: () => true
+ }
}
},
diff --git a/packages/kit/test/apps/basics/test/cross-platform/client.test.js b/packages/kit/test/apps/basics/test/cross-platform/client.test.js
index 50cc68bc8659..b52668882212 100644
--- a/packages/kit/test/apps/basics/test/cross-platform/client.test.js
+++ b/packages/kit/test/apps/basics/test/cross-platform/client.test.js
@@ -1030,3 +1030,119 @@ test.describe('Load', () => {
});
}
});
+
+test.describe('WebSockets', () => {
+ test('upgrade hook', async ({ page }) => {
+ await page.goto('/ws');
+ await page.locator('button', { hasText: 'with sub-protocols' }).click();
+ await expect(page.getByText('connected')).toBeVisible();
+ await expect(page.getByText('protocol: bar')).toBeVisible();
+ });
+
+ test('open hook', async ({ page }) => {
+ await page.goto('/ws');
+ await page.locator('button', { hasText: 'open' }).click();
+ await expect(page.getByText('connected')).toBeVisible();
+ await expect(page.getByText('open hook works')).toBeVisible();
+ });
+
+ test('message hook', async ({ page }) => {
+ await page.goto('/ws');
+
+ await page.locator('button', { hasText: 'open' }).click();
+ await expect(page.getByText('connected')).toBeVisible();
+
+ await page.locator('button', { hasText: 'ping' }).click();
+ await expect(page.getByText('pong')).toBeVisible();
+ });
+
+ test('publish and subscribe', async ({ page }) => {
+ await page.goto('/ws');
+
+ await page.locator('button', { hasText: 'open' }).click();
+ await expect(page.getByText('connected')).toBeVisible();
+
+ await page.locator('button', { hasText: 'join' }).click();
+ await expect(page.getByText('joined the chat')).toBeVisible();
+
+ await page.locator('button', { hasText: 'chat' }).click();
+ await expect(page.getByText('hello')).toBeVisible();
+ });
+
+ test('close hook', async ({ page }) => {
+ await page.goto('/ws');
+
+ await page.locator('button', { hasText: 'open' }).click();
+ await expect(page.getByText('connected')).toBeVisible();
+
+ await page.locator('button', { hasText: 'join' }).click();
+ await expect(page.getByText('joined the chat')).toBeVisible();
+
+ await page.locator('button', { hasText: 'leave' }).click();
+ await expect(page.getByText('left the chat')).toBeVisible();
+ await expect(page.getByText('close: 1000 test')).toBeVisible();
+ });
+
+ // TODO: test error hook runs and can invoke handleError once we know how to trigger the error hook
+
+ test('upgrade hook throwing an error invokes handleError', async ({ page, read_errors }) => {
+ await page.goto('/ws/handle-error/upgrade');
+ await page.locator('button', { hasText: 'upgrade' }).click();
+ await expect(page.getByText('error')).toBeVisible();
+ await page.waitForTimeout(100); // we need to wait for the error to be written to disk
+ const error = read_errors('/ws/handle-error/upgrade');
+ expect(error.message).toBe('upgrade hook');
+ });
+
+ test('open hook throwing an error invokes handleError', async ({ page, read_errors }) => {
+ await page.goto('/ws/handle-error/open');
+ await page.locator('button', { hasText: 'open' }).click();
+ await expect(page.getByText('opened')).toBeVisible();
+ await page.waitForTimeout(100); // we need to wait for the error to be written to disk
+ const error = read_errors('/ws/handle-error/open');
+ expect(error.message).toBe('open hook');
+ });
+
+ test('message hook throwing an error invokes handleError', async ({ page, read_errors }) => {
+ await page.goto('/ws/handle-error/message');
+ await page.locator('button', { hasText: 'message' }).click();
+ await expect(page.getByText('message received')).toBeVisible();
+ await page.waitForTimeout(100); // we need to wait for the error to be written to disk
+ const error = read_errors('/ws/handle-error/message');
+ expect(error.message).toBe('message hook');
+ });
+
+ test('close hook throwing an error invokes handleError', async ({ page, read_errors }) => {
+ await page.goto('/ws/handle-error/close');
+ await page.locator('button', { hasText: 'open' }).click();
+ await expect(page.getByText('connected')).toBeVisible();
+ await page.locator('button', { hasText: 'close' }).click();
+ await expect(page.getByText('closed')).toBeVisible();
+ await page.waitForTimeout(100); // we need to wait for the error to be written to disk
+ const error = read_errors('/ws/handle-error/close');
+ expect(error.message).toBe('close hook');
+ });
+
+ test('RequestEvent is available through Peer', async ({ page }) => {
+ await page.goto('/ws/request-event');
+ // test that event has been added to the Peer object
+ await expect(page.getByText('open: /ws/request-event')).toBeVisible();
+ // test that the modifications to Peer persists to other hooks
+ await page.locator('button', { hasText: 'send message' }).click();
+ await expect(page.getByText('message: /ws/request-event')).toBeVisible();
+ });
+
+ test('getPeers helper', async ({ page }) => {
+ await page.goto('/ws/helpers');
+ await expect(page.getByText('connected')).toBeVisible();
+ await page.locator('button', { hasText: 'message all peers' }).click();
+ await expect(page.getByText('sent to each peer')).toBeVisible();
+ });
+
+ test('publish helper', async ({ page }) => {
+ await page.goto('/ws/helpers');
+ await expect(page.getByText('connected')).toBeVisible();
+ await page.locator('button', { hasText: 'create user' }).click();
+ await expect(page.getByText('created a new user')).toBeVisible();
+ });
+});
diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js
index 1a6f709bf535..e0b2ac08c1c1 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -287,6 +287,138 @@ test.describe('Endpoints', () => {
});
});
+test.describe('WebSockets', () => {
+ test('error helper rejects upgrade', async ({ request, read_errors }) => {
+ const response = await request.get('/ws/error', {
+ headers: {
+ upgrade: 'websocket',
+ connection: 'Upgrade',
+ 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==',
+ 'Sec-WebSocket-Version': '13',
+ // we need this so that one of our hook handlers doesn't reject us
+ 'User-Agent': 'node'
+ }
+ });
+
+ const error = read_errors('/ws/error');
+ expect(error).toBeUndefined();
+
+ expect(response.status()).toBe(403);
+ expect(await response.text()).toBe('Forbidden');
+ });
+
+ test('redirect helper redirects', async ({ request, read_errors }) => {
+ const response = await request.get('/ws/redirect', {
+ headers: {
+ upgrade: 'websocket',
+ connection: 'Upgrade',
+ 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==',
+ 'Sec-WebSocket-Version': '13',
+ // we need this so that one of our hook handlers doesn't reject us
+ 'User-Agent': 'node'
+ },
+ maxRedirects: 0
+ });
+
+ const error = read_errors('/ws/redirect');
+ expect(error).toBeUndefined();
+
+ expect(response.status()).toBe(303);
+ expect(response.headers().location).toBe('%2Fws%3Fme');
+ });
+
+ test('handle can return a custom response during upgrade', async ({ request }) => {
+ const response = await request.get('/ws/handle?custom', {
+ headers: {
+ upgrade: 'websocket',
+ connection: 'Upgrade',
+ 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==',
+ 'Sec-WebSocket-Version': '13',
+ // we need this so that one of our hook handlers doesn't reject us
+ 'User-Agent': 'node'
+ }
+ });
+ expect(response.status()).toBe(200);
+ expect(await response.text()).toBe('custom response');
+ });
+
+ test('handle sets cookies during upgrade', async ({ request }) => {
+ const response = await request.get('/ws/handle?set-cookie', {
+ headers: {
+ upgrade: 'websocket',
+ connection: 'Upgrade',
+ 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==',
+ 'Sec-WebSocket-Version': '13',
+ // we need this so that one of our hook handlers doesn't reject us
+ 'User-Agent': 'node'
+ }
+ });
+ expect(response.status()).toBe(200);
+ expect(response.headers()['set-cookie']).toBe(
+ 'ws%3Dtest%3B%20Path%3D%2Fws%3B%20HttpOnly%3B%20SameSite%3DLax\nname%3DSvelteKit%3B%20path%3D%2F%3B%20HttpOnly'
+ );
+ });
+
+ test('handle sets headers during upgrade', async ({ request }) => {
+ const response = await request.get('/ws/handle?set-headers', {
+ headers: {
+ upgrade: 'websocket',
+ connection: 'Upgrade',
+ 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==',
+ 'Sec-WebSocket-Version': '13',
+ // we need this so that one of our hook handlers doesn't reject us
+ 'User-Agent': 'node'
+ }
+ });
+ expect(response.status()).toBe(200);
+ expect(response.headers()['x-sveltekit-ws']).toBe('test');
+ });
+
+ test('upgrade request to non-existent route returns not found', async ({ request }) => {
+ const response = await request.get('/ws/non-existent-route', {
+ headers: {
+ upgrade: 'websocket',
+ connection: 'Upgrade',
+ 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==',
+ 'Sec-WebSocket-Version': '13',
+ // we need this so that one of our hook handlers doesn't reject us
+ 'User-Agent': 'node'
+ }
+ });
+ expect(response.status()).toBe(404);
+ });
+
+ test('upgrade request to endpoint without socket returns method not allowed', async ({
+ request
+ }) => {
+ const response = await request.get('/ws/no-socket', {
+ headers: {
+ upgrade: 'websocket',
+ connection: 'Upgrade',
+ 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==',
+ 'Sec-WebSocket-Version': '13',
+ // we need this so that one of our hook handlers doesn't reject us
+ 'User-Agent': 'node'
+ }
+ });
+ expect(response.status()).toBe(405);
+ expect(await response.text()).toBe('GET method not allowed');
+ });
+
+ test('non-upgrade request returns upgrade required when no GET handler exists', async ({
+ request
+ }) => {
+ const response = await request.get('/ws', {
+ headers: {
+ // we need this so that one of our hook handlers doesn't reject us
+ 'User-Agent': 'node'
+ }
+ });
+ expect(response.status()).toBe(426);
+ expect(await response.text()).toBe('This service requires use of the websocket protocol.');
+ });
+});
+
test.describe('Errors', () => {
test('invalid route response is handled', async ({ request }) => {
const response = await request.get('/errors/invalid-route-response');
diff --git a/packages/kit/test/apps/options-2/src/routes/+page.svelte b/packages/kit/test/apps/options-2/src/routes/+page.svelte
index c026409d91ee..a71baf39d626 100644
--- a/packages/kit/test/apps/options-2/src/routes/+page.svelte
+++ b/packages/kit/test/apps/options-2/src/routes/+page.svelte
@@ -8,6 +8,8 @@
assets: {assets}
Go to /hello
+
+Go to /ws