diff --git a/.changeset/tricky-drinks-develop.md b/.changeset/tricky-drinks-develop.md new file mode 100644 index 000000000000..405e5db475bc --- /dev/null +++ b/.changeset/tricky-drinks-develop.md @@ -0,0 +1,8 @@ +--- +'@sveltejs/adapter-cloudflare-workers': minor +'@sveltejs/adapter-cloudflare': minor +'@sveltejs/adapter-node': minor +'@sveltejs/kit': minor +--- + +feat: add support for WebSockets diff --git a/.changeset/two-islands-sleep.md b/.changeset/two-islands-sleep.md new file mode 100644 index 000000000000..7505f3f67aef --- /dev/null +++ b/.changeset/two-islands-sleep.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-auto': patch +--- + +fix: better error message when exporting `socket` diff --git a/documentation/docs/25-build-and-deploy/40-adapter-node.md b/documentation/docs/25-build-and-deploy/40-adapter-node.md index 0a7c553c4acc..9d7c920ec903 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,13 @@ 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 protocol upgrades for WebSocket connections +server.on('upgrade', upgradeHandler); ``` diff --git a/documentation/docs/25-build-and-deploy/99-writing-adapters.md b/documentation/docs/25-build-and-deploy/99-writing-adapters.md index c4092af15fb2..163da9b491ad 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,11 @@ 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: () => { + // 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 } } }; @@ -58,3 +63,5 @@ Within the `adapt` method, there are a number of things that an adapter should d - Put the user's static files and the generated JS/CSS in the correct location for the target platform Where possible, we recommend putting the adapter output under the `build/` directory with any intermediate output placed under `.svelte-kit/[adapter-name]`. + +If your environment supports WebSockets, you will need to handle upgrading a HTTP request to a WebSocket connection. You can do this by listening for requests from the platform that have an `Upgrade: websocket` header, calling the `server.getWebSocketHooksResolver({ getClientAddress })` function to get the WebSocket hooks resolver and passing it to the crossws adapter `resolve` option. The [crossws Adapters section](https://crossws.unjs.io/adapters) provides examples of creating this integration within various environments. diff --git a/documentation/docs/30-advanced/15-websockets.md b/documentation/docs/30-advanced/15-websockets.md new file mode 100644 index 000000000000..c3c704be796e --- /dev/null +++ b/documentation/docs/30-advanced/15-websockets.md @@ -0,0 +1,133 @@ +--- +title: WebSockets +--- + +## The `socket` object + +[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. + +A `+server.js` file can export a `socket` object to handle WebSocket connections. It uses [crossws](https://crossws.unjs.io/) to provide a consistent interface across different platforms. You can define [hooks](https://crossws.unjs.io/guide/hooks), all optional, to handle the different stages of the WebSocket lifecycle. + +```js +/** @type {import('@sveltejs/kit').Socket} **/ +export const socket = { + upgrade(req) { + // ... + }, + + open(peer) { + // ... + }, + + message(peer, message) { + // ... + }, + + close(peer, event) { + // ... + }, + + error(peer, error) { + // ... + } +}; +``` + +### upgrade + +The `upgrade` hook is called before a WebSocket connection is established. It receives the [request](https://developer.mozilla.org/docs/Web/API/Request) object as a parameter. + +You can use the [`error`](@sveltejs-kit#error) function imported from `@sveltejs/kit` to easily reject connections. Requests will be auto-accepted if the `upgrade` hook is not defined or does not `error`. + +```js +import { error } from "@sveltejs/kit"; + +/** @type {import('@sveltejs/kit').Socket} **/ +export const socket = { + upgrade(request) { + if (request.headers.get('origin') !== 'allowed_origin') { + // Reject the WebSocket connection by throwing an error + error(403, 'Forbidden'); + } + } +}; +``` + +### open + +The `open` hook is called when a WebSocket connection is opened. It receives the [peer](https://crossws.unjs.io/guide/peer) object, to allow interacting with connected clients, as a parameter. + +```js +/** @type {import('@sveltejs/kit').Socket} **/ +export const socket = { + open(peer) { + // ... + } +}; +``` + +### message + +The `message` hook is called when a message is received from the client. It receives the [peer](https://crossws.unjs.io/guide/peer) object, to allow interacting with connected clients, and the [message](https://crossws.unjs.io/guide/message) object, containing data from the client, as parameters. + +```js +/** @type {import('@sveltejs/kit').Socket} **/ +export const socket = { + message(peer, message) { + // ... + } +}; +``` + +### close + +The `close` hook is called when a WebSocket connection is closed. It receives the [peer](https://crossws.unjs.io/guide/peer) object, to allow interacting with connected clients, and the close event object, containing the [WebSocket connection close code](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code#value) and reason, as parameters. + +```js +/** @type {import('@sveltejs/kit').Socket} **/ +export const socket = { + close(peer, event) { + // ... + } +}; +``` + +### error + +The `error` hook is called when a connection with a WebSocket has been closed due to an error. It receives the [peer](https://crossws.unjs.io/guide/peer) object, to allow interacting with connected clients, and the error, as parameters. + +```js +/** @type {import('@sveltejs/kit').Socket} **/ +export const socket = { + error(peer, error) { + // ... + } +}; +``` + +## 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 + +SvelteKit uses [`crossws`](https://crossws.unjs.io) to handle cross-platform WebSocket connections. Please refer to their [compatibility table](https://crossws.unjs.io/guide/peer#compatibility) for the `peer` object in different runtime environments. diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index b35e66b73a09..01bc38a075d9 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -106,7 +106,7 @@ Note that `resolve(...)` will never throw an error, it will always return a `Pro ### handleFetch -This function allows you to modify (or replace) a `fetch` request that happens inside a `load` or `action` function that runs on the server (or during pre-rendering). +This function allows you to modify (or replace) a `fetch` request that happens inside a `load`, `action`, or `handle` function that runs on the server (or during prerendering). For example, your `load` function might make a request to a public URL like `https://api.yourapp.com` when the user performs a client-side navigation to the respective page, but during SSR it might make sense to hit the API directly (bypassing whatever proxies and load balancers sit between it and the public internet). @@ -153,7 +153,7 @@ The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`: ### handleError -If an [unexpected error](errors#Unexpected-errors) is thrown during loading or rendering, this function will be called with the `error`, `event`, `status` code and `message`. This allows for two things: +If an [unexpected error](errors#Unexpected-errors) is thrown during loading, rendering, or from an endpoint, this function will be called with the `error`, `event`, `status` code and `message`. This allows for two things: - you can log the error - you can generate a custom representation of the error that is safe to show to users, omitting sensitive details like messages and stack traces. The returned value, which defaults to `{ message }`, becomes the value of `$page.error`. diff --git a/packages/adapter-auto/index.js b/packages/adapter-auto/index.js index c83ba6246c59..df59851e94b9 100644 --- a/packages/adapter-auto/index.js +++ b/packages/adapter-auto/index.js @@ -125,6 +125,11 @@ export default () => ({ throw new Error( "The read function imported from $app/server only works in certain environments. Since you're using @sveltejs/adapter-auto, SvelteKit cannot determine whether it will work when your app is deployed. Please replace it with an adapter tailored to your target environment." ); + }, + webSockets: () => { + throw new Error( + "The socket export that creates a WebSocket server only works in certain environments. Since you're using @sveltejs/adapter-auto, SvelteKit cannot determine whether it will work when your app is deployed. Please replace it with an adapter tailored to your target environment." + ); } } }); diff --git a/packages/adapter-cloudflare-workers/files/_package.json b/packages/adapter-cloudflare-workers/files/_package.json index bc4c8d4aabac..b54b25124998 100644 --- a/packages/adapter-cloudflare-workers/files/_package.json +++ b/packages/adapter-cloudflare-workers/files/_package.json @@ -4,6 +4,7 @@ "description": "Worker site generated by SvelteKit", "main": "index.js", "dependencies": { - "@cloudflare/kv-asset-handler": "~0.1.3" + "@cloudflare/kv-asset-handler": "~0.1.3", + "crossws": "^0.3.4" } } diff --git a/packages/adapter-cloudflare-workers/files/entry.js b/packages/adapter-cloudflare-workers/files/entry.js index 5f022e5096b9..39956011352f 100644 --- a/packages/adapter-cloudflare-workers/files/entry.js +++ b/packages/adapter-cloudflare-workers/files/entry.js @@ -2,6 +2,9 @@ import { Server } from 'SERVER'; import { manifest, prerendered, base_path } from 'MANIFEST'; import { getAssetFromKV, mapRequestToAsset } from '@cloudflare/kv-asset-handler'; import static_asset_manifest_json from '__STATIC_CONTENT_MANIFEST'; +// TODO: allow WebSocket integration with Durable Objects using crossws/adapters/cloudflare-durable +import crossws from 'crossws/adapters/cloudflare'; + const static_asset_manifest = JSON.parse(static_asset_manifest_json); const server = new Server(manifest); @@ -11,6 +14,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.getWebSocketHooksResolver) { + ws = crossws({ + resolve: (req) => resolve_websocket_hooks(req) + }); +} + export default { /** * @param {Request} req @@ -18,8 +32,35 @@ export default { * @param {any} context */ async fetch(req, env, context) { + const options = { + platform: { + env, + context, + // lib.dom is interfering with workers-types + caches, + // req is actually a Cloudflare request not a standard request + cf: req.cf + }, + getClientAddress() { + return req.headers.get('cf-connecting-ip'); + } + }; + await server.init({ env }); + if (req.headers.get('upgrade') === 'websocket' && ws) { + resolve_websocket_hooks = server.getWebSocketHooksResolver( + // @ts-ignore + options + ); + return ws.handleUpgrade( + // @ts-ignore wtf is Cloudflare doing to these types + req, + env, + context + ); + } + const url = new URL(req.url); // static assets @@ -90,19 +131,11 @@ export default { } // dynamically-generated pages - return await server.respond(req, { - platform: { - env, - context, - // @ts-expect-error lib.dom is interfering with workers-types - caches, - // @ts-expect-error req is actually a Cloudflare request not a standard request - cf: req.cf - }, - getClientAddress() { - return req.headers.get('cf-connecting-ip'); - } - }); + return await server.respond( + req, + // @ts-ignore + options + ); } }; diff --git a/packages/adapter-cloudflare-workers/index.js b/packages/adapter-cloudflare-workers/index.js index 5d13539cd915..1e00caa97d77 100644 --- a/packages/adapter-cloudflare-workers/index.js +++ b/packages/adapter-cloudflare-workers/index.js @@ -171,6 +171,9 @@ export default function ({ config, platformProxy = {} } = {}) { return prerender ? emulated.prerender_platform : emulated.platform; } }; + }, + supports: { + webSockets: () => true } }; } diff --git a/packages/adapter-cloudflare-workers/package.json b/packages/adapter-cloudflare-workers/package.json index 33ced2718dd6..7ed4d52bf596 100644 --- a/packages/adapter-cloudflare-workers/package.json +++ b/packages/adapter-cloudflare-workers/package.json @@ -38,7 +38,9 @@ "check": "tsc --skipLibCheck" }, "dependencies": { - "@cloudflare/workers-types": "^4.20231121.0", + "@cloudflare/workers-types": "^4.20250129.0", + "@iarna/toml": "^2.2.5", + "crossws": "^0.3.4", "esbuild": "^0.24.0" }, "devDependencies": { diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index ceac64d92a2a..38cb60019683 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -100,6 +100,9 @@ export default function (options = {}) { return prerender ? emulated.prerender_platform : emulated.platform; } }; + }, + supports: { + webSockets: () => true } }; } diff --git a/packages/adapter-cloudflare/package.json b/packages/adapter-cloudflare/package.json index 235a6129ad36..013f58ca6242 100644 --- a/packages/adapter-cloudflare/package.json +++ b/packages/adapter-cloudflare/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@cloudflare/workers-types": "^4.20241106.0", + "crossws": "^0.3.4", "esbuild": "^0.24.0", "worktop": "0.8.0-next.18" }, diff --git a/packages/adapter-cloudflare/src/worker.js b/packages/adapter-cloudflare/src/worker.js index c3c27a0b041f..e360c8f78824 100644 --- a/packages/adapter-cloudflare/src/worker.js +++ b/packages/adapter-cloudflare/src/worker.js @@ -1,6 +1,8 @@ import { Server } from 'SERVER'; import { manifest, prerendered, base_path } from 'MANIFEST'; import * as Cache from 'worktop/cfw.cache'; +// TODO: allow WebSocket integration with Durable Objects using crossws/adapters/cloudflare-durable? +import crossws from 'crossws/adapters/cloudflare'; const server = new Server(manifest); @@ -9,11 +11,44 @@ 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.getWebSocketHooksResolver) { + ws = crossws({ + resolve: (req) => resolve_websocket_hooks(req) + }); +} + /** @type {import('worktop/cfw').Module.Worker<{ ASSETS: import('worktop/cfw.durable').Durable.Object }>} */ const worker = { + // @ts-ignore wtf is Cloudflare doing to these types async fetch(req, env, context) { + const options = { + platform: { env, context, caches, cf: req.cf }, + getClientAddress() { + return req.headers.get('cf-connecting-ip'); + } + }; + // @ts-ignore await server.init({ env }); + + if (req.headers.get('upgrade') === 'websocket' && ws) { + resolve_websocket_hooks = server.getWebSocketHooksResolver( + // @ts-ignore + options + ); + return ws.handleUpgrade( + // @ts-ignore wtf is Cloudflare doing to these types + req, + env, + context + ); + } + // skip cache if "cache-control: no-cache" in request let pragma = req.headers.get('cache-control') || ''; let res = !pragma.includes('no-cache') && (await Cache.lookup(req)); @@ -58,13 +93,11 @@ const worker = { }); } else { // dynamically-generated pages - res = await server.respond(req, { + res = await server.respond( + req, // @ts-ignore - platform: { env, context, caches, cf: req.cf }, - getClientAddress() { - return req.headers.get('cf-connecting-ip'); - } - }); + options + ); } // write to `Cache` only if response is not an error, diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 9b0b3158ab82..164d7b7a12d9 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -92,7 +92,8 @@ export default function (opts = {}) { }, supports: { - read: () => true + read: () => true, + webSockets: () => true } }; } diff --git a/packages/adapter-node/internal.d.ts b/packages/adapter-node/internal.d.ts index fed0584d1851..fe6e78d5bf26 100644 --- a/packages/adapter-node/internal.d.ts +++ b/packages/adapter-node/internal.d.ts @@ -4,6 +4,7 @@ declare module 'ENV' { declare module 'HANDLER' { export const handler: import('polka').Middleware; + export const upgradeHandler: import('crossws/adapters/node').NodeAdapter['handleUpgrade']; } declare module 'MANIFEST' { diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json index 6f93a6d4a3a6..a161deb4f916 100644 --- a/packages/adapter-node/package.json +++ b/packages/adapter-node/package.json @@ -46,6 +46,7 @@ "@sveltejs/kit": "workspace:^", "@sveltejs/vite-plugin-svelte": "^5.0.1", "@types/node": "^18.19.48", + "crossws": "^0.3.4", "polka": "^1.0.0-next.28", "sirv": "^3.0.0", "typescript": "^5.3.3", diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index b6c628dd4e0c..6672482f4da7 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'; @@ -103,6 +104,55 @@ function serve_prerendered() { }; } +/** + * @param {import('node:http').IncomingMessage} req + */ +function get_options(req) { + return { + 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} */ @@ -120,53 +170,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 */ @@ -212,3 +216,56 @@ export const handler = sequence( ssr ].filter(Boolean) ); + +/** @type {import('crossws').ResolveHooks} */ +let resolve_websocket_hooks; +/** @type {import('crossws/adapters/node').NodeAdapter} */ +let ws; + +if (server.getWebSocketHooksResolver) { + ws = crossws({ + resolve: (req) => resolve_websocket_hooks(req) + }); +} + +/** + * @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; + } + + Object.defineProperty(request, 'context', { + enumerable: true, + value: {} + }); + + const resolve = server.getWebSocketHooksResolver(get_options(req)); + + const hooks = await resolve(request); + const upgrade = hooks.upgrade; + hooks.upgrade = () => + upgrade(/** @type {Request & { context: import('crossws').Peer['context'] }} */ (request)); + + resolve_websocket_hooks = () => hooks; + + ws.handleUpgrade(req, socket, head); + } +} diff --git a/packages/adapter-node/src/index.js b/packages/adapter-node/src/index.js index ef1ab701a2a3..88e871303e44 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 } 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, so the internal server is available +server.server.on('upgrade', upgradeHandler); + /** @param {'SIGINT' | 'SIGTERM' | 'IDLE'} reason */ function graceful_shutdown(reason) { if (shutdown_timeout_id) return; diff --git a/packages/kit/package.json b/packages/kit/package.json index 40f37e3566ff..929c310bb572 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 2484aea4831d..713edb66df26 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -103,6 +103,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?.()) { + 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})`); } @@ -181,6 +189,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/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index f25cc225e194..6678cfa8d7fb 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -36,18 +36,22 @@ export interface Adapter { */ adapt: (builder: Builder) => MaybePromise; /** - * Checks called during dev and build to determine whether specific features will work in production with this adapter + * Checks called during dev and build to determine whether specific features will work in production with this adapter. */ supports?: { /** - * Test support for `read` from `$app/server` + * Test support for `read` from `$app/server`. * @param config The merged route config */ read?: (details: { config: any; route: { id: string } }) => boolean; + /** + * Test support for the `socket` export from a `+server.js` file. + */ + webSockets?: () => boolean; }; /** * Creates an `Emulator`, which allows the adapter to influence the environment - * during dev, build and prerendering + * during dev, build and prerendering. */ emulate?: () => MaybePromise; } @@ -1299,6 +1303,11 @@ export class Server { constructor(manifest: SSRManifest); init(options: ServerInitOptions): Promise; respond(request: Request, options: RequestOptions): Promise; + getWebSocketHooksResolver( + options: RequestOptions + ): ( + info: RequestInit | import('crossws').Peer + ) => Promise & { upgrade: import('crossws').Hooks['upgrade'] }>; } export interface ServerInitOptions { @@ -1403,7 +1412,7 @@ export interface ServerLoadEvent< } /** - * Shape of a form action method that is part of `export const actions = {..}` in `+page.server.js`. + * Shape of a form action method that is part of `export const actions = {...}` in `+page.server.js`. * See [form actions](https://svelte.dev/docs/kit/form-actions) for more information. */ export type Action< @@ -1413,7 +1422,7 @@ export type Action< > = (event: RequestEvent) => MaybePromise; /** - * Shape of the `export const actions = {..}` object in `+page.server.js`. + * Shape of the `export const actions = {...}` object in `+page.server.js`. * See [form actions](https://svelte.dev/docs/kit/form-actions) for more information. */ export type Actions< @@ -1452,7 +1461,7 @@ export interface HttpError { } /** - * The object returned by the [`redirect`](https://svelte.dev/docs/kit/@sveltejs-kit#redirect) function + * The object returned by the [`redirect`](https://svelte.dev/docs/kit/@sveltejs-kit#redirect) function. */ export interface Redirect { /** The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages), in the range 300-308. */ @@ -1487,6 +1496,27 @@ export type SubmitFunction< }) => MaybePromise) >; +/** + * Shape of the `export const socket = {...}` object in `+server.js`. + * See [WebSockets](https://svelte.dev/docs/kit/websockets) for more information. + * @since 2.18.0 + */ +export type Socket = Partial; + +/** + * When a new [WebSocket](https://svelte.dev/docs/kit/websockets) client connects to the server, `crossws` creates a `peer` instance that allows getting information from clients and sending messages to them. + * See [Peer](https://crossws.unjs.io/guide/peer) for more information. + * @since 2.18.0 + */ +export type Peer = import('crossws').Peer; + +/** + * During a [WebSocket](https://svelte.dev/docs/kit/websockets) `message` hook, you receive a `message` object containing data from the client. + * See [Message](https://crossws.unjs.io/guide/message) for more information. + * @since 2.18.0 + */ +export type Message = import('crossws').Message; + /** * The type of `export const snapshot` exported from a page or layout component. */ diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 39f4ef41e0cd..1613797e4004 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'; @@ -446,6 +447,63 @@ 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)) + }); + + 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) + }); + return () => { const serve_static_middleware = vite.middlewares.stack.find( (middleware) => @@ -456,14 +514,86 @@ 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; + } + + const resolve = server.getWebSocketHooksResolver({ + getClientAddress: get_client_address(req), + read, + before_handle, + emulator + }); + + // 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 + }); + Object.defineProperty(request, 'context', { + enumerable: true, + value: {} + }); + + const hooks = await resolve(request); + const upgrade = hooks.upgrade; + hooks.upgrade = () => + upgrade( + /** @type {Request & { context: import('crossws').Peer['context'] }} */ (request) + ); + + resolve_websocket_hooks = () => hooks; + + // TODO: remove this eslint disable after crossws releases the type fix + // eslint-disable-next-line @typescript-eslint/await-thenable -- handleUpgrade actually returns Promise + await ws.handleUpgrade(req, socket, head); + } 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))); @@ -499,25 +629,7 @@ export async function dev(vite, vite_config, svelte_config) { return; } - // we have to import `Server` before calling `set_assets` - const { Server } = /** @type {import('types').ServerModule} */ ( - await vite.ssrLoadModule(`${runtime_base}/server/index.js`, { fixStacktrace: true }) - ); - - const { set_fix_stack_trace } = await vite.ssrLoadModule( - `${runtime_base}/shared-server.js` - ); - set_fix_stack_trace(fix_stack_trace); - - const { set_assets } = await vite.ssrLoadModule('__sveltekit/paths'); - set_assets(assets); - - const server = new Server(manifest); - - await server.init({ - env, - read: (file) => createReadableStream(from_fs(file)) - }); + const server = await init_server(); const request = await getRequest({ base, @@ -547,21 +659,9 @@ export async function dev(vite, vite_config, svelte_config) { } const rendered = await server.respond(request, { - getClientAddress: () => { - const { remoteAddress } = req.socket; - if (remoteAddress) return remoteAddress; - throw new Error('Could not determine clientAddress'); - }, - read: (file) => { - if (file in manifest._.server_assets) { - return fs.readFileSync(from_fs(file)); - } - - return fs.readFileSync(path.join(svelte_config.kit.files.assets, file)); - }, - before_handle: (event, config, prerender) => { - async_local_storage.enterWith({ event, config, prerender }); - }, + getClientAddress: get_client_address(req), + read, + before_handle, emulator }); @@ -658,3 +758,14 @@ function has_correct_case(file, assets) { return false; } + +/** + * @param {import('node:http').IncomingMessage} req + */ +function get_client_address(req) { + return () => { + const { remoteAddress } = req.socket; + if (remoteAddress) return remoteAddress; + throw new Error('Could not determine clientAddress'); + }; +} diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 0342e718c75c..ad7f46c98941 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import crossws from 'crossws/adapters/node'; import { lookup } from 'mrmime'; import sirv from 'sirv'; import { loadEnv, normalizePath } from 'vite'; @@ -14,7 +15,7 @@ import { not_found } from '../utils.js'; /** @typedef {(req: Req, res: Res, next: () => void) => void} Handler */ /** - * @param {{ middlewares: import('connect').Server }} vite + * @param {import('vite').PreviewServer} vite * @param {import('vite').ResolvedConfig} vite_config * @param {import('types').ValidatedConfig} svelte_config */ @@ -53,6 +54,31 @@ export async function preview(vite, vite_config, svelte_config) { 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)); + } + + /** @type {import('crossws').ResolveHooks} */ + let resolve_websocket_hooks; + const ws = crossws({ + resolve: (req) => resolve_websocket_hooks(req) + }); + 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 +209,58 @@ 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 resolve = server.getWebSocketHooksResolver({ + getClientAddress: get_client_address(req), + read, + emulator + }); + + // 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: get_base(req), + request: req + }); + Object.defineProperty(request, 'context', { + enumerable: true, + value: {} + }); + + const hooks = await resolve(request); + const upgrade = hooks.upgrade; + hooks.upgrade = () => + upgrade( + /** @type {Request & { context: import('crossws').Peer['context'] }} */ (request) + ); + + resolve_websocket_hooks = () => hooks; + + ws.handleUpgrade(req, socket, head); + } + } + ); + // 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 +306,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/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index 55bcd87807b9..4e122136c222 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -1,3 +1,4 @@ +import { DEV } from 'esm-env'; import { ENDPOINT_METHODS, PAGE_METHODS } from '../../constants.js'; import { negotiate } from '../../utils/http.js'; import { Redirect } from '../control.js'; @@ -10,11 +11,27 @@ import { method_not_allowed } from './utils.js'; * @returns {Promise} */ export async function render_endpoint(event, mod, state) { + if (DEV && mod.socket) { + __SVELTEKIT_TRACK__('websockets'); + } + const method = /** @type {import('types').HttpMethod} */ (event.request.method); + // if we've ended up here, it means the request does not have both the + // `Upgrade: websocket` and the `Connect: upgrade` headers + if (method === 'GET' && !mod.GET && mod.socket) { + return new Response('This service requires use of the websocket protocol.', { + status: 426, + headers: { + upgrade: 'websocket', + connect: 'Upgrade' + } + }); + } + let handler = mod[method] || mod.fallback; - if (method === 'HEAD' && mod.GET && !mod.HEAD) { + if (method === 'HEAD' && !mod.HEAD && mod.GET) { handler = mod.GET; } diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index a2740a8e6aa4..5ddf16dd1f5e 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -1,4 +1,4 @@ -import { respond } from './respond.js'; +import { respond, get_websocket_hooks_resolver } 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'; @@ -112,4 +112,15 @@ export class Server { depth: 0 }); } + + /** + * @param {import('types').RequestOptions} options + */ + getWebSocketHooksResolver(options) { + return get_websocket_hooks_resolver(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 05b1934d0595..8bd440f45de4 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -59,6 +59,74 @@ 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 {import('types').SSROptions} options + * @param {import('@sveltejs/kit').SSRManifest} manifest + * @param {import('types').SSRState} state + * @returns {(info: RequestInit | import('crossws').Peer) => Promise & { upgrade: import('crossws').Hooks['upgrade'] }>} + */ +export function get_websocket_hooks_resolver(options, manifest, state) { + return async (info) => { + /** @type {Request} */ + let request; + + // Check if info is a Peer object + if ('request' in info) { + // @ts-ignore UpgradeRequest and Request are essentially the same + request = info.request; + } else { + // @ts-ignore although the type is RequestInit, it is almost always a Request object + request = info; + } + + const result = await handle_request(request, options, manifest, state, true); + + if (result instanceof Response) { + // if the result is a Response instead of WebSocket hooks, it means + // we should ignore the upgrade request and send back a regular response + return { + upgrade: () => { + // we have to throw the Response to avoid accepting 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); @@ -236,6 +304,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(cookies_to_add)); + return response; + } + return await handle_fatal_error(event, options, e); + } + try { // determine whether we need to redirect to add/remove a trailing slash if (route) { @@ -346,25 +431,125 @@ export async function respond(request, options, manifest, state) { if (state.prerendering && !state.prerendering.fallback) disable_search(url); - const response = await options.hooks.handle({ - event, - resolve: (event, opts) => - resolve(event, opts).then((response) => { - // add headers/cookies here, rather than inside `resolve`, so that we - // can do it once for all responses instead of once per `return` - for (const key in headers) { - const value = headers[key]; - response.headers.set(key, /** @type {string} */ (value)); - } + /** + * @param {Response} response + * @returns {Response} + */ + const after_resolve = (response) => { + event.cookies.set = () => { + throw new Error('Cannot use `cookies.set(...)` after the response has been generated'); + }; + + event.setHeaders = () => { + throw new Error('Cannot use `setHeaders(...)` after the response has been generated'); + }; + + // add headers/cookies here, rather than inside `resolve`, so that we + // can do it once for all responses instead of once per `return` + for (const key in headers) { + const value = headers[key]; + response.headers.set(key, /** @type {string} */ (value)); + } + + add_cookies_to_headers(response.headers, Object.values(cookies_to_add)); - add_cookies_to_headers(response.headers, Object.values(cookies_to_add)); + if (state.prerendering && event.route.id !== null) { + response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); + } + + return response; + }; - if (state.prerendering && event.route.id !== null) { - response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); + if (upgrade && route?.endpoint) { + const node = await route.endpoint(); + if (node.socket) { + if (DEV) { + __SVELTEKIT_TRACK__('websockets'); + } + + return { + upgrade: async (req) => { + /** @type {Response} */ + let response; + + try { + response = await options.hooks.handle({ + event, + resolve: async () => { + /** @type {Response} */ + let upgrade_response; + + try { + const init = (await node.socket?.upgrade?.(req)) ?? undefined; + upgrade_response = new Response(undefined, init); + upgrade_response.headers.set('x-sveltekit-upgrade', 'true'); + } catch (e) { + if (e instanceof HttpError) { + upgrade_response = json(e.body, { status: e.status }); + } else if (e instanceof Redirect) { + upgrade_response = new Response(undefined, { + status: e.status, + headers: { location: e.location } + }); + } + // crossws allows throwing a Response to abort the upgrade + else if (e instanceof Response) { + upgrade_response = e; + } else { + throw e; + } + } + + return after_resolve(upgrade_response); + } + }); + } catch (e) { + return await redirect_or_fatal_error(e); + } + + // if the handle hook returned a custom response or the user threw a response + // then we abort upgrading the connection + if (!response.headers.has('x-sveltekit-upgrade')) { + throw response; + } + + return response; + }, + open: async (peer) => { + try { + await node.socket?.open?.(peer); + } catch (e) { + await handle_fatal_error(event, options, e); + } + }, + message: async (peer, message) => { + try { + await node.socket?.message?.(peer, message); + } catch (e) { + await handle_fatal_error(event, options, e); + } + }, + close: async (peer, close_event) => { + try { + await node.socket?.close?.(peer, close_event); + } catch (e) { + await handle_fatal_error(event, options, e); + } + }, + error: async (peer, error) => { + try { + await node.socket?.error?.(peer, error); + } catch (e) { + await handle_fatal_error(event, options, e); + } } + }; + } + } - return response; - }) + const response = await options.hooks.handle({ + event, + resolve: (event, opts) => resolve(event, opts).then(after_resolve) }); // respond with 304 if etag matches @@ -412,16 +597,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(cookies_to_add)); - return response; - } - return await handle_fatal_error(event, options, e); + return await redirect_or_fatal_error(e); } /** @@ -567,14 +743,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/internal.d.ts b/packages/kit/src/types/internal.d.ts index 12a3b5dac4a8..aaef5cd90a15 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -20,7 +20,8 @@ import { Adapter, ServerInit, ClientInit, - Transporter + Transporter, + Socket } from '@sveltejs/kit'; import { HttpMethod, @@ -79,7 +80,7 @@ export interface BuildData { * An entry is undefined if the layout/page has no component or universal file (i.e. only has a `.server.js` file). * Only set in case of `router.resolution === 'server'`. */ - nodes?: (string | undefined)[]; + nodes?: Array; /** * Contains the client route manifest in a form suitable for the server which is used for server side route resolution. * Notably, it contains all routes, regardless of whether they are prerendered or not (those are missing in the optimized server route manifest). @@ -159,18 +160,22 @@ 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( - 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; + respond(request: Request, options: InternalRequestOptions): Promise; + getWebSocketHooksResolver( + options: InternalRequestOptions + ): ( + info: RequestInit | import('crossws').Peer + ) => Promise & { upgrade: import('crossws').Hooks['upgrade'] }>; } export interface ManifestData { @@ -430,6 +435,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..872d8f9ad6ff 100644 --- a/packages/kit/src/utils/features.js +++ b/packages/kit/src/utils/features.js @@ -19,6 +19,17 @@ export function check_feature(route_id, config, feature, adapter) { `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.` ); } + break; + } + case 'websockets': { + const supported = adapter.supports?.webSockets?.(); + + if (!supported) { + throw new Error( + `Cannot export \`socket\` in ${route_id} when using ${adapter.name}. Please ensure that your adapter is up to date and supports this feature.` + ); + } + break; } } } diff --git a/packages/kit/test/apps/basics/src/hooks.server.js b/packages/kit/test/apps/basics/src/hooks.server.js index 1c825a6a6c90..37f0019185d8 100644 --- a/packages/kit/test/apps/basics/src/hooks.server.js +++ b/packages/kit/test/apps/basics/src/hooks.server.js @@ -151,6 +151,34 @@ export const handle = sequence( event.locals.url = new URL(event.request.url); } return resolve(event); + }, + async ({ event, resolve }) => { + const headers = event.request.headers; + const upgrade = headers.get('upgrade') === 'websocket'; + + if ( + upgrade && + event.url.pathname === '/ws/handle' && + event.url.searchParams.has('set-cookie') + ) { + event.cookies.set('ws', 'test', { path: '/ws' }); + } + + if ( + upgrade && + event.url.pathname === '/ws/handle' && + event.url.searchParams.has('set-headers') + ) { + event.setHeaders({ 'x-sveltekit-ws': 'test' }); + } + + const response = await resolve(event); + + if (upgrade && event.url.pathname === '/ws/handle' && event.url.searchParams.has('custom')) { + return new Response('custom response'); + } + + return response; } ); diff --git a/packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.ts b/packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.js similarity index 100% rename from packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.ts rename to packages/kit/test/apps/basics/src/routes/init-hooks/+page.server.js diff --git a/packages/kit/test/apps/basics/src/routes/ws/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/+page.svelte new file mode 100644 index 000000000000..e2f4816d6aef --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/+page.svelte @@ -0,0 +1,58 @@ + + + + + + + + +
    + {#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..1588052e73f3 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/+server.js @@ -0,0 +1,42 @@ +/** @type {import('@sveltejs/kit').Socket} */ +export const socket = { + upgrade(request) { + const protocols = request.headers + .get('Sec-WebSocket-Protocol') + ?.split(',') + .map((s) => s.trim()); + + if (protocols?.includes('bar')) { + return { + headers: { + 'Sec-WebSocket-Protocol': 'bar' + } + }; + } + }, + open(peer) { + peer.send('open hook works'); + peer.subscribe('chat'); + }, + message(peer, message) { + const data = message.text(); + + if (data === 'ping') { + peer.send('pong'); + return; + } + + if (data === 'close') { + peer.close(1000, 'test'); + return; + } + + peer.publish('chat', data); + }, + close(peer, event) { + if (event.reason === 'test') { + peer.publish('chat', `close: ${event.code} ${event.reason}`); + } + peer.unsubscribe('chat'); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/error/+server.js b/packages/kit/test/apps/basics/src/routes/ws/error/+server.js new file mode 100644 index 000000000000..51f11ede920f --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/error/+server.js @@ -0,0 +1,8 @@ +import { error } from '@sveltejs/kit'; + +/** @type {import('@sveltejs/kit').Socket} */ +export const socket = { + upgrade() { + error(403, 'Forbidden'); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+page.svelte b/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+page.svelte new file mode 100644 index 000000000000..7d989ea09d50 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/close/+page.svelte @@ -0,0 +1,25 @@ + + + + + + +

{message}

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

{message}

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

{message}

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

{message}

diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+server.js new file mode 100644 index 000000000000..38393094de8b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle-error/upgrade/+server.js @@ -0,0 +1,6 @@ +/** @type {import('@sveltejs/kit').Socket} */ +export const socket = { + upgrade() { + throw new Error('upgrade hook'); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/handle/+server.js b/packages/kit/test/apps/basics/src/routes/ws/handle/+server.js new file mode 100644 index 000000000000..79a0a100affe --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/handle/+server.js @@ -0,0 +1,6 @@ +export const socket = { + upgrade() { + // always abort the upgrade request because we just want to test the handle hook runs + throw new Response(); + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/ws/no-socket/+server.js b/packages/kit/test/apps/basics/src/routes/ws/no-socket/+server.js new file mode 100644 index 000000000000..67d0afb10478 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/no-socket/+server.js @@ -0,0 +1,3 @@ +// this empty file ensures a server node exists but no socket or GET handler is +// defined to test that it returns a 405 GET method not allowed when a request goes +// through the upgrade event listener diff --git a/packages/kit/test/apps/basics/src/routes/ws/redirect/+server.js b/packages/kit/test/apps/basics/src/routes/ws/redirect/+server.js new file mode 100644 index 000000000000..fab5be44cfa7 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/redirect/+server.js @@ -0,0 +1,7 @@ +import { redirect } from '@sveltejs/kit'; + +export const socket = { + upgrade() { + redirect(303, '/ws?me'); + } +}; diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index bca05e5376ee..3dbc52109840 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -14,7 +14,8 @@ const config = { }; }, supports: { - read: () => true + read: () => true, + webSockets: () => true } }, diff --git a/packages/kit/test/apps/basics/test/cross-platform/client.test.js b/packages/kit/test/apps/basics/test/cross-platform/client.test.js index c1de68907024..c39a4f97fe64 100644 --- a/packages/kit/test/apps/basics/test/cross-platform/client.test.js +++ b/packages/kit/test/apps/basics/test/cross-platform/client.test.js @@ -1012,3 +1012,100 @@ test.describe('Load', () => { }); } }); + +// Running the tests serially prevents the WebSocket server from being overloaded, +// improving communication speed and reducing test flakiness +test.describe.serial('WebSockets', () => { + test('upgrade hook', async ({ page }) => { + await page.goto('/ws'); + await page.locator('button', { hasText: 'open' }).click(); + expect(page.getByText('connected')).toBeVisible(); + expect(page.getByText('protocol: bar')).toBeVisible(); + }); + + test('open hook', async ({ page }) => { + await page.goto('/ws'); + await page.locator('button', { hasText: 'open' }).click(); + expect(page.getByText('connected')).toBeVisible(); + expect(page.getByText('open hook works')).toBeVisible(); + }); + + test('message hook', async ({ page }) => { + await page.goto('/ws'); + + await page.locator('button', { hasText: 'open' }).click(); + expect(page.getByText('connected')).toBeVisible(); + + await page.locator('button', { hasText: 'ping' }).click(); + expect(page.getByText('pong')).toBeVisible(); + }); + + test('pub/sub', async ({ page }) => { + await page.goto('/ws'); + + await page.locator('button', { hasText: 'open' }).click(); + expect(page.getByText('connected')).toBeVisible(); + + await page.locator('button', { hasText: 'join' }).click(); + expect(page.getByText('joined the chat')).toBeVisible(); + + await page.locator('button', { hasText: 'chat' }).click(); + expect(page.getByText('hello')).toBeVisible(); + }); + + test('close hook', async ({ page }) => { + await page.goto('/ws'); + + await page.locator('button', { hasText: 'open' }).click(); + expect(page.getByText('connected')).toBeVisible(); + + await page.locator('button', { hasText: 'join' }).click(); + expect(page.getByText('joined the chat')).toBeVisible(); + + await page.locator('button', { hasText: 'leave' }).click(); + expect(page.getByText('left the chat')).toBeVisible(); + expect(page.getByText('close: 1000 test')).toBeVisible(); + }); + + // TODO: test error hook runs after finding out how to trigger it + + 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(); + 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(); + 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(); + 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(); + expect(page.getByText('connected')).toBeVisible(); + await page.locator('button', { hasText: 'close' }).click(); + 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'); + }); + + // TODO: test error hook throwing an error runs handleError +}); diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js index e56b8231fb46..b4f635158633 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