diff --git a/.changeset/tricky-drinks-develop.md b/.changeset/tricky-drinks-develop.md new file mode 100644 index 000000000000..132ec31b0e64 --- /dev/null +++ b/.changeset/tricky-drinks-develop.md @@ -0,0 +1,7 @@ +--- +'@sveltejs/adapter-cloudflare': minor +'@sveltejs/adapter-node': minor +'@sveltejs/kit': minor +--- + +feat: add support for WebSockets diff --git a/.changeset/two-islands-sleep.md b/.changeset/two-islands-sleep.md new file mode 100644 index 000000000000..c3275023188e --- /dev/null +++ b/.changeset/two-islands-sleep.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-auto': patch +--- + +fix: add error message when using WebSockets as they are unsupported diff --git a/documentation/docs/25-build-and-deploy/40-adapter-node.md b/documentation/docs/25-build-and-deploy/40-adapter-node.md index ce0a5bccf95f..3007b837c4d7 100644 --- a/documentation/docs/25-build-and-deploy/40-adapter-node.md +++ b/documentation/docs/25-build-and-deploy/40-adapter-node.md @@ -241,12 +241,12 @@ WantedBy=sockets.target The adapter creates two files in your build directory — `index.js` and `handler.js`. Running `index.js` — e.g. `node build`, if you use the default build directory — will start a server on the configured port. -Alternatively, you can import the `handler.js` file, which exports a handler suitable for use with [Express](https://github.com/expressjs/express), [Connect](https://github.com/senchalabs/connect) or [Polka](https://github.com/lukeed/polka) (or even just the built-in [`http.createServer`](https://nodejs.org/dist/latest/docs/api/http.html#httpcreateserveroptions-requestlistener)) and set up your own server: +Alternatively, you can import the `handler.js` file, which exports handlers suitable for use with [Express](https://github.com/expressjs/express), [Connect](https://github.com/senchalabs/connect) or [Polka](https://github.com/lukeed/polka) (or even just the built-in [`http.createServer`](https://nodejs.org/dist/latest/docs/api/http.html#httpcreateserveroptions-requestlistener)) and set up your own server: ```js // @errors: 2307 7006 /// file: my-server.js -import { handler } from './build/handler.js'; +import { handler, upgradeHandler } from './build/handler.js'; import express from 'express'; const app = express(); @@ -256,10 +256,15 @@ app.get('/healthcheck', (req, res) => { res.end('ok'); }); -// let SvelteKit handle everything else, including serving prerendered pages and static assets +// let SvelteKit handle serving prerendered pages, static assets, and SSR app.use(handler); -app.listen(3000, () => { +const server = app.listen(3000, () => { console.log('listening on port 3000'); }); + +// let SvelteKit handle upgrades for WebSocket connections +server.on('upgrade', upgradeHandler); ``` + +If you're manually handling the `SIGTERM` and `SIGINT` signal events to implement your own graceful shutdown, you must use the `closeAllWebSockets` and the `terminateAllWebSockets` helpers imported from the `handler.js` file to gracefully close or immediately terminate all active WebSocket connections. diff --git a/documentation/docs/25-build-and-deploy/99-writing-adapters.md b/documentation/docs/25-build-and-deploy/99-writing-adapters.md index a2bfb50cd7b2..f3759acf70a7 100644 --- a/documentation/docs/25-build-and-deploy/99-writing-adapters.md +++ b/documentation/docs/25-build-and-deploy/99-writing-adapters.md @@ -34,6 +34,23 @@ export default function (options) { // Return `true` if the route with the given `config` can use `read` // from `$app/server` in production, return `false` if it can't. // Or throw a descriptive error describing how to configure the deployment + }, + webSockets: { + socket: () => { + // Return `true` if the production environment supports WebSockets, + // return `false` if it can't. + // Or throw a descriptive error describing how to configure the deployment + }, + getPeers: ({ route }) => { + // Return `true` if the production environment supports WebSockets, + // return `false` if it can't. + // Or throw a descriptive error describing how to configure the deployment + }, + publish: ({ route }) => { + // Return `true` if the production environment supports coordination among + // multiple WebSockets, return `false` if it can't. + // Or throw a descriptive error describing how to configure the deployment + } } } }; @@ -51,6 +68,7 @@ Within the `adapt` method, there are a number of things that an adapter should d - Output code that: - Imports `Server` from `${builder.getServerDirectory()}/index.js` - Instantiates the app with a manifest generated with `builder.generateManifest({ relativePath })` + - Initialises the server by calling the `server.init({ env })` function - Listens for requests from the platform, converts them to a standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) if necessary, calls the `server.respond(request, { getClientAddress })` function to generate a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) and responds with it - expose any platform-specific information to SvelteKit via the `platform` option passed to `server.respond` - Globally shims `fetch` to work on the target platform, if necessary. SvelteKit provides a `@sveltejs/kit/node/polyfills` helper for platforms that can use `undici` @@ -58,3 +76,8 @@ Within the `adapt` method, there are a number of things that an adapter should d - Put the user's static files and the generated JS/CSS in the correct location for the target platform Where possible, we recommend putting the adapter output under the `build/` directory with any intermediate output placed under `.svelte-kit/[adapter-name]`. + +If your environment supports WebSockets, you will need to configure the `supports.webSockets` property returned by the adapter and integrate [`crossws`](https://crossws.unjs.io/adapters) into your adapter by outputting code that: + +- Initialises the server with the `crossws` adapter's `peers` and `publish` utilities by calling the `server.init({ env, peers, publish })` function +- Listens for requests from the platform that have an `Upgrade: websocket` header, converts them to a standard `Request` if necessary, calls the `server.resolveWebSocketHooks(request, { getClientAddress })` function to resolve the WebSocket hooks, and passes it to the `crossws` adapter's `resolve` option. diff --git a/documentation/docs/30-advanced/15-websockets.md b/documentation/docs/30-advanced/15-websockets.md new file mode 100644 index 000000000000..ef615670bb36 --- /dev/null +++ b/documentation/docs/30-advanced/15-websockets.md @@ -0,0 +1,137 @@ +--- +title: WebSockets +--- + +[WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) provide a way to open a bidirectional communication channel between a client and a server. SvelteKit uses [`crossws`](https://crossws.unjs.io/) to provide a consistent interface across different platforms. + +## Hooks + +A `+server.js` file can export a `socket` object with [hooks](https://crossws.unjs.io/guide/hooks), all optional, to handle the different stages of the WebSocket lifecycle. + +```js +/** @type {import('./$types').Socket} **/ +export const socket = { + upgrade(event) { + // ... + }, + open(peer) { + // ... + }, + message(peer, message) { + // ... + }, + close(peer, details) { + // ... + }, + error(peer, error) { + // ... + } +}; +``` + +### upgrade + +The `upgrade` hook is called before a WebSocket connection is established. It receives a [`RequestEvent`](@sveltejs-kit#RequestEvent) object that has an additional [`context`](https://crossws.unjs.io/guide/peer#peercontext) property to store abitrary information that is shared with that connection's [`Peer`](https://crossws.unjs.io/guide/peer) object. + +You can use the [`error`](@sveltejs-kit#error) function imported from `@sveltejs/kit` to easily reject connections. Requests will be auto-accepted if the `upgrade` hook is not defined or does not `error`. + +```js +import { error } from "@sveltejs/kit"; + +/** @type {import('./$types').Socket} **/ +export const socket = { + upgrade({ request }) { + if (request.headers.get('origin') !== 'my_allowed_origin') { + // Reject the WebSocket connection by throwing an error + error(403, 'Forbidden'); + } + } +}; +``` + +### open + +The `open` hook is called when a WebSocket connection is opened. It receives a [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients. + +```js +/** @type {import('./$types').Socket} **/ +export const socket = { + open(peer) { + // ... + } +}; +``` + +### message + +The `message` hook is called when a message is received from the client. It receives the [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients and the [`Message`](https://crossws.unjs.io/guide/message) object which contains data from the client. + +```js +/** @type {import('./$types').Socket} **/ +export const socket = { + message(peer, message) { + // ... + } +}; +``` + +### close + +The `close` hook is called when a WebSocket connection is closed. It receives the [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients and the close details object which contains the [WebSocket connection close code](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code#value) and reason. + +```js +/** @type {import('./$types').Socket} **/ +export const socket = { + close(peer, details) { + // ... + } +}; +``` + +### error + +The `error` hook is called when a connection with a WebSocket has been closed due to an error. It receives the [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients and the error. + +```js +/** @type {import('./$types').Socket} **/ +export const socket = { + error(peer, error) { + // ... + } +}; +``` + +## Accessing `RequestEvent` through `Peer` + +The [`Peer`](https://crossws.unjs.io/guide/peer) object has been extended to include the [`RequestEvent`](@sveltejs-kit#RequestEvent) object from the initial upgrade request. It can be accessed through the `peer.event` property. + +## `getPeers` and `publish` + +The [`getPeers`]($app-server#getPeers) and [`publish`]($app-server#publish) functions from `$app/server` can be used to interact with your WebSocket connections from anywhere on the server. + +## Connecting from the client + +To connect to a WebSocket endpoint, you can use the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket) constructor in the browser. + +```svelte + +``` + +See [the WebSocket documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) for more details. + +## Compatibility + +Please refer to the crossws [`Peer` object compatibility table](https://crossws.unjs.io/guide/peer#compatibility) and [`Message` object compatibility table](https://crossws.unjs.io/guide/message#adapter-support) to know what is supported in different runtime environments. diff --git a/eslint.config.js b/eslint.config.js index 8444424cd723..8942562f7efb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -31,8 +31,9 @@ export default [ }, ignores: [ 'packages/adapter-cloudflare/test/apps/**/*', + 'packages/adapter-node/test/apps/**/*', 'packages/adapter-node/rollup.config.js', - 'packages/adapter-node/tests/smoke.spec_disabled.js', + 'packages/adapter-node/smoke.spec_disabled.js', 'packages/adapter-static/test/apps/**/*', 'packages/create-svelte/shared/**/*', 'packages/create-svelte/templates/**/*', diff --git a/packages/adapter-auto/index.js b/packages/adapter-auto/index.js index ccd20f600e56..0714f135e15c 100644 --- a/packages/adapter-auto/index.js +++ b/packages/adapter-auto/index.js @@ -125,6 +125,21 @@ export default () => ({ supports_error( 'The read function imported from $app/server only works in certain environments' ); + }, + webSockets: { + socket: () => { + supports_error('The socket export only works in environments that support WebSockets'); + }, + getPeers: () => { + supports_error( + 'The getPeers function imported from $app/server only works in environments that support WebSockets' + ); + }, + publish: () => { + supports_error( + 'The publish function imported from $app/server only works in environments that support WebSockets' + ); + } } } }); diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index af99452ef8fd..5d4b10556df1 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -4,10 +4,12 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { getPlatformProxy, unstable_readConfig } from 'wrangler'; +const name = '@sveltejs/adapter-cloudflare'; + /** @type {import('./index.js').default} */ export default function (options = {}) { return { - name: '@sveltejs/adapter-cloudflare', + name, async adapt(builder) { if (existsSync('_routes.json')) { throw new Error( @@ -160,6 +162,18 @@ export default function (options = {}) { return prerender ? emulated.prerender_platform : emulated.platform; } }; + }, + supports: { + webSockets: { + socket: () => true, + getPeers: () => true, + publish: ({ route }) => { + // TODO: allow WebSocket integration with Durable Objects using crossws/adapters/cloudflare-durable + throw new Error( + `${name}: Cannot use \`publish\` from \`$app/server\` in route \`${route.id}\` because Cloudflare Workers cannot coordinate among multiple WebSocket connections without Durable Objects` + ); + } + } } }; } diff --git a/packages/adapter-cloudflare/package.json b/packages/adapter-cloudflare/package.json index fa24270f8faa..dab585127c78 100644 --- a/packages/adapter-cloudflare/package.json +++ b/packages/adapter-cloudflare/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "@cloudflare/workers-types": "^4.20250415.0", + "crossws": "^0.3.4", "worktop": "0.8.0-next.18" }, "devDependencies": { diff --git a/packages/adapter-cloudflare/src/worker.js b/packages/adapter-cloudflare/src/worker.js index a2ea423c1653..f3d753218399 100644 --- a/packages/adapter-cloudflare/src/worker.js +++ b/packages/adapter-cloudflare/src/worker.js @@ -1,6 +1,7 @@ import { Server } from 'SERVER'; import { manifest, prerendered, base_path } from 'MANIFEST'; import * as Cache from 'worktop/cfw.cache'; +import crossws from 'crossws/adapters/cloudflare'; const server = new Server(manifest); @@ -9,6 +10,17 @@ const app_path = `/${manifest.appPath}`; const immutable = `${app_path}/immutable/`; const version_file = `${app_path}/version.json`; +/** @type {import('crossws').ResolveHooks} */ +let resolve_websocket_hooks; +/** @type {import('crossws/adapters/cloudflare').CloudflareAdapter} */ +let ws; + +if (server.resolveWebSocketHooks) { + ws = crossws({ + resolve: (req) => resolve_websocket_hooks(req) + }); +} + export default { /** * @param {Request} req @@ -17,11 +29,38 @@ export default { * @returns {Promise} */ async fetch(req, env, context) { + const options = /** @satisfies {Parameters[1]} */ ({ + platform: { + env, + context, + // @ts-expect-error webworker types from worktop are not compatible with Cloudflare Workers types + caches, + // @ts-expect-error the type is correct but ts is confused because platform.cf uses the type from index.ts while req.cf uses the type from index.d.ts + cf: req.cf + }, + getClientAddress() { + return req.headers.get('cf-connecting-ip'); + } + }); + await server.init({ // @ts-expect-error env contains environment variables and bindings - env + env, + peers: ws?.peers, + publish: ws?.publish }); + if (req.headers.get('upgrade') === 'websocket' && ws) { + const hooks = await server.resolveWebSocketHooks( + req, + // @ts-ignore + options + ); + resolve_websocket_hooks = () => hooks; + // @ts-ignore wtf is Cloudflare doing to these types + return ws.handleUpgrade(req, env, context); + } + // skip cache if "cache-control: no-cache" in request let pragma = req.headers.get('cache-control') || ''; let res = !pragma.includes('no-cache') && (await Cache.lookup(req)); @@ -67,19 +106,11 @@ export default { }); } else { // dynamically-generated pages - res = await server.respond(req, { - platform: { - env, - context, - // @ts-expect-error webworker types from worktop are not compatible with Cloudflare Workers types - caches, - // @ts-expect-error the type is correct but ts is confused because platform.cf uses the type from index.ts while req.cf uses the type from index.d.ts - cf: req.cf - }, - getClientAddress() { - return req.headers.get('cf-connecting-ip'); - } - }); + res = await server.respond( + req, + // @ts-ignore wtf is Cloudflare doing to these types + options + ); } // write to `Cache` only if response is not an error, diff --git a/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+page.svelte b/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+page.svelte new file mode 100644 index 000000000000..59ca583f5c51 --- /dev/null +++ b/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+page.svelte @@ -0,0 +1,14 @@ + + +

{message}

diff --git a/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+server.js b/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+server.js new file mode 100644 index 000000000000..0d64dc2a0713 --- /dev/null +++ b/packages/adapter-cloudflare/test/apps/pages/src/routes/ws/+server.js @@ -0,0 +1,6 @@ +/** @type {import('./$types').Socket} */ +export const socket = { + open(peer) { + peer.send('connected'); + } +}; diff --git a/packages/adapter-cloudflare/test/apps/pages/test/test.js b/packages/adapter-cloudflare/test/apps/pages/test/test.js index aab7cbca568a..c32d4f63a294 100644 --- a/packages/adapter-cloudflare/test/apps/pages/test/test.js +++ b/packages/adapter-cloudflare/test/apps/pages/test/test.js @@ -4,3 +4,8 @@ test('worker works', async ({ page }) => { await page.goto('/'); await expect(page.locator('h1')).toContainText('Sum: 3'); }); + +test('WebSockets work', async ({ page }) => { + await page.goto('/ws'); + await expect(page.locator('p')).toContainText('connected'); +}); diff --git a/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+page.svelte b/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+page.svelte new file mode 100644 index 000000000000..59ca583f5c51 --- /dev/null +++ b/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+page.svelte @@ -0,0 +1,14 @@ + + +

{message}

diff --git a/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+server.js b/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+server.js new file mode 100644 index 000000000000..0d64dc2a0713 --- /dev/null +++ b/packages/adapter-cloudflare/test/apps/workers/src/routes/ws/+server.js @@ -0,0 +1,6 @@ +/** @type {import('./$types').Socket} */ +export const socket = { + open(peer) { + peer.send('connected'); + } +}; diff --git a/packages/adapter-cloudflare/test/apps/workers/test/test.js b/packages/adapter-cloudflare/test/apps/workers/test/test.js index aab7cbca568a..c32d4f63a294 100644 --- a/packages/adapter-cloudflare/test/apps/workers/test/test.js +++ b/packages/adapter-cloudflare/test/apps/workers/test/test.js @@ -4,3 +4,8 @@ test('worker works', async ({ page }) => { await page.goto('/'); await expect(page.locator('h1')).toContainText('Sum: 3'); }); + +test('WebSockets work', async ({ page }) => { + await page.goto('/ws'); + await expect(page.locator('p')).toContainText('connected'); +}); diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 9b0b3158ab82..9a841751e519 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -92,7 +92,12 @@ export default function (opts = {}) { }, supports: { - read: () => true + read: () => true, + webSockets: { + socket: () => true, + getPeers: () => true, + publish: () => true + } } }; } diff --git a/packages/adapter-node/internal.d.ts b/packages/adapter-node/internal.d.ts index fed0584d1851..7b7515109fa9 100644 --- a/packages/adapter-node/internal.d.ts +++ b/packages/adapter-node/internal.d.ts @@ -4,6 +4,9 @@ declare module 'ENV' { declare module 'HANDLER' { export const handler: import('polka').Middleware; + export const upgradeHandler: import('crossws/adapters/node').NodeAdapter['handleUpgrade']; + export const closeAllWebSockets: () => void; + export const terminateAllWebSockets: () => void; } declare module 'MANIFEST' { diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json index 7f5f72c60d02..79a976d4bfb9 100644 --- a/packages/adapter-node/package.json +++ b/packages/adapter-node/package.json @@ -35,17 +35,20 @@ "scripts": { "dev": "node -e \"fs.rmSync('files', { force: true, recursive: true })\" && rollup -cw", "build": "node -e \"fs.rmSync('files', { force: true, recursive: true })\" && rollup -c", - "test": "vitest run", "check": "tsc", "lint": "prettier --check .", "format": "pnpm lint --write", - "prepublishOnly": "pnpm build" + "prepublishOnly": "pnpm build", + "test": "pnpm test:unit && pnpm test:integration", + "test:unit": "vitest run", + "test:integration": "pnpm build && pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test" }, "devDependencies": { "@polka/url": "^1.0.0-next.28", "@sveltejs/kit": "workspace:^", "@sveltejs/vite-plugin-svelte": "^5.0.1", "@types/node": "^18.19.48", + "crossws": "^0.3.4", "polka": "^1.0.0-next.28", "sirv": "^3.0.0", "typescript": "^5.3.3", diff --git a/packages/adapter-node/tests/smoke.spec_disabled.js b/packages/adapter-node/smoke.spec_disabled.js similarity index 96% rename from packages/adapter-node/tests/smoke.spec_disabled.js rename to packages/adapter-node/smoke.spec_disabled.js index 58809bf6cb59..c4e104a4ab01 100644 --- a/packages/adapter-node/tests/smoke.spec_disabled.js +++ b/packages/adapter-node/smoke.spec_disabled.js @@ -1,6 +1,6 @@ import process from 'node:process'; import { assert, test } from 'vitest'; -import { create_kit_middleware } from '../src/handler.js'; +import { create_kit_middleware } from './src/handler.js'; import fetch from 'node-fetch'; import polka from 'polka'; diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index 37827e64042c..411497828955 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -2,6 +2,7 @@ import 'SHIMS'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import crossws from 'crossws/adapters/node'; import sirv from 'sirv'; import { fileURLToPath } from 'node:url'; import { parse as polka_url_parser } from '@polka/url'; @@ -34,9 +35,29 @@ const dir = path.dirname(fileURLToPath(import.meta.url)); const asset_dir = `${dir}/client${base}`; +/** @type {import('crossws').ResolveHooks} */ +let resolve_websocket_hooks; +/** @type {import('crossws/adapters/node').NodeAdapter} */ +let ws; + +if (server.resolveWebSocketHooks) { + ws = crossws({ + resolve: (req) => resolve_websocket_hooks(req), + serverOptions: { + // we need to disable the `ws` package's default behaviour of automatically + // returning the request's sec-websocket-protocol header in the response + // to avoid sending that header multiple times if the user also returns that header. + // TODO: we could remove this if https://github.com/unjs/crossws/pull/142 standardises this behaviour + handleProtocols: () => false + } + }); +} + await server.init({ env: process.env, - read: (file) => createReadableStream(`${asset_dir}/${file}`) + read: (file) => createReadableStream(`${asset_dir}/${file}`), + peers: ws?.peers, + publish: ws?.publish }); /** @@ -91,6 +112,55 @@ function serve_prerendered() { }; } +/** + * @param {import('node:http').IncomingMessage} req + */ +function get_options(req) { + return /** @satisfies {Parameters[1]} */ ({ + platform: { req }, + /** + * @returns {string} + */ + getClientAddress: () => { + if (address_header) { + if (!(address_header in req.headers)) { + throw new Error( + `Address header was specified with ${ENV_PREFIX + 'ADDRESS_HEADER'}=${address_header} but is absent from request` + ); + } + + const value = /** @type {string} */ (req.headers[address_header]) || ''; + + if (address_header === 'x-forwarded-for') { + const addresses = value.split(','); + + if (xff_depth < 1) { + throw new Error(`${ENV_PREFIX + 'XFF_DEPTH'} must be a positive integer`); + } + + if (xff_depth > addresses.length) { + throw new Error( + `${ENV_PREFIX + 'XFF_DEPTH'} is ${xff_depth}, but only found ${addresses.length} addresses` + ); + } + return addresses[addresses.length - xff_depth].trim(); + } + + return value; + } + + return ( + req.connection?.remoteAddress || + // @ts-expect-error + req.connection?.socket?.remoteAddress || + req.socket?.remoteAddress || + // @ts-expect-error + req.info?.remoteAddress + ); + } + }); +} + /** @type {import('polka').Middleware} */ const ssr = async (req, res) => { /** @type {Request} */ @@ -108,53 +178,7 @@ const ssr = async (req, res) => { return; } - await setResponse( - res, - await server.respond(request, { - platform: { req }, - getClientAddress: () => { - if (address_header) { - if (!(address_header in req.headers)) { - throw new Error( - `Address header was specified with ${ - ENV_PREFIX + 'ADDRESS_HEADER' - }=${address_header} but is absent from request` - ); - } - - const value = /** @type {string} */ (req.headers[address_header]) || ''; - - if (address_header === 'x-forwarded-for') { - const addresses = value.split(','); - - if (xff_depth < 1) { - throw new Error(`${ENV_PREFIX + 'XFF_DEPTH'} must be a positive integer`); - } - - if (xff_depth > addresses.length) { - throw new Error( - `${ENV_PREFIX + 'XFF_DEPTH'} is ${xff_depth}, but only found ${ - addresses.length - } addresses` - ); - } - return addresses[addresses.length - xff_depth].trim(); - } - - return value; - } - - return ( - req.connection?.remoteAddress || - // @ts-expect-error - req.connection?.socket?.remoteAddress || - req.socket?.remoteAddress || - // @ts-expect-error - req.info?.remoteAddress - ); - } - }) - ); + await setResponse(res, await server.respond(request, get_options(req))); }; /** @param {import('polka').Middleware[]} handlers */ @@ -200,3 +224,56 @@ export const handler = sequence( ssr ].filter(Boolean) ); + +/** + * @param {import('node:http').IncomingMessage} req + * @param {import('node:stream').Duplex} socket + * @param {Buffer} head + */ +export async function upgradeHandler(req, socket, head) { + if (req.headers.upgrade === 'websocket' && ws) { + /** @type {Request} */ + let request; + + // the crossws Node adapter doesn't actually pass a Request object, so we need to create one + // see https://github.com/unjs/crossws/issues/137 + try { + request = await getRequest({ + base: origin || get_origin(req.headers), + request: req, + bodySizeLimit: body_size_limit + }); + } catch { + socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); + socket.end(); + return; + } + + const hooks = await server.resolveWebSocketHooks(request, get_options(req)); + resolve_websocket_hooks = () => hooks; + + // eslint-disable-next-line @typescript-eslint/await-thenable -- this function call is awaitable but the crossws type fix hasn't been released yet + await ws.handleUpgrade(req, socket, head); + // TODO: remove this block once https://github.com/unjs/crossws/pull/140 is merged + if (socket.writableFinished) { + socket.destroy(); + } else { + socket.once('finish', socket.destroy); + } + } +} + +export function closeAllWebSockets() { + if (ws) { + ws.closeAll(); + } +} + +export function terminateAllWebSockets() { + if (ws) { + // TODO: replace this once https://github.com/unjs/crossws/issues/145 is resolved + ws.peers.forEach((peer) => { + peer.terminate(); + }); + } +} diff --git a/packages/adapter-node/src/index.js b/packages/adapter-node/src/index.js index ef1ab701a2a3..41fbcf19a90f 100644 --- a/packages/adapter-node/src/index.js +++ b/packages/adapter-node/src/index.js @@ -1,5 +1,5 @@ import process from 'node:process'; -import { handler } from 'HANDLER'; +import { handler, upgradeHandler, closeAllWebSockets, terminateAllWebSockets } from 'HANDLER'; import { env } from 'ENV'; import polka from 'polka'; @@ -43,6 +43,9 @@ if (socket_activation) { }); } +// Register the upgrade handler after the listen call, when the internal server is available +server.server.on('upgrade', upgradeHandler); + /** @param {'SIGINT' | 'SIGTERM' | 'IDLE'} reason */ function graceful_shutdown(reason) { if (shutdown_timeout_id) return; @@ -67,11 +70,13 @@ function graceful_shutdown(reason) { process.emit('sveltekit:shutdown', reason); }); - shutdown_timeout_id = setTimeout( + closeAllWebSockets(); + + shutdown_timeout_id = setTimeout(() => { // @ts-expect-error this was added in 18.2.0 but is not reflected in the types - () => server.server.closeAllConnections(), - shutdown_timeout * 1000 - ); + server.server.closeAllConnections(); + terminateAllWebSockets(); + }, shutdown_timeout * 1000); } server.server.on( diff --git a/packages/adapter-node/test/apps/basic/.gitignore b/packages/adapter-node/test/apps/basic/.gitignore new file mode 100644 index 000000000000..de1a7689bb6b --- /dev/null +++ b/packages/adapter-node/test/apps/basic/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +node_modules +/.svelte-kit +/build \ No newline at end of file diff --git a/packages/adapter-node/test/apps/basic/package.json b/packages/adapter-node/test/apps/basic/package.json new file mode 100644 index 000000000000..28da8b15eb3f --- /dev/null +++ b/packages/adapter-node/test/apps/basic/package.json @@ -0,0 +1,19 @@ +{ + "name": "test-node", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "ORIGIN=http://localhost:3000 node build", + "prepare": "svelte-kit sync || echo ''", + "test": "playwright test" + }, + "devDependencies": { + "@sveltejs/kit": "workspace:^", + "@sveltejs/vite-plugin-svelte": "^5.0.1", + "svelte": "^5.23.1", + "vite": "^6.0.11" + }, + "type": "module" +} diff --git a/packages/adapter-node/test/apps/basic/playwright.config.js b/packages/adapter-node/test/apps/basic/playwright.config.js new file mode 100644 index 000000000000..33d36b651014 --- /dev/null +++ b/packages/adapter-node/test/apps/basic/playwright.config.js @@ -0,0 +1 @@ +export { config as default } from '../../utils.js'; diff --git a/packages/adapter-node/test/apps/basic/src/app.html b/packages/adapter-node/test/apps/basic/src/app.html new file mode 100644 index 000000000000..d533c5e31716 --- /dev/null +++ b/packages/adapter-node/test/apps/basic/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/adapter-node/test/apps/basic/src/routes/ws/+page.svelte b/packages/adapter-node/test/apps/basic/src/routes/ws/+page.svelte new file mode 100644 index 000000000000..3b490a863ce2 --- /dev/null +++ b/packages/adapter-node/test/apps/basic/src/routes/ws/+page.svelte @@ -0,0 +1,15 @@ + + +

{message}

diff --git a/packages/adapter-node/test/apps/basic/src/routes/ws/+server.js b/packages/adapter-node/test/apps/basic/src/routes/ws/+server.js new file mode 100644 index 000000000000..0d64dc2a0713 --- /dev/null +++ b/packages/adapter-node/test/apps/basic/src/routes/ws/+server.js @@ -0,0 +1,6 @@ +/** @type {import('./$types').Socket} */ +export const socket = { + open(peer) { + peer.send('connected'); + } +}; diff --git a/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.server.js b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.server.js new file mode 100644 index 000000000000..7df4ecb6cbaf --- /dev/null +++ b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.server.js @@ -0,0 +1,13 @@ +import { publish, getPeers } from '$app/server'; + +export const actions = { + publish: async () => { + publish('users', 'created a new user'); + }, + peers: async () => { + const peers = getPeers(); + peers.forEach((peer) => { + peer.send('sent to each peer'); + }); + } +}; diff --git a/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.svelte b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.svelte new file mode 100644 index 000000000000..6ec87ca0d71f --- /dev/null +++ b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+page.svelte @@ -0,0 +1,33 @@ + + +
    + {#each messages as message} +
  • {message}
  • + {/each} +
+ +
+ +
+ +
+ +
diff --git a/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+server.js b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+server.js new file mode 100644 index 000000000000..92e26abafe0b --- /dev/null +++ b/packages/adapter-node/test/apps/basic/src/routes/ws/helpers/+server.js @@ -0,0 +1,6 @@ +/** @type {import('./$types').Socket} */ +export const socket = { + open(peer) { + peer.subscribe('users'); + } +}; diff --git a/packages/adapter-node/test/apps/basic/svelte.config.js b/packages/adapter-node/test/apps/basic/svelte.config.js new file mode 100644 index 000000000000..20cd2b3ff5b8 --- /dev/null +++ b/packages/adapter-node/test/apps/basic/svelte.config.js @@ -0,0 +1,10 @@ +import adapter from '../../../index.js'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/packages/adapter-node/test/apps/basic/test/test.js b/packages/adapter-node/test/apps/basic/test/test.js new file mode 100644 index 000000000000..682cf4686e1b --- /dev/null +++ b/packages/adapter-node/test/apps/basic/test/test.js @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; + +test('WebSockets work', async ({ page }) => { + await page.goto('/ws'); + await expect(page.locator('p')).toContainText('connected'); +}); + +test('WebSockets getPeers helper', async ({ page }) => { + await page.goto('/ws/helpers'); + await expect(page.getByText('connected')).toBeVisible(); + await page.locator('button', { hasText: 'message all peers' }).click(); + await expect(page.getByText('sent to each peer')).toBeVisible(); +}); + +test('WebSockets publish helper', async ({ page }) => { + await page.goto('/ws/helpers'); + await expect(page.getByText('connected')).toBeVisible(); + await page.locator('button', { hasText: 'create user' }).click(); + await expect(page.getByText('created a new user')).toBeVisible(); +}); diff --git a/packages/adapter-node/test/apps/basic/tsconfig.json b/packages/adapter-node/test/apps/basic/tsconfig.json new file mode 100644 index 000000000000..34380ebc986e --- /dev/null +++ b/packages/adapter-node/test/apps/basic/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + }, + "extends": "./.svelte-kit/tsconfig.json" +} diff --git a/packages/adapter-node/test/apps/basic/vite.config.js b/packages/adapter-node/test/apps/basic/vite.config.js new file mode 100644 index 000000000000..29ad08debe6a --- /dev/null +++ b/packages/adapter-node/test/apps/basic/vite.config.js @@ -0,0 +1,11 @@ +import { sveltekit } from '@sveltejs/kit/vite'; + +/** @type {import('vite').UserConfig} */ +const config = { + build: { + minify: false + }, + plugins: [sveltekit()] +}; + +export default config; diff --git a/packages/adapter-node/test/utils.js b/packages/adapter-node/test/utils.js new file mode 100644 index 000000000000..b4bdc92a77d4 --- /dev/null +++ b/packages/adapter-node/test/utils.js @@ -0,0 +1,28 @@ +import { devices } from '@playwright/test'; +import process from 'node:process'; + +/** @type {import('@playwright/test').PlaywrightTestConfig} */ +export const config = { + forbidOnly: !!process.env.CI, + // generous timeouts on CI + timeout: process.env.CI ? 45000 : 15000, + webServer: { + command: 'pnpm build && pnpm preview', + port: 3000 + }, + retries: process.env.CI ? 2 : 0, + projects: [ + { + name: 'chromium' + } + ], + use: { + ...devices['Desktop Chrome'], + screenshot: 'only-on-failure', + trace: 'retain-on-failure' + }, + workers: process.env.CI ? 2 : undefined, + reporter: 'list', + testDir: 'test', + testMatch: /(.+\.)?(test|spec)\.[jt]s/ +}; diff --git a/packages/adapter-node/tsconfig.json b/packages/adapter-node/tsconfig.json index 895d76f908cf..8658c9b8b344 100644 --- a/packages/adapter-node/tsconfig.json +++ b/packages/adapter-node/tsconfig.json @@ -13,6 +13,12 @@ "@sveltejs/kit": ["../kit/types/index"] } }, - "include": ["index.js", "src/**/*.js", "tests/**/*.js", "internal.d.ts", "utils.js"], - "exclude": ["tests/smoke.spec_disabled.js"] + "include": [ + "index.js", + "src/**/*.js", + "internal.d.ts", + "utils.js", + "utils.spec.js", + "test/utils.js" + ] } diff --git a/packages/adapter-node/tests/utils.spec.js b/packages/adapter-node/utils.spec.js similarity index 91% rename from packages/adapter-node/tests/utils.spec.js rename to packages/adapter-node/utils.spec.js index 06a495fde9ba..3f359ccaaba0 100644 --- a/packages/adapter-node/tests/utils.spec.js +++ b/packages/adapter-node/utils.spec.js @@ -1,5 +1,5 @@ import { expect, test, describe } from 'vitest'; -import { parse_as_bytes } from '../utils.js'; +import { parse_as_bytes } from './utils.js'; describe('parse_as_bytes', () => { test('parses correctly', () => { diff --git a/packages/kit/package.json b/packages/kit/package.json index 2273ec079873..41f4296aa3d6 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -20,6 +20,7 @@ "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", + "crossws": "^0.3.4", "devalue": "^5.1.0", "esm-env": "^1.2.2", "import-meta-resolve": "^4.1.0", diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index 25bd403f1eb8..186bc25b091e 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -57,6 +57,8 @@ async function analyse({ internal.set_safe_public_env(public_env); internal.set_manifest(manifest); internal.set_read_implementation((file) => createReadableStream(`${server_root}/server/${file}`)); + internal.set_peers(new Set()); + internal.set_publish_implementation(() => {}); /** @type {import('types').ServerMetadata} */ const metadata = { @@ -96,6 +98,14 @@ async function analyse({ const endpoint = route.endpoint && analyse_endpoint(route, await route.endpoint()); + // we need to perform this check ourselves because `list_features` only includes + // chunks that have imported a feature, but using WebSockets doesn't involve any imports + if (endpoint?.socket && !config.adapter?.supports?.webSockets?.socket()) { + throw new Error( + `Cannot export \`socket\` in ${route.id} when using ${config.adapter?.name}. Please ensure that your adapter is up to date and supports this feature.` + ); + } + if (page?.prerender && endpoint?.prerender) { throw new Error(`Cannot prerender a route with both +page and +server files (${route.id})`); } @@ -153,9 +163,9 @@ async function analyse({ function analyse_endpoint(route, mod) { validate_server_exports(mod, route.id); - if (mod.prerender && (mod.POST || mod.PATCH || mod.PUT || mod.DELETE)) { + if (mod.prerender && (mod.POST || mod.PATCH || mod.PUT || mod.DELETE || mod.socket)) { throw new Error( - `Cannot prerender a +server file with POST, PATCH, PUT, or DELETE (${route.id})` + `Cannot prerender a +server file with POST, PATCH, PUT, DELETE, or socket (${route.id})` ); } @@ -174,6 +184,7 @@ function analyse_endpoint(route, mod) { config: mod.config, entries: mod.entries, methods, + socket: !!mod.socket, prerender: mod.prerender ?? false }; } diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 5e93d5c1cd25..cf72b01e2ac3 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -31,7 +31,7 @@ const server_template = ({ import root from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}'; import { set_building, set_prerendering } from '__sveltekit/environment'; import { set_assets } from '__sveltekit/paths'; -import { set_manifest, set_read_implementation } from '__sveltekit/server'; +import { set_manifest, set_read_implementation, set_peers, set_publish_implementation } from '__sveltekit/server'; import { set_private_env, set_public_env, set_safe_public_env } from '${runtime_directory}/shared-server.js'; export const options = { @@ -84,7 +84,18 @@ export async function get_hooks() { }; } -export { set_assets, set_building, set_manifest, set_prerendering, set_private_env, set_public_env, set_read_implementation, set_safe_public_env }; +export { + set_assets, + set_building, + set_manifest, + set_peers, + set_prerendering, + set_private_env, + set_public_env, + set_publish_implementation, + set_read_implementation, + set_safe_public_env +}; `; // TODO need to re-run this whenever src/app.html or src/error.html are diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js index 308d566606f8..08970b0df3db 100644 --- a/packages/kit/src/core/sync/write_types/index.js +++ b/packages/kit/src/core/sync/write_types/index.js @@ -347,6 +347,9 @@ function update_types(config, routes, route, to_delete = new Set()) { if (route.endpoint) { exports.push('export type RequestHandler = Kit.RequestHandler;'); + exports.push('export type Socket = Kit.Socket;'); + exports.push('export type Peer = Kit.Peer;'); + exports.push('export type Message = Kit.Message;'); } if (route.leaf?.server || route.layout?.server || route.endpoint) { diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 684f18981610..4ec7752bf304 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -44,6 +44,20 @@ export interface Adapter { * @param details.config The merged route config */ read?: (details: { config: any; route: { id: string } }) => boolean; + webSockets?: { + /** + * Test support for the `socket` export from a `+server.js` file. + */ + socket: () => boolean; + /** + * Test support for `getPeers` from `$app/server`. + */ + getPeers: (details: { route: { id: string } }) => boolean; + /** + * Test support for `publish` from `$app/server`. + */ + publish: (details: { route: { id: string } }) => boolean; + }; }; /** * Creates an `Emulator`, which allows the adapter to influence the environment @@ -1304,6 +1318,10 @@ export class Server { constructor(manifest: SSRManifest); init(options: ServerInitOptions): Promise; respond(request: Request, options: RequestOptions): Promise; + resolveWebSocketHooks( + request: Request, + options: RequestOptions + ): Promise>; } export interface ServerInitOptions { @@ -1311,6 +1329,10 @@ export interface ServerInitOptions { env: Record; /** A function that turns an asset filename into a `ReadableStream`. Required for the `read` export from `$app/server` to work. */ read?: (file: string) => ReadableStream; + /** A `Set` of WebSocket `Peer` instances. Required for the `getPeers` export from `$app/server` to work. */ + peers?: import('crossws').AdapterInstance['peers']; + /** A function that publishes a message to WebSocket subscribers of a topic. Required for the `publish` export from `$app/server` to work. */ + publish?: import('crossws').AdapterInstance['publish']; } export interface SSRManifest { @@ -1494,7 +1516,82 @@ export type SubmitFunction< >; /** - * The type of `export const snapshot` exported from a page or layout component. + * Shape of the `export const socket = {...}` object in `+server.js`. + * See [WebSockets](https://svelte.dev/docs/kit/websockets) for more information. + * @since 2.21.0 + */ +export interface Socket< + Params extends Partial> = Partial>, + RouteId extends string | null = string | null +> { + /** + * The [upgrade](https://svelte.dev/docs/kit/websockets#Hooks-upgrade) hook runs + * every time a request is attempting to upgrade to a WebSocket connection. + */ + upgrade?: ( + event: RequestEvent & { context: import('crossws').Peer['context'] } + ) => MaybePromise; + /** + * The [open](https://svelte.dev/docs/kit/websockets#Hooks-open) hook runs + * every time a WebSocket connection is opened. + */ + open?: (peer: Peer) => MaybePromise; + /** + * The [message](https://svelte.dev/docs/kit/websockets#Hooks-message) hook + * runs every time a message is received from a WebSocket client. + */ + message?: (peer: Peer, message: Message) => MaybePromise; + /** + * The [close](https://svelte.dev/docs/kit/websockets#Hooks-close) hook runs + * every time a WebSocket connection is closed. + */ + close?: ( + peer: Peer, + details: { code?: number; reason?: string } + ) => MaybePromise; + /** + * The [error](https://svelte.dev/docs/kit/websockets#Hooks-error) hook runs + * every time a WebSocket error occurs. + */ + error?: (peer: Peer, error: import('crossws').WSError) => MaybePromise; +} + +/** + * When a new [WebSocket](https://svelte.dev/docs/kit/websockets) client connects + * to the server, `crossws` creates a [`Peer`](https://crossws.unjs.io/guide/peer) + * object that allows interacting with the connected client. + * @since 2.21.0 + */ +export type Peer< + Params extends Partial> = Partial>, + RouteId extends string | null = string | null +> = import('crossws').Peer & { + /** The original request object before upgrading to a WebSocket connection. */ + request: Request; + /** Represents the initial request before upgrading to a WebSocket connection. */ + event: RequestEvent; +}; + +/** + * During a WebSocket [`message`](https://svelte.dev/docs/kit/websockets#Hooks-message) + * hook, you'll receive a [`Message`](https://crossws.unjs.io/guide/message) + * object containing data from the client. + * @since 2.21.0 + */ +export type Message< + Params extends Partial> = Partial>, + RouteId extends string | null = string | null +> = import('crossws').Message & { + /** Access to the `Peer` that emitted the message. */ + peer: Peer; +}; + +/** + * Shape of the `export const snapshot = {...}` object in a page or layout component. + * You should import these from `./$types` (see [generated types](https://svelte.dev/docs/kit/types#Generated-types)) + * rather than using `Snapshot` directly. + * See [snapshots](https://svelte.dev/docs/kit/snapshots) for more information. + * @since 1.5.0 */ export interface Snapshot { capture: () => T; diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index ded4685f527c..b96b69e0e34e 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -3,6 +3,7 @@ import path from 'node:path'; import process from 'node:process'; import { URL } from 'node:url'; import { AsyncLocalStorage } from 'node:async_hooks'; +import crossws from 'crossws/adapters/node'; import colors from 'kleur'; import sirv from 'sirv'; import { isCSSRequest, loadEnv, buildErrorMessage } from 'vite'; @@ -418,6 +419,72 @@ export async function dev(vite, vite_config, svelte_config) { const env = loadEnv(vite_config.mode, svelte_config.kit.env.dir, ''); const emulator = await svelte_config.kit.adapter?.emulate?.(); + /** + * @param {import('node:http').IncomingMessage} req + */ + function get_base(req) { + return `${vite.config.server.https ? 'https' : 'http'}://${ + req.headers[':authority'] || req.headers.host + }`; + } + + async function init_server() { + // we have to import `Server` before calling `set_assets` + const { Server } = /** @type {import('types').ServerModule} */ ( + await vite.ssrLoadModule(`${runtime_base}/server/index.js`, { fixStacktrace: true }) + ); + + const { set_fix_stack_trace } = await vite.ssrLoadModule(`${runtime_base}/shared-server.js`); + set_fix_stack_trace(fix_stack_trace); + + const { set_assets } = await vite.ssrLoadModule('__sveltekit/paths'); + set_assets(assets); + + const server = new Server(manifest); + + await server.init({ + env, + read: (file) => createReadableStream(from_fs(file)), + peers: ws.peers, + publish: ws.publish + }); + + return server; + } + + /** + * @param {string} file + */ + function read(file) { + if (file in manifest._.server_assets) { + return fs.readFileSync(from_fs(file)); + } + + return fs.readFileSync(path.join(svelte_config.kit.files.assets, file)); + } + + /** + * @param {import('@sveltejs/kit').RequestEvent} event + * @param {any} config + * @param {import('types').PrerenderOption} prerender + */ + function before_handle(event, config, prerender) { + async_local_storage.enterWith({ event, config, prerender }); + } + + /** @type {import('crossws').ResolveHooks} */ + let resolve_websocket_hooks; + const ws = crossws({ + resolve: (req) => resolve_websocket_hooks(req), + serverOptions: { + // we need to disable the `ws` package's default behaviour of automatically + // returning the request's sec-websocket-protocol header in the response + // to avoid sending that header multiple times if the user also returns that header. + // TODO: we could remove this if https://github.com/unjs/crossws/pull/142 standardises this behaviour + handleProtocols: () => false + } + }); + return () => { const serve_static_middleware = vite.middlewares.stack.find( (middleware) => @@ -428,14 +495,79 @@ export async function dev(vite, vite_config, svelte_config) { // serving routes with those names. See https://github.com/vitejs/vite/issues/7363 remove_static_middlewares(vite.middlewares); + vite.httpServer?.on( + 'upgrade', + /** + * @param {import('node:http').IncomingMessage} req + * @param {import('node:stream').Duplex} socket + * @param {Buffer} head + */ + async (req, socket, head) => { + if ( + req.headers['sec-websocket-protocol'] !== 'vite-hmr' && + req.headers.upgrade === 'websocket' + ) { + try { + const base = get_base(req); + const decoded = decodeURI(new URL(base + req.url).pathname); + + if (!decoded.startsWith(svelte_config.kit.paths.base)) { + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + socket.end( + `The server is configured with a public base URL of ${escape_html( + svelte_config.kit.paths.base + )} - did you mean to visit ${escape_html(svelte_config.kit.paths.base + req.url)} instead?` + ); + return; + } + + const server = await init_server(); + + if (manifest_error) { + console.error(colors.bold().red(manifest_error.message)); + socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n'); + socket.end(manifest_error.message ?? 'Invalid routes'); + return; + } + + // the crossws Node adapter doesn't actually pass a Request object, so we need to create one + // see https://github.com/unjs/crossws/issues/137 + const request = await getRequest({ + base, + request: req + }); + + const hooks = await server.resolveWebSocketHooks(request, { + getClientAddress: get_client_address(req), + read, + before_handle, + emulator + }); + resolve_websocket_hooks = () => hooks; + + // eslint-disable-next-line @typescript-eslint/await-thenable -- this function call is awaitable but the crossws type fix hasn't been released yet + await ws.handleUpgrade(req, socket, head); + // TODO: remove this block once https://github.com/unjs/crossws/pull/140 is merged + if (socket.writableFinished) { + socket.destroy(); + } else { + socket.once('finish', socket.destroy); + } + } catch (e) { + const error = coalesce_to_error(e); + socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n'); + socket.end(fix_stack_trace(error)); + } + } + } + ); + vite.middlewares.use(async (req, res) => { // Vite's base middleware strips out the base path. Restore it const original_url = req.url; req.url = req.originalUrl; try { - const base = `${vite.config.server.https ? 'https' : 'http'}://${ - req.headers[':authority'] || req.headers.host - }`; + const base = get_base(req); const decoded = decodeURI(new URL(base + req.url).pathname); const file = posixify(path.resolve(decoded.slice(svelte_config.kit.paths.base.length + 1))); @@ -471,25 +603,7 @@ export async function dev(vite, vite_config, svelte_config) { return; } - // we have to import `Server` before calling `set_assets` - const { Server } = /** @type {import('types').ServerModule} */ ( - await vite.ssrLoadModule(`${runtime_base}/server/index.js`, { fixStacktrace: true }) - ); - - const { set_fix_stack_trace } = await vite.ssrLoadModule( - `${runtime_base}/shared-server.js` - ); - set_fix_stack_trace(fix_stack_trace); - - const { set_assets } = await vite.ssrLoadModule('__sveltekit/paths'); - set_assets(assets); - - const server = new Server(manifest); - - await server.init({ - env, - read: (file) => createReadableStream(from_fs(file)) - }); + const server = await init_server(); const request = await getRequest({ base, @@ -519,21 +633,9 @@ export async function dev(vite, vite_config, svelte_config) { } const rendered = await server.respond(request, { - getClientAddress: () => { - const { remoteAddress } = req.socket; - if (remoteAddress) return remoteAddress; - throw new Error('Could not determine clientAddress'); - }, - read: (file) => { - if (file in manifest._.server_assets) { - return fs.readFileSync(from_fs(file)); - } - - return fs.readFileSync(path.join(svelte_config.kit.files.assets, file)); - }, - before_handle: (event, config, prerender) => { - async_local_storage.enterWith({ event, config, prerender }); - }, + getClientAddress: get_client_address(req), + read, + before_handle, emulator }); @@ -630,3 +732,14 @@ function has_correct_case(file, assets) { return false; } + +/** + * @param {import('node:http').IncomingMessage} req + */ +function get_client_address(req) { + return () => { + const { remoteAddress } = req.socket; + if (remoteAddress) return remoteAddress; + throw new Error('Could not determine clientAddress'); + }; +} diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index c2e445e865d2..157c76b3ca21 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -536,6 +536,10 @@ Tips: export let manifest = null; + export let peers = null; + + export let publish_implementation = null; + export function set_read_implementation(fn) { read_implementation = fn; } @@ -543,6 +547,14 @@ Tips: export function set_manifest(_) { manifest = _; } + + export function set_peers(_) { + peers = _; + } + + export function set_publish_implementation(fn) { + publish_implementation = fn; + } `; } } diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index efff12bbd3b7..bb67397cbe3a 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import crossws from 'crossws/adapters/node'; import { lookup } from 'mrmime'; import sirv from 'sirv'; import { loadEnv, normalizePath } from 'vite'; @@ -45,14 +46,48 @@ export async function preview(vite, vite_config, svelte_config) { set_assets(assets); + /** @type {import('crossws').ResolveHooks} */ + let resolve_websocket_hooks; + const ws = crossws({ + resolve: (req) => resolve_websocket_hooks(req), + serverOptions: { + // we need to disable the `ws` package's default behaviour of automatically + // returning the request's sec-websocket-protocol header in the response + // to avoid sending that header multiple times if the user also returns that header. + // TODO: we could remove this if https://github.com/unjs/crossws/pull/142 standardises this behaviour + handleProtocols: () => false + } + }); + const server = new Server(manifest); await server.init({ env: loadEnv(vite_config.mode, svelte_config.kit.env.dir, ''), - read: (file) => createReadableStream(`${dir}/${file}`) + read: (file) => createReadableStream(`${dir}/${file}`), + peers: ws.peers, + publish: ws.publish }); const emulator = await svelte_config.kit.adapter?.emulate?.(); + /** + * @param {import('node:http').IncomingMessage} req + */ + function get_base(req) { + const host = req.headers[':authority'] || req.headers.host; + return `${protocol}://${host}`; + } + + /** + * @param {string} file + */ + function read(file) { + if (file in manifest._.server_assets) { + return fs.readFileSync(join(dir, file)); + } + + return fs.readFileSync(join(svelte_config.kit.files.assets, file)); + } + return () => { // Remove the base middleware. It screws with the URL. // It also only lets through requests beginning with the base path, so that requests beginning @@ -183,30 +218,52 @@ export async function preview(vite, vite_config, svelte_config) { }) ); + vite.httpServer.on( + 'upgrade', + /** + * @param {import('node:http').IncomingMessage} req + * @param {import('node:stream').Duplex} socket + * @param {Buffer} head + */ + async (req, socket, head) => { + if (req.headers.upgrade === 'websocket') { + const request = await getRequest({ + base: get_base(req), + request: req + }); + + const hooks = await server.resolveWebSocketHooks(request, { + getClientAddress: get_client_address(req), + read, + emulator + }); + + resolve_websocket_hooks = () => hooks; + + // eslint-disable-next-line @typescript-eslint/await-thenable -- this function call is awaitable but the crossws type fix hasn't been released yet + await ws.handleUpgrade(req, socket, head); + // TODO: remove this block once https://github.com/unjs/crossws/pull/140 is merged + if (socket.writableFinished) { + socket.destroy(); + } else { + socket.once('finish', socket.destroy); + } + } + } + ); + // SSR vite.middlewares.use(async (req, res) => { - const host = req.headers[':authority'] || req.headers.host; - const request = await getRequest({ - base: `${protocol}://${host}`, + base: get_base(req), request: req }); await setResponse( res, await server.respond(request, { - getClientAddress: () => { - const { remoteAddress } = req.socket; - if (remoteAddress) return remoteAddress; - throw new Error('Could not determine clientAddress'); - }, - read: (file) => { - if (file in manifest._.server_assets) { - return fs.readFileSync(join(dir, file)); - } - - return fs.readFileSync(join(svelte_config.kit.files.assets, file)); - }, + getClientAddress: get_client_address(req), + read, emulator }) ); @@ -252,3 +309,14 @@ function scoped(scope, handler) { function is_file(path) { return fs.existsSync(path) && !fs.statSync(path).isDirectory(); } + +/** + * @param {import('node:http').IncomingMessage} req + */ +const get_client_address = (req) => { + return () => { + const { remoteAddress } = req.socket; + if (remoteAddress) return remoteAddress; + throw new Error('Could not determine clientAddress'); + }; +}; diff --git a/packages/kit/src/runtime/app/server/index.js b/packages/kit/src/runtime/app/server/index.js index 19c384932107..82bcf48dc95e 100644 --- a/packages/kit/src/runtime/app/server/index.js +++ b/packages/kit/src/runtime/app/server/index.js @@ -1,10 +1,10 @@ -import { read_implementation, manifest } from '__sveltekit/server'; +import { read_implementation, peers, publish_implementation, manifest } from '__sveltekit/server'; import { base } from '__sveltekit/paths'; import { DEV } from 'esm-env'; import { b64_decode } from '../../utils.js'; /** - * Read the contents of an imported asset from the filesystem + * Read the contents of an imported asset from the filesystem. * @example * ```js * import { read } from '$app/server'; @@ -72,4 +72,58 @@ export function read(asset) { throw new Error(`Asset does not exist: ${file}`); } +/** + * Returns a set of connected WebSocket peers. + * See [Peer](https://crossws.unjs.io/guide/peer) for more information. + * @example + * ```js + * import { getPeers } from '$app/server'; + * + * const peers = getPeers(); + * peers.forEach((peer) => { + * // ... + * }); + * ``` + * @returns {import('crossws').AdapterInstance['peers']} + * @since 2.21.0 + */ +export function getPeers() { + __SVELTEKIT_TRACK__('$app/server:getPeers'); + + if (!peers) { + throw new Error( + 'No `peers` reference was provided. Please ensure that your adapter is up to date and supports this feature' + ); + } + + return peers; +} + +/** + * Send a message to WebSocket peer subscribers of a given topic. + * See [Pub / Sub](https://crossws.unjs.io/guide/pubsub) for more information. + * @example + * ```js + * import { publish } from '$app/server'; + * + * publish('chat', { message: 'Hello, world!' }); + * ``` + * @param {string} topic + * @param {unknown} data + * @param {{ compress?: boolean }=} options + * @returns {void} + * @since 2.21.0 + */ +export function publish(topic, data, options) { + __SVELTEKIT_TRACK__('$app/server:publish'); + + if (!publish_implementation) { + throw new Error( + 'No `publish` implementation was provided. Please ensure that your adapter is up to date and supports this feature' + ); + } + + publish_implementation(topic, data, options); +} + export { getRequestEvent } from './event.js'; diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index d480d344036b..ba0fdffcb566 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -1,3 +1,4 @@ +import { DEV } from 'esm-env'; import { ENDPOINT_METHODS, PAGE_METHODS } from '../../constants.js'; import { negotiate } from '../../utils/http.js'; import { with_event } from '../app/server/event.js'; @@ -11,8 +12,24 @@ import { method_not_allowed } from './utils.js'; * @returns {Promise} */ export async function render_endpoint(event, mod, state) { + if (DEV && mod.socket) { + __SVELTEKIT_TRACK__('websockets'); + } + const method = /** @type {import('types').HttpMethod} */ (event.request.method); + // if we've ended up here, it means the request does not have both the + // `Upgrade: websocket` and the `Connect: upgrade` headers + if (method === 'GET' && !mod.GET && mod.socket) { + return new Response('This service requires use of the websocket protocol.', { + status: 426, + headers: { + upgrade: 'websocket', + connect: 'Upgrade' + } + }); + } + let handler = mod[method] || mod.fallback; if (method === 'HEAD' && !mod.HEAD && mod.GET) { diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index a2740a8e6aa4..ac618bfb12bc 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -1,10 +1,15 @@ -import { respond } from './respond.js'; +import { respond, resolve_websocket_hooks } from './respond.js'; import { set_private_env, set_public_env, set_safe_public_env } from '../shared-server.js'; import { options, get_hooks } from '__SERVER__/internal.js'; import { DEV } from 'esm-env'; import { filter_private_env, filter_public_env } from '../../utils/env.js'; import { prerendering } from '__sveltekit/environment'; -import { set_read_implementation, set_manifest } from '__sveltekit/server'; +import { + set_read_implementation, + set_manifest, + set_peers, + set_publish_implementation +} from '__sveltekit/server'; /** @type {ProxyHandler<{ type: 'public' | 'private' }>} */ const prerender_env_handler = { @@ -35,12 +40,9 @@ export class Server { } /** - * @param {{ - * env: Record; - * read?: (file: string) => ReadableStream; - * }} opts + * @param {import('@sveltejs/kit').ServerInitOptions} opts */ - async init({ env, read }) { + async init({ env, read, peers, publish }) { // Take care: Some adapters may have to call `Server.init` per-request to set env vars, // so anything that shouldn't be rerun should be wrapped in an `if` block to make sure it hasn't // been done already. @@ -66,6 +68,14 @@ export class Server { set_read_implementation(read); } + if (peers) { + set_peers(peers); + } + + if (publish) { + set_publish_implementation(publish); + } + // During DEV and for some adapters this function might be called in quick succession, // so we need to make sure we're not invoking this logic (most notably the init hook) multiple times await (init_promise ??= (async () => { @@ -112,4 +122,16 @@ export class Server { depth: 0 }); } + + /** + * @param {Request} request + * @param {import('types').RequestOptions} options + */ + resolveWebSocketHooks(request, options) { + return resolve_websocket_hooks(request, this.#options, this.#manifest, { + ...options, + error: false, + depth: 0 + }); + } } diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 81b30e0756a5..8e0b4799cc17 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -59,9 +59,75 @@ const allowed_page_methods = new Set(['GET', 'HEAD', 'OPTIONS']); * @returns {Promise} */ export async function respond(request, options, manifest, state) { + return handle_request(request, options, manifest, state); +} + +/** + * @param {Request} request + * @param {import('types').SSROptions} options + * @param {import('@sveltejs/kit').SSRManifest} manifest + * @param {import('types').SSRState} state + * @returns {Promise} + */ +export async function resolve_websocket_hooks(request, options, manifest, state) { + const result = await handle_request(request, options, manifest, state, true); + + if (result instanceof Response) { + // if the result is a Response instead of WebSocket hooks, it means + // we should reject the upgrade + return { + upgrade: () => { + // we have to throw the Response to reject the upgrade + throw result; + } + }; + } + + return result; +} + +// we need the type overload so that TypeScript knows the return value +// can only be a Response if the upgrade param was omitted +/** + * @overload + * @param {Request} request + * @param {import('types').SSROptions} options + * @param {import('@sveltejs/kit').SSRManifest} manifest + * @param {import('types').SSRState} state + * @returns {Promise} + */ +/** + * @overload + * @param {Request} request + * @param {import('types').SSROptions} options + * @param {import('@sveltejs/kit').SSRManifest} manifest + * @param {import('types').SSRState} state + * @param {boolean} upgrade + * @returns {Promise>} + */ +/** + * @param {Request} request + * @param {import('types').SSROptions} options + * @param {import('@sveltejs/kit').SSRManifest} manifest + * @param {import('types').SSRState} state + * @param {boolean=} upgrade + * @returns {Promise>} + */ +async function handle_request(request, options, manifest, state, upgrade) { /** URL but stripped from the potential `/__data.json` suffix and its search param */ const url = new URL(request.url); + /** + * @param {HttpError} error + * @returns {Response} + */ + function text_or_json(error) { + if (request.headers.get('accept') === 'application/json') { + return json(error.body, { status: error.status }); + } + return text(error.body.message, { status: error.status }); + } + if (options.csrf_check_origin) { const forbidden = is_form_content_type(request) && @@ -72,14 +138,9 @@ export async function respond(request, options, manifest, state) { request.headers.get('origin') !== url.origin; if (forbidden) { - const csrf_error = new HttpError( - 403, - `Cross-site ${request.method} form submissions are forbidden` + return text_or_json( + new HttpError(403, `Cross-site ${request.method} form submissions are forbidden`) ); - if (request.headers.get('accept') === 'application/json') { - return json(csrf_error.body, { status: csrf_error.status }); - } - return text(csrf_error.body.message, { status: csrf_error.status }); } } @@ -284,6 +345,23 @@ export async function respond(request, options, manifest, state) { preload: default_preload }; + /** + * @param {unknown} e + * @returns {Promise} + */ + async function redirect_or_fatal_error(e) { + if (e instanceof Redirect) { + const response = is_data_request + ? redirect_json_response(e) + : route?.page && is_action_json_request(event) + ? action_json_redirect(e) + : redirect_response(e.status, e.location); + add_cookies_to_headers(response.headers, Object.values(new_cookies)); + return response; + } + return await handle_fatal_error(event, options, e); + } + /** @type {import('types').TrailingSlash} */ let trailing_slash = 'never'; @@ -360,32 +438,156 @@ export async function respond(request, options, manifest, state) { disable_search(url); } - const response = await with_event(event, () => - options.hooks.handle({ - event, - resolve: (event, opts) => - // counter-intuitively, we need to clear the event, so that it's not - // e.g. accessible when loading modules needed to handle the request - with_event(null, () => - resolve(event, page_nodes, opts).then((response) => { - // add headers/cookies here, rather than inside `resolve`, so that we - // can do it once for all responses instead of once per `return` - for (const key in headers) { - const value = headers[key]; - response.headers.set(key, /** @type {string} */ (value)); - } + /** + * @param {(event: import('@sveltejs/kit').RequestEvent, page_nodes: PageNodes | undefined, opts?: import('@sveltejs/kit').ResolveOptions) => Promise} resolve + * @returns {Promise} + */ + const handle_hook = async (resolve) => { + return await with_event(event, () => { + return options.hooks.handle({ + event, + resolve: (event, opts) => { + // counter-intuitively, we need to clear the event, so that it's not + // e.g. accessible when loading modules needed to handle the request + return with_event(null, () => { + return resolve(event, page_nodes, opts).then((response) => { + event.cookies.set = () => { + throw new Error( + 'Cannot use `cookies.set(...)` after the response has been generated' + ); + }; + + event.setHeaders = () => { + throw new Error( + 'Cannot use `setHeaders(...)` after the response has been generated' + ); + }; + + // add headers/cookies here, rather than inside `resolve`, so that we + // can do it once for all responses instead of once per `return` + for (const key in headers) { + const value = headers[key]; + response.headers.set(key, /** @type {string} */ (value)); + } + + add_cookies_to_headers(response.headers, Object.values(new_cookies)); + + if (state.prerendering && event.route.id !== null) { + response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); + } + + return response; + }); + }); + } + }); + }); + }; - add_cookies_to_headers(response.headers, Object.values(new_cookies)); + const node = upgrade && route?.endpoint ? await route.endpoint() : undefined; + if (node?.socket) { + if (DEV) { + __SVELTEKIT_TRACK__('websockets'); + } + + return { + upgrade: async ({ context }) => { + /** @type {Response} */ + let response; + + try { + response = await handle_hook(async (event) => { + /** @type {Response} */ + let upgrade_response; + + try { + /** @type {Response | ResponseInit | undefined} */ + let result; + + if (node.socket?.upgrade) { + Object.defineProperty(event, 'context', { + enumerable: true, + value: context + }); + result = + (await node.socket.upgrade( + /** @type {import('@sveltejs/kit').RequestEvent & { context: {} }} */ (event) + )) ?? undefined; + } - if (state.prerendering && event.route.id !== null) { - response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); + upgrade_response = + result instanceof Response ? result : new Response(undefined, result); + upgrade_response.headers.set('x-sveltekit-upgrade', 'true'); + } catch (e) { + if (e instanceof HttpError) { + upgrade_response = text_or_json(e); + } else if (e instanceof Response) { + // crossws allows throwing a Response to abort the upgrade + upgrade_response = e; + } else { + throw e; + } } - return response; - }) - ) - }) - ); + return upgrade_response; + }); + } catch (e) { + return await redirect_or_fatal_error(e); + } + + // if the x-sveltekit-upgrade header is missing we know we should + // abort the upgrade request because it means a different response + // has been thrown from the upgrade hook or returned from the handle hook + if (!response.headers.has('x-sveltekit-upgrade')) { + throw response; + } + + return response; + }, + /** + * @param {import('crossws').Peer} peer The Peer object before we modify it. + */ + open: async (peer) => { + // `peer.request` is a getter with no setter so this is the only way to override it + Object.defineProperty(peer, 'request', { + configurable: true, + enumerable: true, + get() { + return event.request; + } + }); + /** @type {import('@sveltejs/kit').Peer} */ (peer).event = event; + try { + await node.socket?.open?.(/** @type {import('@sveltejs/kit').Peer} */ (peer)); + } catch (e) { + await handle_fatal_error(event, options, e); + } + }, + message: async (peer, message) => { + try { + await node.socket?.message?.(peer, message); + } catch (e) { + await handle_fatal_error(event, options, e); + } + }, + close: async (peer, close_event) => { + try { + await node.socket?.close?.(peer, close_event); + } catch (e) { + await handle_fatal_error(event, options, e); + } + }, + error: async (peer, error) => { + try { + await node.socket?.error?.(peer, error); + } catch (e) { + await handle_fatal_error(event, options, e); + } + } + }; + } + + const response = await handle_hook(resolve); // respond with 304 if etag matches if (response.status === 200 && response.headers.has('etag')) { @@ -432,16 +634,7 @@ export async function respond(request, options, manifest, state) { return response; } catch (e) { - if (e instanceof Redirect) { - const response = is_data_request - ? redirect_json_response(e) - : route?.page && is_action_json_request(event) - ? action_json_redirect(e) - : redirect_response(e.status, e.location); - add_cookies_to_headers(response.headers, Object.values(new_cookies)); - return response; - } - return await handle_fatal_error(event, options, e); + return await redirect_or_fatal_error(e); } /** @@ -597,14 +790,6 @@ export async function respond(request, options, manifest, state) { // HttpError from endpoint can end up here - TODO should it be handled there instead? return await handle_fatal_error(event, options, e); - } finally { - event.cookies.set = () => { - throw new Error('Cannot use `cookies.set(...)` after the response has been generated'); - }; - - event.setHeaders = () => { - throw new Error('Cannot use `setHeaders(...)` after the response has been generated'); - }; } } } diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index c98af8cb0062..1062aced71d0 100644 --- a/packages/kit/src/types/ambient-private.d.ts +++ b/packages/kit/src/types/ambient-private.d.ts @@ -24,6 +24,12 @@ declare module '__sveltekit/server' { export let manifest: SSRManifest; export function read_implementation(path: string): ReadableStream; + export let peers: import('crossws').AdapterInstance['peers']; + export const publish_implementation: import('crossws').AdapterInstance['publish']; export function set_manifest(manifest: SSRManifest): void; export function set_read_implementation(fn: (path: string) => ReadableStream): void; + export function set_peers(peers: import('crossws').AdapterInstance['peers']): void; + export function set_publish_implementation( + fn: import('crossws').AdapterInstance['publish'] + ): void; } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 2d54b37ac145..918aaae55d2a 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -9,7 +9,6 @@ import { RequestHandler, ResolveOptions, Server, - ServerInitOptions, HandleFetch, Actions, HandleClientError, @@ -20,7 +19,8 @@ import { Adapter, ServerInit, ClientInit, - Transporter + Transporter, + Socket } from '@sveltejs/kit'; import { HttpMethod, @@ -42,6 +42,8 @@ export interface ServerInternalModule { set_private_env(environment: Record): void; set_public_env(environment: Record): void; set_read_implementation(implementation: (path: string) => ReadableStream): void; + set_peers(peers: import('crossws').AdapterInstance['peers']): void; + set_publish_implementation(implementation: import('crossws').AdapterInstance['publish']): void; set_safe_public_env(environment: Record): void; set_version(version: string): void; set_fix_stack_trace(fix_stack_trace: (error: unknown) => string): void; @@ -166,18 +168,20 @@ export interface Env { public: Record; } +type InternalRequestOptions = RequestOptions & { + prerendering?: PrerenderOptions; + read: (file: string) => Buffer; + /** A hook called before `handle` during dev, so that `AsyncLocalStorage` can be populated */ + before_handle?: (event: RequestEvent, config: any, prerender: PrerenderOption) => void; + emulator?: Emulator; +}; + export class InternalServer extends Server { - init(options: ServerInitOptions): Promise; - respond( + respond(request: Request, options: InternalRequestOptions): Promise; + resolveWebSocketHooks( request: Request, - options: RequestOptions & { - prerendering?: PrerenderOptions; - read: (file: string) => Buffer; - /** A hook called before `handle` during dev, so that `AsyncLocalStorage` can be populated. */ - before_handle?: (event: RequestEvent, config: any, prerender: PrerenderOption) => void; - emulator?: Emulator; - } - ): Promise; + options: InternalRequestOptions + ): Promise>; } export interface ManifestData { @@ -447,6 +451,7 @@ export interface PageNodeIndexes { export type PrerenderEntryGenerator = () => MaybePromise>>; export type SSREndpoint = Partial> & { + socket?: Socket; prerender?: PrerenderOption; trailingSlash?: TrailingSlash; config?: any; diff --git a/packages/kit/src/utils/exports.js b/packages/kit/src/utils/exports.js index ed685edb7ded..9cd6e9c9e019 100644 --- a/packages/kit/src/utils/exports.js +++ b/packages/kit/src/utils/exports.js @@ -83,7 +83,8 @@ const valid_server_exports = new Set([ 'prerender', 'trailingSlash', 'config', - 'entries' + 'entries', + 'socket' ]); export const validate_layout_exports = validator(valid_layout_exports); diff --git a/packages/kit/src/utils/exports.spec.js b/packages/kit/src/utils/exports.spec.js index e27817c17b5c..74e403aa697b 100644 --- a/packages/kit/src/utils/exports.spec.js +++ b/packages/kit/src/utils/exports.spec.js @@ -174,7 +174,7 @@ test('validates +server.js', () => { validate_server_exports({ answer: 42 }); - }, "Invalid export 'answer' (valid exports are GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD, fallback, prerender, trailingSlash, config, entries, or anything with a '_' prefix)"); + }, "Invalid export 'answer' (valid exports are GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD, fallback, prerender, trailingSlash, config, entries, socket, or anything with a '_' prefix)"); check_error(() => { validate_server_exports({ diff --git a/packages/kit/src/utils/features.js b/packages/kit/src/utils/features.js index 4a8530d22bbb..d4d7953708bc 100644 --- a/packages/kit/src/utils/features.js +++ b/packages/kit/src/utils/features.js @@ -7,6 +7,16 @@ export function check_feature(route_id, config, feature, adapter) { if (!adapter) return; + /** + * @param {string} message + * @throws {Error} + */ + const error = (message) => { + throw new Error( + `${message} in ${route_id} when using ${adapter.name}. Please ensure that your adapter is up to date and supports this feature.` + ); + }; + switch (feature) { case '$app/server:read': { const supported = adapter.supports?.read?.({ @@ -15,10 +25,35 @@ export function check_feature(route_id, config, feature, adapter) { }); if (!supported) { - throw new Error( - `Cannot use \`read\` from \`$app/server\` in ${route_id} when using ${adapter.name}. Please ensure that your adapter is up to date and supports this feature.` - ); + error('Cannot use `read` from `$app/server`'); + } + break; + } + case 'websockets': { + const supported = adapter.supports?.webSockets?.socket(); + + if (!supported) { + error('Cannot export `socket`'); } + break; + } + case '$app/server:getPeers': { + const supported = adapter.supports?.webSockets?.getPeers({ route: { id: route_id } }); + + if (!supported) { + error('Cannot use `getPeers` from `$app/server`'); + } + + break; + } + case '$app/server:publish': { + const supported = adapter.supports?.webSockets?.publish({ route: { id: route_id } }); + + if (!supported) { + error('Cannot use `publish` from `$app/server`'); + } + + break; } } } diff --git a/packages/kit/test/apps/basics/src/hooks.server.js b/packages/kit/test/apps/basics/src/hooks.server.js index 5976df3f5763..c907bb734829 100644 --- a/packages/kit/test/apps/basics/src/hooks.server.js +++ b/packages/kit/test/apps/basics/src/hooks.server.js @@ -158,6 +158,34 @@ export const handle = sequence( } return resolve(event); }, + async ({ event, resolve }) => { + const headers = event.request.headers; + const upgrade = headers.get('upgrade') === 'websocket'; + + if ( + upgrade && + event.url.pathname === '/ws/handle' && + event.url.searchParams.has('set-cookie') + ) { + event.cookies.set('ws', 'test', { path: '/ws' }); + } + + if ( + upgrade && + event.url.pathname === '/ws/handle' && + event.url.searchParams.has('set-headers') + ) { + event.setHeaders({ 'x-sveltekit-ws': 'test' }); + } + + const response = await resolve(event); + + if (upgrade && event.url.pathname === '/ws/handle' && event.url.searchParams.has('custom')) { + return new Response('custom response'); + } + + return response; + }, async ({ event, resolve }) => { if (event.url.pathname.startsWith('/get-request-event/')) { const e = getRequestEvent(); diff --git a/packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.ts b/packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.js similarity index 100% rename from packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.ts rename to packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.js diff --git a/packages/kit/test/apps/basics/src/routes/ws/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/+page.svelte new file mode 100644 index 000000000000..90197cec9726 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/+page.svelte @@ -0,0 +1,71 @@ + + + + + + + + + +
    + {#each messages as message} +
  • {message}
  • + {/each} +
diff --git a/packages/kit/test/apps/basics/src/routes/ws/+server.js b/packages/kit/test/apps/basics/src/routes/ws/+server.js new file mode 100644 index 000000000000..c702707a0121 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/+server.js @@ -0,0 +1,42 @@ +/** @type {import('./$types').Socket} */ +export const socket = { + upgrade({ request }) { + const protocols = request.headers + .get('Sec-WebSocket-Protocol') + ?.split(',') + .map((s) => s.trim()); + + if (protocols?.includes('bar')) { + return { + headers: { + 'Sec-WebSocket-Protocol': 'bar' + } + }; + } + }, + open(peer) { + peer.send('open hook works'); + peer.subscribe('chat'); + }, + message(peer, message) { + const data = message.text(); + + if (data === 'ping') { + peer.send('pong'); + return; + } + + if (data === 'close') { + peer.close(1000, 'test'); + return; + } + + peer.publish('chat', data); + }, + close(peer, event) { + if (event.reason === 'test') { + peer.publish('chat', `close: ${event.code} ${event.reason}`); + } + peer.unsubscribe('chat'); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/error/+server.js b/packages/kit/test/apps/basics/src/routes/ws/error/+server.js new file mode 100644 index 000000000000..4fdc6ca348f8 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/error/+server.js @@ -0,0 +1,8 @@ +import { error } from '@sveltejs/kit'; + +/** @type {import('./$types').Socket} */ +export const socket = { + upgrade() { + error(403, 'Forbidden'); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+page.svelte new file mode 100644 index 000000000000..7d989ea09d50 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+page.svelte @@ -0,0 +1,25 @@ + + + + + + +

{message}

diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+server.js new file mode 100644 index 000000000000..a1915e283c2a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+server.js @@ -0,0 +1,11 @@ +/** @type {import('./$types').Socket} */ +export const socket = { + message(peer) { + peer.close(1000, 'test close hook error'); + }, + close(peer, details) { + if (details.reason === 'test close hook error') { + throw new Error('close hook'); + } + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+page.svelte new file mode 100644 index 000000000000..8b7060b17f3e --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+page.svelte @@ -0,0 +1,17 @@ + + + + +

{message}

diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+server.js new file mode 100644 index 000000000000..1a33d92b5561 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/message/+server.js @@ -0,0 +1,7 @@ +/** @type {import('./$types').Socket} */ +export const socket = { + message(peer) { + peer.send('message received'); + throw new Error('message hook'); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+page.svelte new file mode 100644 index 000000000000..d9761e21c472 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+page.svelte @@ -0,0 +1,14 @@ + + + + +

{message}

diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+server.js new file mode 100644 index 000000000000..374f95e76258 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/open/+server.js @@ -0,0 +1,7 @@ +/** @type {import('./$types').Socket} */ +export const socket = { + open(peer) { + peer.send('opened'); + throw new Error('open hook'); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+page.svelte new file mode 100644 index 000000000000..ca5260e2f18a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+page.svelte @@ -0,0 +1,14 @@ + + + + +

{message}

diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+server.js new file mode 100644 index 000000000000..1566a47756bf --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+server.js @@ -0,0 +1,6 @@ +/** @type {import('./$types').Socket} */ +export const socket = { + upgrade() { + throw new Error('upgrade hook'); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle/+server.js new file mode 100644 index 000000000000..79a0a100affe --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle/+server.js @@ -0,0 +1,6 @@ +export const socket = { + upgrade() { + // always abort the upgrade request because we just want to test the handle hook runs + throw new Response(); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.server.js b/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.server.js new file mode 100644 index 000000000000..7df4ecb6cbaf --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.server.js @@ -0,0 +1,13 @@ +import { publish, getPeers } from '$app/server'; + +export const actions = { + publish: async () => { + publish('users', 'created a new user'); + }, + peers: async () => { + const peers = getPeers(); + peers.forEach((peer) => { + peer.send('sent to each peer'); + }); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.svelte new file mode 100644 index 000000000000..a07a79fea6db --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/helpers/+page.svelte @@ -0,0 +1,32 @@ + + +
    + {#each messages as message} +
  • {message}
  • + {/each} +
+ +
+ +
+ +
+ +
diff --git a/packages/kit/test/apps/basics/src/routes/ws/helpers/+server.js b/packages/kit/test/apps/basics/src/routes/ws/helpers/+server.js new file mode 100644 index 000000000000..92e26abafe0b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/helpers/+server.js @@ -0,0 +1,6 @@ +/** @type {import('./$types').Socket} */ +export const socket = { + open(peer) { + peer.subscribe('users'); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/no-socket/+server.js b/packages/kit/test/apps/basics/src/routes/ws/no-socket/+server.js new file mode 100644 index 000000000000..67d0afb10478 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/no-socket/+server.js @@ -0,0 +1,3 @@ +// this empty file ensures a server node exists but no socket or GET handler is +// defined to test that it returns a 405 GET method not allowed when a request goes +// through the upgrade event listener diff --git a/packages/kit/test/apps/basics/src/routes/ws/redirect/+server.js b/packages/kit/test/apps/basics/src/routes/ws/redirect/+server.js new file mode 100644 index 000000000000..fab5be44cfa7 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/redirect/+server.js @@ -0,0 +1,7 @@ +import { redirect } from '@sveltejs/kit'; + +export const socket = { + upgrade() { + redirect(303, '/ws?me'); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/request-event/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/request-event/+page.svelte new file mode 100644 index 000000000000..bdadd752c5f3 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/request-event/+page.svelte @@ -0,0 +1,17 @@ + + +

{message}

+ + diff --git a/packages/kit/test/apps/basics/src/routes/ws/request-event/+server.js b/packages/kit/test/apps/basics/src/routes/ws/request-event/+server.js new file mode 100644 index 000000000000..7e6edcfe5e53 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/request-event/+server.js @@ -0,0 +1,13 @@ +/** @type {import('./$types').Socket} */ +export const socket = { + open(peer) { + peer.send(`open: ${peer.event.url.pathname}`); + }, + message(peer, message) { + if (message.text() === 'close') { + peer.close(); + return; + } + peer.send(`message: ${peer.event.url.pathname}`); + } +}; diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index d2193940f0ab..82a8958d5c7a 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -14,7 +14,12 @@ const config = { }; }, supports: { - read: () => true + read: () => true, + webSockets: { + socket: () => true, + getPeers: () => true, + publish: () => true + } } }, diff --git a/packages/kit/test/apps/basics/test/cross-platform/client.test.js b/packages/kit/test/apps/basics/test/cross-platform/client.test.js index 50cc68bc8659..b52668882212 100644 --- a/packages/kit/test/apps/basics/test/cross-platform/client.test.js +++ b/packages/kit/test/apps/basics/test/cross-platform/client.test.js @@ -1030,3 +1030,119 @@ test.describe('Load', () => { }); } }); + +test.describe('WebSockets', () => { + test('upgrade hook', async ({ page }) => { + await page.goto('/ws'); + await page.locator('button', { hasText: 'with sub-protocols' }).click(); + await expect(page.getByText('connected')).toBeVisible(); + await expect(page.getByText('protocol: bar')).toBeVisible(); + }); + + test('open hook', async ({ page }) => { + await page.goto('/ws'); + await page.locator('button', { hasText: 'open' }).click(); + await expect(page.getByText('connected')).toBeVisible(); + await expect(page.getByText('open hook works')).toBeVisible(); + }); + + test('message hook', async ({ page }) => { + await page.goto('/ws'); + + await page.locator('button', { hasText: 'open' }).click(); + await expect(page.getByText('connected')).toBeVisible(); + + await page.locator('button', { hasText: 'ping' }).click(); + await expect(page.getByText('pong')).toBeVisible(); + }); + + test('publish and subscribe', async ({ page }) => { + await page.goto('/ws'); + + await page.locator('button', { hasText: 'open' }).click(); + await expect(page.getByText('connected')).toBeVisible(); + + await page.locator('button', { hasText: 'join' }).click(); + await expect(page.getByText('joined the chat')).toBeVisible(); + + await page.locator('button', { hasText: 'chat' }).click(); + await expect(page.getByText('hello')).toBeVisible(); + }); + + test('close hook', async ({ page }) => { + await page.goto('/ws'); + + await page.locator('button', { hasText: 'open' }).click(); + await expect(page.getByText('connected')).toBeVisible(); + + await page.locator('button', { hasText: 'join' }).click(); + await expect(page.getByText('joined the chat')).toBeVisible(); + + await page.locator('button', { hasText: 'leave' }).click(); + await expect(page.getByText('left the chat')).toBeVisible(); + await expect(page.getByText('close: 1000 test')).toBeVisible(); + }); + + // TODO: test error hook runs and can invoke handleError once we know how to trigger the error hook + + test('upgrade hook throwing an error invokes handleError', async ({ page, read_errors }) => { + await page.goto('/ws/handle-error/upgrade'); + await page.locator('button', { hasText: 'upgrade' }).click(); + await expect(page.getByText('error')).toBeVisible(); + await page.waitForTimeout(100); // we need to wait for the error to be written to disk + const error = read_errors('/ws/handle-error/upgrade'); + expect(error.message).toBe('upgrade hook'); + }); + + test('open hook throwing an error invokes handleError', async ({ page, read_errors }) => { + await page.goto('/ws/handle-error/open'); + await page.locator('button', { hasText: 'open' }).click(); + await expect(page.getByText('opened')).toBeVisible(); + await page.waitForTimeout(100); // we need to wait for the error to be written to disk + const error = read_errors('/ws/handle-error/open'); + expect(error.message).toBe('open hook'); + }); + + test('message hook throwing an error invokes handleError', async ({ page, read_errors }) => { + await page.goto('/ws/handle-error/message'); + await page.locator('button', { hasText: 'message' }).click(); + await expect(page.getByText('message received')).toBeVisible(); + await page.waitForTimeout(100); // we need to wait for the error to be written to disk + const error = read_errors('/ws/handle-error/message'); + expect(error.message).toBe('message hook'); + }); + + test('close hook throwing an error invokes handleError', async ({ page, read_errors }) => { + await page.goto('/ws/handle-error/close'); + await page.locator('button', { hasText: 'open' }).click(); + await expect(page.getByText('connected')).toBeVisible(); + await page.locator('button', { hasText: 'close' }).click(); + await expect(page.getByText('closed')).toBeVisible(); + await page.waitForTimeout(100); // we need to wait for the error to be written to disk + const error = read_errors('/ws/handle-error/close'); + expect(error.message).toBe('close hook'); + }); + + test('RequestEvent is available through Peer', async ({ page }) => { + await page.goto('/ws/request-event'); + // test that event has been added to the Peer object + await expect(page.getByText('open: /ws/request-event')).toBeVisible(); + // test that the modifications to Peer persists to other hooks + await page.locator('button', { hasText: 'send message' }).click(); + await expect(page.getByText('message: /ws/request-event')).toBeVisible(); + }); + + test('getPeers helper', async ({ page }) => { + await page.goto('/ws/helpers'); + await expect(page.getByText('connected')).toBeVisible(); + await page.locator('button', { hasText: 'message all peers' }).click(); + await expect(page.getByText('sent to each peer')).toBeVisible(); + }); + + test('publish helper', async ({ page }) => { + await page.goto('/ws/helpers'); + await expect(page.getByText('connected')).toBeVisible(); + await page.locator('button', { hasText: 'create user' }).click(); + await expect(page.getByText('created a new user')).toBeVisible(); + }); +}); diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js index 1a6f709bf535..e0b2ac08c1c1 100644 --- a/packages/kit/test/apps/basics/test/server.test.js +++ b/packages/kit/test/apps/basics/test/server.test.js @@ -287,6 +287,138 @@ test.describe('Endpoints', () => { }); }); +test.describe('WebSockets', () => { + test('error helper rejects upgrade', async ({ request, read_errors }) => { + const response = await request.get('/ws/error', { + headers: { + upgrade: 'websocket', + connection: 'Upgrade', + 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==', + 'Sec-WebSocket-Version': '13', + // we need this so that one of our hook handlers doesn't reject us + 'User-Agent': 'node' + } + }); + + const error = read_errors('/ws/error'); + expect(error).toBeUndefined(); + + expect(response.status()).toBe(403); + expect(await response.text()).toBe('Forbidden'); + }); + + test('redirect helper redirects', async ({ request, read_errors }) => { + const response = await request.get('/ws/redirect', { + headers: { + upgrade: 'websocket', + connection: 'Upgrade', + 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==', + 'Sec-WebSocket-Version': '13', + // we need this so that one of our hook handlers doesn't reject us + 'User-Agent': 'node' + }, + maxRedirects: 0 + }); + + const error = read_errors('/ws/redirect'); + expect(error).toBeUndefined(); + + expect(response.status()).toBe(303); + expect(response.headers().location).toBe('%2Fws%3Fme'); + }); + + test('handle can return a custom response during upgrade', async ({ request }) => { + const response = await request.get('/ws/handle?custom', { + headers: { + upgrade: 'websocket', + connection: 'Upgrade', + 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==', + 'Sec-WebSocket-Version': '13', + // we need this so that one of our hook handlers doesn't reject us + 'User-Agent': 'node' + } + }); + expect(response.status()).toBe(200); + expect(await response.text()).toBe('custom response'); + }); + + test('handle sets cookies during upgrade', async ({ request }) => { + const response = await request.get('/ws/handle?set-cookie', { + headers: { + upgrade: 'websocket', + connection: 'Upgrade', + 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==', + 'Sec-WebSocket-Version': '13', + // we need this so that one of our hook handlers doesn't reject us + 'User-Agent': 'node' + } + }); + expect(response.status()).toBe(200); + expect(response.headers()['set-cookie']).toBe( + 'ws%3Dtest%3B%20Path%3D%2Fws%3B%20HttpOnly%3B%20SameSite%3DLax\nname%3DSvelteKit%3B%20path%3D%2F%3B%20HttpOnly' + ); + }); + + test('handle sets headers during upgrade', async ({ request }) => { + const response = await request.get('/ws/handle?set-headers', { + headers: { + upgrade: 'websocket', + connection: 'Upgrade', + 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==', + 'Sec-WebSocket-Version': '13', + // we need this so that one of our hook handlers doesn't reject us + 'User-Agent': 'node' + } + }); + expect(response.status()).toBe(200); + expect(response.headers()['x-sveltekit-ws']).toBe('test'); + }); + + test('upgrade request to non-existent route returns not found', async ({ request }) => { + const response = await request.get('/ws/non-existent-route', { + headers: { + upgrade: 'websocket', + connection: 'Upgrade', + 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==', + 'Sec-WebSocket-Version': '13', + // we need this so that one of our hook handlers doesn't reject us + 'User-Agent': 'node' + } + }); + expect(response.status()).toBe(404); + }); + + test('upgrade request to endpoint without socket returns method not allowed', async ({ + request + }) => { + const response = await request.get('/ws/no-socket', { + headers: { + upgrade: 'websocket', + connection: 'Upgrade', + 'Sec-WebSocket-Key': 'W3vhWQbVNmNADVH4GinPfg==', + 'Sec-WebSocket-Version': '13', + // we need this so that one of our hook handlers doesn't reject us + 'User-Agent': 'node' + } + }); + expect(response.status()).toBe(405); + expect(await response.text()).toBe('GET method not allowed'); + }); + + test('non-upgrade request returns upgrade required when no GET handler exists', async ({ + request + }) => { + const response = await request.get('/ws', { + headers: { + // we need this so that one of our hook handlers doesn't reject us + 'User-Agent': 'node' + } + }); + expect(response.status()).toBe(426); + expect(await response.text()).toBe('This service requires use of the websocket protocol.'); + }); +}); + test.describe('Errors', () => { test('invalid route response is handled', async ({ request }) => { const response = await request.get('/errors/invalid-route-response'); diff --git a/packages/kit/test/apps/options-2/src/routes/+page.svelte b/packages/kit/test/apps/options-2/src/routes/+page.svelte index c026409d91ee..a71baf39d626 100644 --- a/packages/kit/test/apps/options-2/src/routes/+page.svelte +++ b/packages/kit/test/apps/options-2/src/routes/+page.svelte @@ -8,6 +8,8 @@

assets: {assets}

Go to /hello +
+Go to /ws