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..a8f9321af7b4 --- /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 an error occurs on the underlying WebSocket server. 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/entry.js b/packages/adapter-cloudflare-workers/files/entry.js index 5f022e5096b9..8bb8a823f680 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,12 @@ const app_path = `/${manifest.appPath}`; const immutable = `${app_path}/immutable/`; const version_file = `${app_path}/version.json`; +/** @type {import('crossws').ResolveHooks | undefined} */ +let resolve_websocket_hooks; +const ws = crossws({ + resolve: (req) => resolve_websocket_hooks?.(req) ?? {} +}); + export default { /** * @param {Request} req @@ -18,8 +27,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') { + 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 +126,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 5da3fe275022..aa58cc291cd7 100644 --- a/packages/adapter-cloudflare-workers/index.js +++ b/packages/adapter-cloudflare-workers/index.js @@ -182,6 +182,9 @@ export default function ({ config = 'wrangler.toml', 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 95a07cbdecd1..db1689bcf8e5 100644 --- a/packages/adapter-cloudflare-workers/package.json +++ b/packages/adapter-cloudflare-workers/package.json @@ -38,8 +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 a705531a6863..046ae94377ec 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..e78c0884d896 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,39 @@ const app_path = `/${manifest.appPath}`; const immutable = `${app_path}/immutable/`; const version_file = `${app_path}/version.json`; +/** @type {import('crossws').ResolveHooks | undefined} */ +let resolve_websocket_hooks; +const 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') { + 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 +88,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..0f3dc55ea180 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,24 @@ export const handler = sequence( ssr ].filter(Boolean) ); + +const ws = crossws({ + resolve: (req) => { + const resolve = server.getWebSocketHooksResolver?.( + // the provided type for req is too generic. It is really just a standard node req + get_options(/** @type {import("node:http").IncomingMessage} */ (req)) + ); + return resolve?.(req) ?? {}; + } +}); + +/** + * @param {import('node:http').IncomingMessage} req + * @param {import('node:stream').Duplex} socket + * @param {Buffer} head + */ +export function upgradeHandler(req, socket, head) { + if (req.headers.upgrade === 'websocket') { + 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 0af7827de78c..e9b989dc1dd8 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..ea6665eb60e2 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,9 @@ 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>; } export interface ServerInitOptions { @@ -1403,7 +1410,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 +1420,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 +1459,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 +1494,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..1885e885f55e 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?.(); + 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 }); + } + + const ws = crossws({ + resolve: async (req) => { + const server = await init_server(); + const resolve = server.getWebSocketHooksResolver({ + getClientAddress: get_client_address( + /** @type {import('node:http').IncomingMessage} */ (req) + ), + read, + before_handle, + emulator + }); + return resolve(req); + } + }); + return () => { const serve_static_middleware = vite.middlewares.stack.find( (middleware) => @@ -456,6 +514,30 @@ 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', + /** @type {(req: import('node:http').IncomingMessage, socket: import('node:stream').Duplex, head: Buffer) => void} */ ( + async (req, socket, head) => { + if ( + req.headers['sec-websocket-protocol'] !== 'vite-hmr' && + req.headers.upgrade === 'websocket' + ) { + try { + // TODO: check if anymore of the middleware logic below needs to be duplicated here + + // TODO: fix the incorrect type in crossws. handleUpgrade actually returns a promise + // eslint-disable-next-line @typescript-eslint/await-thenable + 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; @@ -499,25 +581,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 +611,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 +710,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 4885d000ec15..5837e0541b6a 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -533,7 +533,6 @@ Tips: case sveltekit_server: { return dedent` export let read_implementation = null; - export let manifest = null; export function set_read_implementation(fn) { diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 0342e718c75c..dc1cc1963551 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'; @@ -53,6 +54,31 @@ export async function preview(vite, vite_config, svelte_config) { const emulator = await svelte_config.kit.adapter?.emulate?.(); + /** + * @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)); + } + + const ws = crossws({ + resolve: (req) => { + const resolve = server.getWebSocketHooksResolver({ + getClientAddress: get_client_address( + // the provided type for req is too generic. It is really just a standard node req + /** @type {import("node:http").IncomingMessage} */ (req) + ), + read, + emulator + }); + return resolve(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,6 +209,17 @@ export async function preview(vite, vite_config, svelte_config) { }) ); + vite.middlewares.on( + 'upgrade', + /** @type {(req: import('node:http').IncomingMessage, socket: import('node:stream').Duplex, head: Buffer) => void} */ ( + (req, socket, head) => { + if (req.headers.upgrade === 'websocket') { + ws.handleUpgrade(req, socket, head); + } + } + ) + ); + // SSR vite.middlewares.use(async (req, res) => { const host = req.headers[':authority'] || req.headers.host; @@ -195,18 +232,8 @@ export async function preview(vite, vite_config, svelte_config) { 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 +279,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/control.js b/packages/kit/src/runtime/control.js index 64737a9cff1d..d14af8fd4cfb 100644 --- a/packages/kit/src/runtime/control.js +++ b/packages/kit/src/runtime/control.js @@ -12,6 +12,11 @@ export class HttpError { } else { this.body = { message: `Error: ${status}` }; } + // used by unjs/crossws to reject a websocket connection + // see https://github.com/unjs/crossws/blob/bc55c9765f436316213e9a3b907522cc86013a8c/src/hooks.ts#L69 + this.response = new Response(this.toString(), { + status: this.status + }); } toString() { diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index 55bcd87807b9..6465f690e008 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, the request probably doesn't have the + // `Upgrade` and `Connect` 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 429d523c3715..286952855b3b 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -59,6 +59,71 @@ 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>} + */ +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 the type UpgradeRequest is equivalent to Request + 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 the WebSocket hooks, it means + // an error has occured, so we return the bad response to the client + return { + upgrade: () => 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); @@ -346,25 +411,83 @@ 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) => { + // 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)); + } - if (state.prerendering && event.route.id !== null) { - response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); + return response; + }; + + if (upgrade && route?.endpoint) { + const node = await route.endpoint(); + if (node.socket) { + if (DEV) { + __SVELTEKIT_TRACK__('websockets'); + } + + return { + upgrade: async (req) => { + try { + return await options.hooks.handle({ + event, + resolve: async () => { + const init = (await node.socket?.upgrade?.(req)) ?? undefined; + return after_resolve(new Response(undefined, init)); + } + }); + } catch (e) { + return await handle_fatal_error(event, options, e); + } + }, + 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 diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index c5d2609fc006..6caa8c41d86d 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,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( - 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>; } export interface ManifestData { @@ -430,6 +433,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/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..a9661861546f --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/+page.svelte @@ -0,0 +1,45 @@ + + + + + + + + + +
    + {#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..adba5c06920c --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/ws/+server.js @@ -0,0 +1,45 @@ +/** @type {import('@sveltejs/kit').Socket} */ +export const socket = { + upgrade(req) { + console.log(`[ws] upgrade ${req.headers.get('origin')}`); + }, + + open(peer) { + console.log(`[ws] open: ${peer.id}`); + }, + + message(peer, message) { + const data = message.text(); + + console.log('[ws] message:', data); + + if (data === 'ping') { + peer.send('pong'); + return; + } + + if (data === 'add') { + peer.send('added'); + return; + } + + if (data === 'broadcast') { + peer.peers.forEach((socket) => { + socket.send(data); + }); + return; + } + + if (data === 'error') { + throw new Error('client error'); + } + }, + + close(peer, event) { + console.log('[ws] close', event); + }, + + error(peer, error) { + console.log('[ws] error', error); + } +}; 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/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