diff --git a/.changeset/tricky-drinks-develop.md b/.changeset/tricky-drinks-develop.md
new file mode 100644
index 000000000000..405e5db475bc
--- /dev/null
+++ b/.changeset/tricky-drinks-develop.md
@@ -0,0 +1,8 @@
+---
+'@sveltejs/adapter-cloudflare-workers': minor
+'@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..7505f3f67aef
--- /dev/null
+++ b/.changeset/two-islands-sleep.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/adapter-auto': patch
+---
+
+fix: better error message when exporting `socket`
diff --git a/documentation/docs/20-core-concepts/10-routing.md b/documentation/docs/20-core-concepts/10-routing.md
index 6fcbac8e583b..5a6851a0a65b 100644
--- a/documentation/docs/20-core-concepts/10-routing.md
+++ b/documentation/docs/20-core-concepts/10-routing.md
@@ -277,7 +277,7 @@ Like `+layout.js`, `+layout.server.js` can export [page options](page-options)
## +server
-As well as pages, you can define routes with a `+server.js` file (sometimes referred to as an 'API route' or an 'endpoint'), which gives you full control over the response. Your `+server.js` file exports functions corresponding to HTTP verbs like `GET`, `POST`, `PATCH`, `PUT`, `DELETE`, `OPTIONS`, and `HEAD` that take a `RequestEvent` argument and return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object.
+As well as pages, you can define routes with a `+server.js` file (sometimes referred to as an 'API route' or an 'endpoint'), which gives you full control over the response. Your `+server.js` file exports functions corresponding to HTTP verbs like `GET`, `POST`, `PATCH`, `PUT`, `DELETE`, `OPTIONS`, and `HEAD` that take a [`RequestEvent`](@sveltejs-kit#RequestEvent) argument and return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object.
For example we could create an `/api/random-number` route with a `GET` handler:
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 0a7c553c4acc..40fdfa54ed61 100644
--- a/documentation/docs/25-build-and-deploy/40-adapter-node.md
+++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md
@@ -34,7 +34,7 @@ Development dependencies will be bundled into your app using [Rollup](https://ro
### Compressing responses
-You will typically want to compress responses coming from the server. If you are already deploying your server behind a reverse proxy for SSL or load balancing, it typically results in better performance to also handle compression at that layer since Node.js is single-threaded.
+You will typically want to compress responses coming from the server. If you're already deploying your server behind a reverse proxy for SSL or load balancing, it typically results in better performance to also handle compression at that layer since Node.js is single-threaded.
However, if you're building a [custom server](#Custom-server) and do want to add a compression middleware there, note that we would recommend using [`@polka/compression`](https://www.npmjs.com/package/@polka/compression) since SvelteKit streams responses and the more popular `compression` package does not support streaming and may cause errors when used.
@@ -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/60-adapter-cloudflare.md b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md
index e0f71c8dcdce..ad75abc9a83e 100644
--- a/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md
+++ b/documentation/docs/25-build-and-deploy/60-adapter-cloudflare.md
@@ -84,6 +84,7 @@ export async function POST({ request, platform }) {
To make these types available to your app, install [`@cloudflare/workers-types`](https://www.npmjs.com/package/@cloudflare/workers-types) and reference them in your `src/app.d.ts`:
```ts
+// @errors: 2307
/// file: src/app.d.ts
+++import { KVNamespace, DurableObjectNamespace } from '@cloudflare/workers-types';+++
diff --git a/documentation/docs/25-build-and-deploy/70-adapter-cloudflare-workers.md b/documentation/docs/25-build-and-deploy/70-adapter-cloudflare-workers.md
index 71197d9daabe..ce6c49b9b14c 100644
--- a/documentation/docs/25-build-and-deploy/70-adapter-cloudflare-workers.md
+++ b/documentation/docs/25-build-and-deploy/70-adapter-cloudflare-workers.md
@@ -91,6 +91,7 @@ export async function POST({ request, platform }) {
To make these types available to your app, install [`@cloudflare/workers-types`](https://www.npmjs.com/package/@cloudflare/workers-types) and reference them in your `src/app.d.ts`:
```ts
+// @errors: 2307
/// file: src/app.d.ts
+++import { KVNamespace, DurableObjectNamespace } from '@cloudflare/workers-types';+++
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 c4092af15fb2..9c8dd3b8f561 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
+ }
}
}
};
@@ -58,3 +75,5 @@ 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 handle upgrading a HTTP request to a WebSocket connection. You can do this by listening for requests from the platform that have an `Upgrade: websocket` header, calling the `server.getWebSocketHooksResolver({ getClientAddress })` function to get the WebSocket hooks resolver and passing it to the crossws adapter `resolve` option. The [crossws Adapters section](https://crossws.unjs.io/adapters) provides examples of creating this integration within various environments.
diff --git a/documentation/docs/30-advanced/15-websockets.md b/documentation/docs/30-advanced/15-websockets.md
new file mode 100644
index 000000000000..d323a01fd3f6
--- /dev/null
+++ b/documentation/docs/30-advanced/15-websockets.md
@@ -0,0 +1,133 @@
+---
+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.
+
+## The `socket` object
+
+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('@sveltejs/kit').Socket} **/
+export const socket = {
+ upgrade(event) {
+ // ...
+ },
+ open(peer) {
+ // ...
+ },
+ message(peer, message) {
+ // ...
+ },
+ close(peer, event) {
+ // ...
+ },
+ error(peer, error) {
+ // ...
+ }
+};
+```
+
+### upgrade
+
+The `upgrade` hook is called before a WebSocket connection is established. It takes a [RequestEvent](@sveltejs-kit#RequestEvent) argument.
+
+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('@sveltejs/kit').Socket} **/
+export const socket = {
+ upgrade({ request }) {
+ if (request.headers.get('origin') !== '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 the [peer](https://crossws.unjs.io/guide/peer) object, to allow interacting with connected clients, as a parameter.
+
+```js
+/** @type {import('@sveltejs/kit').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, containing data from the client, as parameters.
+
+```js
+/** @type {import('@sveltejs/kit').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 event object, containing the [WebSocket connection close code](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code#value) and reason, as parameters.
+
+```js
+/** @type {import('@sveltejs/kit').Socket} **/
+export const socket = {
+ close(peer, event) {
+ // ...
+ }
+};
+```
+
+### 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, as parameters.
+
+```js
+/** @type {import('@sveltejs/kit').Socket} **/
+export const socket = {
+ error(peer, error) {
+ // ...
+ }
+};
+```
+
+## `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/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md
index 849be6910b48..789124836ccb 100644
--- a/documentation/docs/30-advanced/20-hooks.md
+++ b/documentation/docs/30-advanced/20-hooks.md
@@ -106,7 +106,7 @@ Note that `resolve(...)` will never throw an error, it will always return a `Pro
### handleFetch
-This function allows you to modify (or replace) a `fetch` request that happens inside a `load` or `action` function that runs on the server (or during pre-rendering).
+This function allows you to modify (or replace) a `fetch` request that happens inside a `load`, `action`, or `handle` function that runs on the server (or during prerendering).
For example, your `load` function might make a request to a public URL like `https://api.yourapp.com` when the user performs a client-side navigation to the respective page, but during SSR it might make sense to hit the API directly (bypassing whatever proxies and load balancers sit between it and the public internet).
@@ -153,7 +153,7 @@ The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`:
### handleError
-If an [unexpected error](errors#Unexpected-errors) is thrown during loading or rendering, this function will be called with the `error`, `event`, `status` code and `message`. This allows for two things:
+If an [unexpected error](errors#Unexpected-errors) is thrown during loading, rendering, or from an endpoint, this function will be called with the `error`, `event`, `status` code and `message`. This allows for two things:
- you can log the error
- you can generate a custom representation of the error that is safe to show to users, omitting sensitive details like messages and stack traces. The returned value, which defaults to `{ message }`, becomes the value of `$page.error`.
diff --git a/packages/adapter-auto/index.js b/packages/adapter-auto/index.js
index c83ba6246c59..0714f135e15c 100644
--- a/packages/adapter-auto/index.js
+++ b/packages/adapter-auto/index.js
@@ -122,9 +122,35 @@ export default () => ({
},
supports: {
read: () => {
- throw new Error(
- "The read function imported from $app/server only works in certain environments. Since you're using @sveltejs/adapter-auto, SvelteKit cannot determine whether it will work when your app is deployed. Please replace it with an adapter tailored to your target environment."
+ 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'
+ );
+ }
}
}
});
+
+/**
+ * @param {string} message
+ * @returns {never}
+ * @throws {Error}
+ */
+function supports_error(message) {
+ throw new Error(
+ `${message}. Since you're using @sveltejs/adapter-auto, SvelteKit cannot determine whether it will work when your app is deployed. Please replace it with an adapter tailored to your target environment.`
+ );
+}
diff --git a/packages/adapter-cloudflare-workers/files/_package.json b/packages/adapter-cloudflare-workers/files/_package.json
index bc4c8d4aabac..b54b25124998 100644
--- a/packages/adapter-cloudflare-workers/files/_package.json
+++ b/packages/adapter-cloudflare-workers/files/_package.json
@@ -4,6 +4,7 @@
"description": "Worker site generated by SvelteKit",
"main": "index.js",
"dependencies": {
- "@cloudflare/kv-asset-handler": "~0.1.3"
+ "@cloudflare/kv-asset-handler": "~0.1.3",
+ "crossws": "^0.3.4"
}
}
diff --git a/packages/adapter-cloudflare-workers/files/entry.js b/packages/adapter-cloudflare-workers/files/entry.js
index 5f022e5096b9..c394873a4604 100644
--- a/packages/adapter-cloudflare-workers/files/entry.js
+++ b/packages/adapter-cloudflare-workers/files/entry.js
@@ -2,6 +2,8 @@ import { Server } from 'SERVER';
import { manifest, prerendered, base_path } from 'MANIFEST';
import { getAssetFromKV, mapRequestToAsset } from '@cloudflare/kv-asset-handler';
import static_asset_manifest_json from '__STATIC_CONTENT_MANIFEST';
+import crossws from 'crossws/adapters/cloudflare';
+
const static_asset_manifest = JSON.parse(static_asset_manifest_json);
const server = new Server(manifest);
@@ -11,6 +13,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
@@ -18,7 +31,40 @@ export default {
* @param {any} context
*/
async fetch(req, env, context) {
- await server.init({ env });
+ const options = /** @satisfies {Parameters[1]} */ ({
+ platform: {
+ env,
+ context,
+ // @ts-ignore lib.dom is interfering with workers-types
+ caches,
+ // @ts-ignore req is actually a Cloudflare request not a standard request
+ cf: req.cf
+ },
+ getClientAddress() {
+ return req.headers.get('cf-connecting-ip');
+ }
+ });
+
+ await server.init({
+ 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;
+ return ws.handleUpgrade(
+ // @ts-ignore wtf is Cloudflare doing to these types
+ req,
+ env,
+ context
+ );
+ }
const url = new URL(req.url);
@@ -90,19 +136,11 @@ export default {
}
// dynamically-generated pages
- return await server.respond(req, {
- platform: {
- env,
- context,
- // @ts-expect-error lib.dom is interfering with workers-types
- caches,
- // @ts-expect-error req is actually a Cloudflare request not a standard request
- cf: req.cf
- },
- getClientAddress() {
- return req.headers.get('cf-connecting-ip');
- }
- });
+ return await server.respond(
+ req,
+ // @ts-ignore
+ options
+ );
}
};
diff --git a/packages/adapter-cloudflare-workers/index.js b/packages/adapter-cloudflare-workers/index.js
index 5d13539cd915..1d2455e1b433 100644
--- a/packages/adapter-cloudflare-workers/index.js
+++ b/packages/adapter-cloudflare-workers/index.js
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url';
import esbuild from 'esbuild';
import { getPlatformProxy, unstable_readConfig } from 'wrangler';
+const name = '@sveltejs/adapter-cloudflare-workers';
+
// list from https://developers.cloudflare.com/workers/runtime-apis/nodejs/
const compatible_node_modules = [
'assert',
@@ -23,7 +25,7 @@ const compatible_node_modules = [
/** @type {import('./index.js').default} */
export default function ({ config, platformProxy = {} } = {}) {
return {
- name: '@sveltejs/adapter-cloudflare-workers',
+ name,
async adapt(builder) {
const { main, site, compatibility_flags } = validate_config(builder, config);
@@ -171,6 +173,18 @@ export default function ({ config, platformProxy = {} } = {}) {
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-workers/package.json b/packages/adapter-cloudflare-workers/package.json
index 1491e69c266e..74cf2f306d88 100644
--- a/packages/adapter-cloudflare-workers/package.json
+++ b/packages/adapter-cloudflare-workers/package.json
@@ -38,7 +38,9 @@
"check": "tsc --skipLibCheck"
},
"dependencies": {
- "@cloudflare/workers-types": "^4.20231121.0",
+ "@cloudflare/workers-types": "^4.20250129.0",
+ "@iarna/toml": "^2.2.5",
+ "crossws": "^0.3.4",
"esbuild": "^0.24.0"
},
"devDependencies": {
diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js
index ceac64d92a2a..45feefb97d29 100644
--- a/packages/adapter-cloudflare/index.js
+++ b/packages/adapter-cloudflare/index.js
@@ -3,10 +3,12 @@ import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getPlatformProxy } 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(
@@ -100,6 +102,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 235a6129ad36..013f58ca6242 100644
--- a/packages/adapter-cloudflare/package.json
+++ b/packages/adapter-cloudflare/package.json
@@ -41,6 +41,7 @@
},
"dependencies": {
"@cloudflare/workers-types": "^4.20241106.0",
+ "crossws": "^0.3.4",
"esbuild": "^0.24.0",
"worktop": "0.8.0-next.18"
},
diff --git a/packages/adapter-cloudflare/src/worker.js b/packages/adapter-cloudflare/src/worker.js
index c3c27a0b041f..7456eb7e7335 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,11 +10,57 @@ 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)
+ });
+}
+
/** @type {import('worktop/cfw').Module.Worker<{ ASSETS: import('worktop/cfw.durable').Durable.Object }>} */
const worker = {
+ // @ts-ignore wtf is Cloudflare doing to these types
async fetch(req, env, context) {
- // @ts-ignore
- await server.init({ env });
+ const options = /** @satisfies {Parameters[1]} */ ({
+ platform: {
+ env,
+ context,
+ // @ts-ignore
+ caches,
+ // @ts-ignore
+ cf: req.cf
+ },
+ getClientAddress() {
+ return req.headers.get('cf-connecting-ip');
+ }
+ });
+
+ await server.init({
+ // @ts-ignore
+ 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;
+ return ws.handleUpgrade(
+ // @ts-ignore wtf is Cloudflare doing to these types
+ 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));
@@ -58,13 +105,11 @@ const worker = {
});
} else {
// dynamically-generated pages
- res = await server.respond(req, {
+ res = await server.respond(
+ req,
// @ts-ignore
- platform: { env, context, caches, cf: req.cf },
- getClientAddress() {
- return req.headers.get('cf-connecting-ip');
- }
- });
+ options
+ );
}
// write to `Cache` only if response is not an error,
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..fe6e78d5bf26 100644
--- a/packages/adapter-node/internal.d.ts
+++ b/packages/adapter-node/internal.d.ts
@@ -4,6 +4,7 @@ declare module 'ENV' {
declare module 'HANDLER' {
export const handler: import('polka').Middleware;
+ export const upgradeHandler: import('crossws/adapters/node').NodeAdapter['handleUpgrade'];
}
declare module 'MANIFEST' {
diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json
index b287b1086b3d..21a73116db08 100644
--- a/packages/adapter-node/package.json
+++ b/packages/adapter-node/package.json
@@ -46,6 +46,7 @@
"@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/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..11a3b89de8ce 100644
--- a/packages/adapter-node/src/index.js
+++ b/packages/adapter-node/src/index.js
@@ -1,7 +1,8 @@
import process from 'node:process';
-import { handler } from 'HANDLER';
+import { handler, upgradeHandler } from 'HANDLER';
import { env } from 'ENV';
import polka from 'polka';
+import { closeAllWebSockets, terminateAllWebSockets } from './handler.js';
export const path = env('SOCKET_PATH', false);
export const host = env('HOST', '0.0.0.0');
@@ -43,6 +44,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 +71,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/kit/package.json b/packages/kit/package.json
index 80763f3056c5..2820e30e5c30 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/exports/public.d.ts b/packages/kit/src/exports/public.d.ts
index 2ff29f3571a0..57128d6691a7 100644
--- a/packages/kit/src/exports/public.d.ts
+++ b/packages/kit/src/exports/public.d.ts
@@ -36,18 +36,32 @@ export interface Adapter {
*/
adapt: (builder: Builder) => MaybePromise;
/**
- * Checks called during dev and build to determine whether specific features will work in production with this adapter
+ * Checks called during dev and build to determine whether specific features will work in production with this adapter.
*/
supports?: {
/**
- * Test support for `read` from `$app/server`
- * @param config The merged route config
+ * Test support for `read` from `$app/server`.
+ * @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
- * during dev, build and prerendering
+ * during dev, build and prerendering.
*/
emulate?: () => MaybePromise;
}
@@ -1299,6 +1313,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 {
@@ -1306,6 +1324,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 {
@@ -1404,7 +1426,7 @@ export interface ServerLoadEvent<
}
/**
- * Shape of a form action method that is part of `export const actions = {..}` in `+page.server.js`.
+ * Shape of a form action method that is part of `export const actions = {...}` in `+page.server.js`.
* See [form actions](https://svelte.dev/docs/kit/form-actions) for more information.
*/
export type Action<
@@ -1414,7 +1436,7 @@ export type Action<
> = (event: RequestEvent) => MaybePromise;
/**
- * Shape of the `export const actions = {..}` object in `+page.server.js`.
+ * Shape of the `export const actions = {...}` object in `+page.server.js`.
* See [form actions](https://svelte.dev/docs/kit/form-actions) for more information.
*/
export type Actions<
@@ -1488,6 +1510,45 @@ export type SubmitFunction<
}) => MaybePromise)
>;
+/**
+ * Shape of the `export const socket = {...}` object in `+server.js`.
+ * See [WebSockets](https://svelte.dev/docs/kit/websockets) for more information.
+ * @since 2.19.0
+ */
+export interface Socket {
+ // TODO: write our own descriptions for these properties?
+ /**
+ * Upgrading.
+ * @param event
+ * @throws {Response}
+ */
+ upgrade?: (
+ event: RequestEvent & { context: import('crossws').Peer['context'] }
+ ) => MaybePromise;
+ /** A message is received. */
+ message?: import('crossws').Hooks['message'];
+ /** A socket is opened. */
+ open?: import('crossws').Hooks['open'];
+ /** A socket is closed. */
+ close?: import('crossws').Hooks['close'];
+ /** A WebSocket error occurs. */
+ error?: import('crossws').Hooks['error'];
+}
+
+/**
+ * When a new [WebSocket](https://svelte.dev/docs/kit/websockets) client connects to the server, `crossws` creates a `peer` instance that allows getting information from clients and sending messages to them.
+ * See [Peer](https://crossws.unjs.io/guide/peer) for more information.
+ * @since 2.19.0
+ */
+export type Peer = import('crossws').Peer;
+
+/**
+ * During a [WebSocket](https://svelte.dev/docs/kit/websockets) `message` hook, you receive a `message` object containing data from the client.
+ * See [Message](https://crossws.unjs.io/guide/message) for more information.
+ * @since 2.19.0
+ */
+export type Message = import('crossws').Message;
+
/**
* The type of `export const snapshot` exported from a page or layout component.
*/
diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js
index 7049d8910508..e702913ba09b 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';
@@ -444,6 +445,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) =>
@@ -454,14 +521,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)));
@@ -497,25 +629,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,
@@ -545,21 +659,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
});
@@ -656,3 +758,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 bdb37b1f9cff..ba3c8b63fbef 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 0342e718c75c..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';
@@ -14,7 +15,7 @@ import { not_found } from '../utils.js';
/** @typedef {(req: Req, res: Res, next: () => void) => void} Handler */
/**
- * @param {{ middlewares: import('connect').Server }} vite
+ * @param {import('vite').PreviewServer} vite
* @param {import('vite').ResolvedConfig} vite_config
* @param {import('types').ValidatedConfig} svelte_config
*/
@@ -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 33c9b0a0d1ba..51892773f9b4 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';
@@ -71,3 +71,57 @@ 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.19.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.19.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);
+}
diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js
index 55bcd87807b9..4e122136c222 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 { Redirect } from '../control.js';
@@ -10,11 +11,27 @@ 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.GET && !mod.HEAD) {
+ if (method === 'HEAD' && !mod.HEAD && mod.GET) {
handler = 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 08e142a3ce39..5c9fce06c0a1 100644
--- a/packages/kit/src/runtime/server/respond.js
+++ b/packages/kit/src/runtime/server/respond.js
@@ -51,9 +51,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 ignore the upgrade request and send back the response
+ 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) &&
@@ -64,14 +130,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 });
}
}
@@ -241,6 +302,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';
@@ -315,25 +393,132 @@ export async function respond(request, options, manifest, state) {
if (state.prerendering && !state.prerendering.fallback) disable_search(url);
- const response = await options.hooks.handle({
- event,
- resolve: (event, opts) =>
- 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 {Response} response
+ * @returns {Response}
+ */
+ const after_resolve = (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));
+ }
- add_cookies_to_headers(response.headers, Object.values(new_cookies));
+ return response;
+ };
+
+ if (upgrade && route?.endpoint) {
+ const node = await route.endpoint();
+ if (node.socket) {
+ if (DEV) {
+ __SVELTEKIT_TRACK__('websockets');
+ }
+
+ return {
+ upgrade: async ({ context }) => {
+ /** @type {Response} */
+ let response;
+
+ try {
+ response = await options.hooks.handle({
+ event,
+ resolve: async (event) => {
+ /** @type {Response} */
+ let upgrade_response;
+
+ try {
+ let init;
+ if (node.socket?.upgrade) {
+ Object.defineProperty(event, 'context', {
+ enumerable: true,
+ value: context
+ });
+ init =
+ (await node.socket.upgrade(
+ /** @type {import('@sveltejs/kit').RequestEvent & { context: {} }} */ (
+ event
+ )
+ )) ?? undefined;
+ }
+ upgrade_response = new Response(undefined, init);
+ 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 after_resolve(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 a custom response has been thrown
+ // from the upgrade hook or returned from the handle hook
+ if (!response.headers.has('x-sveltekit-upgrade')) {
+ throw response;
+ }
- if (state.prerendering && event.route.id !== null) {
- response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id));
+ return response;
+ },
+ open: async (peer) => {
+ try {
+ await node.socket?.open?.(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);
+ }
}
+ };
+ }
+ }
- return response;
- })
+ const response = await options.hooks.handle({
+ event,
+ resolve: (event, opts) => resolve(event, page_nodes, opts).then(after_resolve)
});
// respond with 304 if etag matches
@@ -381,16 +566,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);
}
/**
@@ -546,14 +722,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 82317e8417ab..18934eaa40a8 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;
@@ -165,18 +167,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 {
@@ -444,6 +448,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 1c825a6a6c90..37f0019185d8 100644
--- a/packages/kit/test/apps/basics/src/hooks.server.js
+++ b/packages/kit/test/apps/basics/src/hooks.server.js
@@ -151,6 +151,34 @@ export const handle = sequence(
event.locals.url = new URL(event.request.url);
}
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;
}
);
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 @@
+
+
+
+
+
+
+
+
+
+
+ {#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..c5c56defdae1
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/+server.js
@@ -0,0 +1,42 @@
+/** @type {import('@sveltejs/kit').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..51f11ede920f
--- /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('@sveltejs/kit').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 @@
+
+
+
+
+
+
+{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..02854fab8cd4
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+server.js
@@ -0,0 +1,11 @@
+/** @type {import('@sveltejs/kit').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 @@
+
+
+
+
+{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..a7055631a1d7
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+server.js
@@ -0,0 +1,7 @@
+/** @type {import('@sveltejs/kit').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 @@
+
+
+
+
+{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..30f36c633ad1
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+server.js
@@ -0,0 +1,7 @@
+/** @type {import('@sveltejs/kit').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 @@
+
+
+
+
+{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..38393094de8b
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+server.js
@@ -0,0 +1,6 @@
+/** @type {import('@sveltejs/kit').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..990739a3e2c0
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/ws/helpers/+server.js
@@ -0,0 +1,6 @@
+/** @type {import('@sveltejs/kit').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/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js
index bca05e5376ee..092ae25a14ec 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 c1de68907024..67ca13e686d6 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
@@ -1012,3 +1012,110 @@ 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('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 bfe1b85b8e2b..898c535e49f9 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