Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: native support for Websockets #12973

Open
wants to merge 41 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
d0b7f09
example crossws implementation with `hooks.server.js` websocketHooks …
LukeHagar Nov 7, 2024
b69b2e0
Migrated from hooks to server.js export named socket, validated funct…
LukeHagar Nov 8, 2024
aa69e1c
Formatting and fix a test
LukeHagar Nov 8, 2024
38c919c
removed global comment
LukeHagar Nov 8, 2024
2a022b5
regenerated types
LukeHagar Nov 8, 2024
c3a0bf7
removed some log statements
LukeHagar Nov 8, 2024
c86e4e9
cleaning up previous implementation
LukeHagar Nov 8, 2024
5858b49
Thoroughly tested handle implementation
LukeHagar Nov 9, 2024
9d56c50
Cleaning
LukeHagar Nov 9, 2024
46c8682
regenerated types and ran formatter
LukeHagar Nov 9, 2024
7791759
generate types
eltigerchino Nov 11, 2024
70202e3
adjusted implementation to only use responses and the updated crossws…
LukeHagar Jan 23, 2025
92e3e41
corrected example
LukeHagar Jan 23, 2025
db517f6
swapped from browser to onMount
LukeHagar Jan 23, 2025
a43c49b
cleaned log statements
LukeHagar Jan 23, 2025
6469816
added a docs page
LukeHagar Jan 24, 2025
ee0c6ee
updated node adapter
LukeHagar Jan 24, 2025
e737e02
fixed imports and resolve type
LukeHagar Jan 24, 2025
42e2f2d
updating adapters
LukeHagar Jan 24, 2025
0abeb17
Update documentation/docs/30-advanced/15-websockets.md
LukeHagar Jan 24, 2025
2a9971f
Update packages/kit/src/exports/index.js
LukeHagar Jan 24, 2025
cc58820
Update documentation/docs/30-advanced/15-websockets.md
LukeHagar Jan 24, 2025
29fdfec
ditching reject function for existing error
LukeHagar Jan 24, 2025
fda8a68
Update documentation/docs/30-advanced/15-websockets.md
LukeHagar Jan 24, 2025
ff58988
Update documentation/docs/30-advanced/15-websockets.md
LukeHagar Jan 24, 2025
1fed29d
Update documentation/docs/30-advanced/15-websockets.md
LukeHagar Jan 24, 2025
0ea72ab
Update documentation/docs/30-advanced/15-websockets.md
LukeHagar Jan 24, 2025
6408350
moved adapter integration, added response getter to HttpError
LukeHagar Jan 24, 2025
182a666
TABS
LukeHagar Jan 24, 2025
dda7298
corrected package.json versions
LukeHagar Jan 24, 2025
192840a
normalize on error and the response prop
LukeHagar Jan 24, 2025
ec0b797
Merge branch 'main' into crossws
LukeHagar Jan 24, 2025
f3bed08
recreated lockfile
LukeHagar Jan 24, 2025
2f11dca
ran formatter
LukeHagar Jan 24, 2025
3f2679e
fix lint errors
LukeHagar Jan 24, 2025
de37505
Update documentation/docs/25-build-and-deploy/99-writing-adapters.md
LukeHagar Jan 24, 2025
8dfad0c
fix lint errors
LukeHagar Jan 24, 2025
21cb866
added an s
LukeHagar Jan 24, 2025
f70aea1
correcting lockfile issues
LukeHagar Jan 24, 2025
c0a5e3d
regenerated types
LukeHagar Jan 24, 2025
a6bcf60
adjusting types
LukeHagar Jan 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions documentation/docs/25-build-and-deploy/99-writing-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,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]`.

To add WebSockets to a SvelteKit adapter, you will need to handle upgrading the connection within the adapter. The crossws [adapter integration guides](https://crossws.unjs.io/adapters) may be a helpful reference.
120 changes: 120 additions & 0 deletions documentation/docs/30-advanced/15-websockets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
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 the client and server.

SvelteKit accepts a `socket` object in `+server.js` files that you can use to handle WebSocket connections to different routes in your app.

The shape of this socket object directly corresponds to the [Hooks](https://crossws.unjs.io/guide/hooks) type in `crossws` as this is the package being used to handle cross-platform WebSocket connections.

```js
export const socket = {
LukeHagar marked this conversation as resolved.
Show resolved Hide resolved
upgrade(req) {
// ...
},

open(peer) {
// ...
},

message(peer, message) {
// ...
},

close(peer, event) {
// ...
},

error(peer, error) {
// ...
}
};
```

### Upgrade

The `upgrade` hook is called when a WebSocket connection is established, and can be used to accept or reject the connection attempt.

Additionally, SvelteKit provides a WebSocket specific `accept` helper function alongside the existing `error` function used for HTTP errors to easily accept or reject connections.

```js
import { error, accept } from "@sveltejs/kit";

export const socket = {
upgrade(req) {
// Accept the WebSocket connection with a return
return accept();

// Reject the WebSocket connection with a standard SvelteKit error
error(401, 'unauthorized');
}

// ...
};
```

### Open

The `open` hook is called when a WebSocket connection is opened. It receives the [peer](https://crossws.unjs.io/guide/peer) WebSocket object as a parameter.

```js
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) WebSocket object and the [message](https://crossws.unjs.io/guide/message) as parameters.

```js
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) WebSocket object and the close event as parameters.

```js
export const socket = {
close(peer, event) {
// ...
}
};
```

### Error

The `error` hook is called when a WebSocket connection error occurs. It receives the [peer](https://crossws.unjs.io/guide/peer) WebSocket object and the error as parameters.

```js
export const socket = {
error(peer, error) {
// ...
}
};
```

## Connecting from the client

To connect to a WebSocket endpoint in SvelteKit, you can use the native `WebSocket` class in the browser.

```js
// To connect to src/routes/ws/+server.js
const socket = new WebSocket(`/ws`);
LukeHagar marked this conversation as resolved.
Show resolved Hide resolved
```

See [the WebSocket documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket) for more details.

## Compatibility

SvelteKit uses [`unjs/crossws`](https://crossws.unjs.io) to handle WebSocket connections. Please refer to their [compatibility table](https://crossws.unjs.io/guide/peer#compatibility) for the peer object in different runtime environments.
10 changes: 10 additions & 0 deletions packages/adapter-cloudflare-workers/files/entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Server } from 'SERVER';
import { manifest, prerendered, base_path } from 'MANIFEST';
import { getAssetFromKV, mapRequestToAsset } from '@cloudflare/kv-asset-handler';
import static_asset_manifest_json from '__STATIC_CONTENT_MANIFEST';
import crossws from 'crossws/adapters/cloudflare';

const static_asset_manifest = JSON.parse(static_asset_manifest_json);

const server = new Server(manifest);
Expand All @@ -20,6 +22,14 @@ export default {
async fetch(req, env, context) {
await server.init({ env });

const ws = crossws({
resolve: server.resolve()
});

if (req.headers.get('upgrade') === 'websocket') {
return ws.handleUpgrade(req, env, context);
}

const url = new URL(req.url);

// static assets
Expand Down
1 change: 1 addition & 0 deletions packages/adapter-cloudflare-workers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"dependencies": {
"@cloudflare/workers-types": "^4.20231121.0",
"@iarna/toml": "^2.2.5",
"crossws": "^0.3.2",
"esbuild": "^0.24.0"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions packages/adapter-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
},
"dependencies": {
"@cloudflare/workers-types": "^4.20241106.0",
"crossws": "^0.3.2",
"esbuild": "^0.24.0",
"worktop": "0.8.0-next.18"
},
Expand Down
10 changes: 10 additions & 0 deletions packages/adapter-cloudflare/src/worker.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Server } from 'SERVER';
import { manifest, prerendered, base_path } from 'MANIFEST';
import * as Cache from 'worktop/cfw.cache';
import crossws from 'crossws/adapters/cloudflare';

const server = new Server(manifest);

Expand All @@ -14,6 +15,15 @@ const worker = {
async fetch(req, env, context) {
// @ts-ignore
await server.init({ env });

const ws = crossws({
resolve: server.resolve()
});

if (req.headers.get('upgrade') === 'websocket') {
return ws.handleUpgrade(req, env, context);
}

// skip cache if "cache-control: no-cache" in request
let pragma = req.headers.get('cache-control') || '';
let res = !pragma.includes('no-cache') && (await Cache.lookup(req));
Expand Down
1 change: 1 addition & 0 deletions packages/adapter-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@sveltejs/vite-plugin-svelte": "^5.0.1",
"@types/node": "^18.19.48",
"polka": "^1.0.0-next.28",
"crossws": "^0.3.2",
"sirv": "^3.0.0",
"typescript": "^5.3.3",
"vitest": "^3.0.1"
Expand Down
2 changes: 2 additions & 0 deletions packages/adapter-node/src/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,5 @@ export const handler = sequence(
ssr
].filter(Boolean)
);

export const resolve = server.resolve;
15 changes: 13 additions & 2 deletions packages/adapter-node/src/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import process from 'node:process';
import { handler } from 'HANDLER';
import { handler, resolve } from 'HANDLER';
import { env } from 'ENV';
import polka from 'polka';
import http from 'node:http';
import crossws from 'crossws/adapters/node';

export const path = env('SOCKET_PATH', false);
export const host = env('HOST', '0.0.0.0');
Expand Down Expand Up @@ -31,7 +33,16 @@ let shutdown_timeout_id;
/** @type {NodeJS.Timeout | void} */
let idle_timeout_id;

const server = polka().use(handler);
const httpServer = http.createServer();
const server = polka({ server: httpServer }).use(handler);

const ws = crossws({
resolve: resolve()
});

httpServer.on('upgrade', (req, socket, head) => {
ws.handleUpgrade(req, socket, head);
});

if (socket_activation) {
server.listen({ fd: SD_LISTEN_FDS_START }, () => {
Expand Down
1 change: 1 addition & 0 deletions packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^0.6.0",
"crossws": "^0.3.3",
"devalue": "^5.1.0",
"esm-env": "^1.2.2",
"import-meta-resolve": "^4.1.0",
Expand Down
12 changes: 12 additions & 0 deletions packages/kit/src/exports/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,18 @@ export function redirect(status, location) {
);
}

export const acceptResponse = new Response(null, {
status: 200
});

/**
* Accepts a websocket upgrade request. When called during request handling, SvelteKit will accept the websocket upgrade request.
* @return {Response} This response instructs SvelteKit to accept the websocket upgrade request.
*/
export function accept() {
return acceptResponse;
}

/**
* Checks whether this is a redirect thrown by {@link redirect}.
* @param {unknown} e The object to check.
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '../types/private.js';
import { BuildData, SSRNodeLoader, SSRRoute, ValidatedConfig } from 'types';
import type { PluginOptions } from '@sveltejs/vite-plugin-svelte';
import { ResolveHooks } from 'crossws';

export { PrerenderOption } from '../types/private.js';

Expand Down Expand Up @@ -1277,6 +1278,7 @@ export class Server {
constructor(manifest: SSRManifest);
init(options: ServerInitOptions): Promise<void>;
respond(request: Request, options: RequestOptions): Promise<Response>;
resolve(): ResolveHooks;
}

export interface ServerInitOptions {
Expand Down
53 changes: 32 additions & 21 deletions packages/kit/src/exports/vite/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { URL } from 'node:url';
import crossws from 'crossws/adapters/node';
import { AsyncLocalStorage } from 'node:async_hooks';
import colors from 'kleur';
import sirv from 'sirv';
Expand Down Expand Up @@ -418,7 +419,7 @@ 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?.();

return () => {
return async () => {
const serve_static_middleware = vite.middlewares.stack.find(
(middleware) =>
/** @type {function} */ (middleware.handle).name === 'viteServeStaticMiddleware'
Expand All @@ -428,6 +429,36 @@ 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);

// 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);

// we have to initialize the server before we can call the resolve function to populate the webhook resolver in the websocket handler
await server.init({
env,
read: (file) => createReadableStream(from_fs(file))
});

/** @type {import('crossws/adapters/node').NodeAdapter} */
const ws = crossws({
resolve: server.resolve()
});

vite.httpServer?.on('upgrade', (req, socket, head) => {
if (req.headers['sec-websocket-protocol'] !== 'vite-hmr') {
ws.handleUpgrade(req, socket, head);
}
});

vite.middlewares.use(async (req, res) => {
// Vite's base middleware strips out the base path. Restore it
const original_url = req.url;
Expand Down Expand Up @@ -471,26 +502,6 @@ 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 request = await getRequest({
base,
request: req
Expand Down
3 changes: 3 additions & 0 deletions packages/kit/src/runtime/control.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export class HttpError {
} else {
this.body = { message: `Error: ${status}` };
}
this.response = new Response(this.toString(), {
status: this.status
});
}

toString() {
Expand Down
Loading
Loading