-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
feat: native support for Websockets #12973
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
base: main
Are you sure you want to change the base?
Changes from all commits
d0b7f09
b69b2e0
aa69e1c
38c919c
2a022b5
c3a0bf7
c86e4e9
5858b49
9d56c50
46c8682
7791759
70202e3
92e3e41
db517f6
a43c49b
6469816
ee0c6ee
e737e02
42e2f2d
0abeb17
2a9971f
cc58820
29fdfec
fda8a68
ff58988
1fed29d
0ea72ab
6408350
182a666
dda7298
192840a
ec0b797
f3bed08
2f11dca
3f2679e
de37505
8dfad0c
21cb866
f70aea1
c0a5e3d
a6bcf60
d3a48d7
fa52645
e63dfc3
a0c54ab
c9457a4
51d4729
04b011d
2ab789f
4a6c8b3
9bdfc04
894f38b
e7726a6
8de07d1
c18b4e8
ddc9bc1
80e14fe
3e58143
7563cb3
fd5b98b
e79716c
3f884b1
dea0952
25f039a
4072369
875536b
84a1aa1
93d40b2
24f3b52
8c10aa4
52d607a
1ac7ccf
d9c4b1f
6a3581b
e762693
5c1f334
8f98d61
ac788fb
0a160fd
d6f4ee5
5e07cba
a0629ba
fef9dbe
940eb3d
e6caf6c
d437299
f203b8d
e6fed10
f13af3b
278c283
a458ff3
b766e06
3dc373c
9597c7f
9a839b5
183117f
a9a3f9b
e201b3b
74937fd
68bedce
ec630b8
19f273b
8517855
be4346d
63cfe11
7eb180f
c8de055
6b06673
09c1e2a
2fd6711
b03206d
d997737
06063f1
fef8521
0c1de43
6411a4c
db7f8c8
23ad389
3ffe264
367df8f
3bc744d
31d5617
fd05a21
bf9d9d3
ffadf9a
bb7aeb4
d4ca5eb
2b13bba
427aeef
2b6be27
4f7b7ec
f4e6c05
be2a624
651c15e
c902fe8
304e8a5
63646f3
64fff72
5570227
6e12953
ac17702
6640525
c5c9f0a
5ee9478
74fcc83
2896ecf
7905670
0560640
a72bac6
32981e0
f7a88dd
87c3071
5ec65b0
330c325
9582975
1c314ef
0c497bb
51783ae
737d352
fd956cb
3977af6
fbc9a8a
7d0b219
448f46a
ba58ee7
fe48df9
335f58c
e933ea7
7f74b95
12f7a97
7a6615e
82e35ea
71cb0c5
5d65b6f
e73415c
d122476
71376db
10e9274
5ff6324
5595d34
628323a
16ddd0c
52677d7
d6e64ab
98c2021
5de28ec
0a80c3c
13113c2
be8f67e
5e2bfc8
4d89bba
b34d7c8
4746851
74e2a86
ef0619d
dfaa63d
56b91df
514fb6d
4c0ee68
2b36ccc
13d11c5
c64ce20
4bab022
6ca1034
440e71c
a1f9e05
6931bfa
802d925
f46d2bf
b99608e
1998d7b
cf3361a
ef444e2
23b4f31
20ee709
0402340
8eb64dc
3de9608
2ec6f55
5fbc9c8
8cbc879
03bce3e
b625c85
53ca1fa
1d812e1
d65212c
ed0b01d
276c828
7db389f
3059744
08a3fcc
d8d803f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
--- | ||
'@sveltejs/adapter-cloudflare': minor | ||
'@sveltejs/adapter-node': minor | ||
'@sveltejs/kit': minor | ||
--- | ||
|
||
feat: add support for WebSockets |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@sveltejs/adapter-auto': patch | ||
--- | ||
|
||
fix: add error message when using WebSockets as they are unsupported |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -34,6 +34,23 @@ export default function (options) { | |
// Return `true` if the route with the given `config` can use `read` | ||
// from `$app/server` in production, return `false` if it can't. | ||
// Or throw a descriptive error describing how to configure the deployment | ||
}, | ||
webSockets: { | ||
socket: () => { | ||
// Return `true` if the production environment supports WebSockets, | ||
// return `false` if it can't. | ||
// Or throw a descriptive error describing how to configure the deployment | ||
}, | ||
getPeers: ({ route }) => { | ||
// Return `true` if the production environment supports WebSockets, | ||
// return `false` if it can't. | ||
// Or throw a descriptive error describing how to configure the deployment | ||
}, | ||
publish: ({ route }) => { | ||
// Return `true` if the production environment supports coordination among | ||
// multiple WebSockets, return `false` if it can't. | ||
// Or throw a descriptive error describing how to configure the deployment | ||
} | ||
} | ||
} | ||
}; | ||
|
@@ -51,10 +68,16 @@ Within the `adapt` method, there are a number of things that an adapter should d | |
- Output code that: | ||
- Imports `Server` from `${builder.getServerDirectory()}/index.js` | ||
- Instantiates the app with a manifest generated with `builder.generateManifest({ relativePath })` | ||
- Initialises the server by calling the `server.init({ env })` function | ||
- Listens for requests from the platform, converts them to a standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) if necessary, calls the `server.respond(request, { getClientAddress })` function to generate a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) and responds with it | ||
- expose any platform-specific information to SvelteKit via the `platform` option passed to `server.respond` | ||
- Globally shims `fetch` to work on the target platform, if necessary. SvelteKit provides a `@sveltejs/kit/node/polyfills` helper for platforms that can use `undici` | ||
- Bundle the output to avoid needing to install dependencies on the target platform, if necessary | ||
- Put the user's static files and the generated JS/CSS in the correct location for the target platform | ||
|
||
Where possible, we recommend putting the adapter output under the `build/` directory with any intermediate output placed under `.svelte-kit/[adapter-name]`. | ||
|
||
If your environment supports WebSockets, you will need to configure the `supports.webSockets` property returned by the adapter and integrate [`crossws`](https://crossws.unjs.io/adapters) into your adapter by outputting code that: | ||
|
||
- Initialises the server with the `crossws` adapter's `peers` and `publish` utilities by calling the `server.init({ env, peers, publish })` function | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is there also something about There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've added "you will need to configure the |
||
- Listens for requests from the platform that have an `Upgrade: websocket` header, converts them to a standard `Request` if necessary, calls the `server.resolveWebSocketHooks(request, { getClientAddress })` function to resolve the WebSocket hooks, and passes it to the `crossws` adapter's `resolve` option. |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,137 @@ | ||||||
--- | ||||||
title: WebSockets | ||||||
--- | ||||||
|
||||||
[WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) provide a way to open a bidirectional communication channel between a client and a server. SvelteKit uses [`crossws`](https://crossws.unjs.io/) to provide a consistent interface across different platforms. | ||||||
|
||||||
## Hooks | ||||||
|
||||||
A `+server.js` file can export a `socket` object with [hooks](https://crossws.unjs.io/guide/hooks), all optional, to handle the different stages of the WebSocket lifecycle. | ||||||
|
||||||
```js | ||||||
/** @type {import('./$types').Socket} **/ | ||||||
export const socket = { | ||||||
LukeHagar marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
upgrade(event) { | ||||||
// ... | ||||||
}, | ||||||
open(peer) { | ||||||
// ... | ||||||
}, | ||||||
message(peer, message) { | ||||||
// ... | ||||||
}, | ||||||
close(peer, details) { | ||||||
// ... | ||||||
}, | ||||||
error(peer, error) { | ||||||
// ... | ||||||
} | ||||||
}; | ||||||
``` | ||||||
|
||||||
### upgrade | ||||||
|
||||||
The `upgrade` hook is called before a WebSocket connection is established. It receives a [`RequestEvent`](@sveltejs-kit#RequestEvent) object that has an additional [`context`](https://crossws.unjs.io/guide/peer#peercontext) property to store abitrary information that is shared with that connection's [`Peer`](https://crossws.unjs.io/guide/peer) object. | ||||||
|
||||||
You can use the [`error`](@sveltejs-kit#error) function imported from `@sveltejs/kit` to easily reject connections. Requests will be auto-accepted if the `upgrade` hook is not defined or does not `error`. | ||||||
|
||||||
```js | ||||||
import { error } from "@sveltejs/kit"; | ||||||
|
||||||
/** @type {import('./$types').Socket} **/ | ||||||
export const socket = { | ||||||
upgrade({ request }) { | ||||||
if (request.headers.get('origin') !== 'my_allowed_origin') { | ||||||
eltigerchino marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
// Reject the WebSocket connection by throwing an error | ||||||
error(403, 'Forbidden'); | ||||||
} | ||||||
} | ||||||
}; | ||||||
``` | ||||||
|
||||||
### open | ||||||
|
||||||
The `open` hook is called when a WebSocket connection is opened. It receives a [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients. | ||||||
|
||||||
```js | ||||||
/** @type {import('./$types').Socket} **/ | ||||||
export const socket = { | ||||||
open(peer) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be a deviation from the crossws API but if we used this signature instead we would be able to access the
Suggested change
Similarly for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I love the experience this destructing provides, and love it today with the existing parts of kit |
||||||
// ... | ||||||
} | ||||||
}; | ||||||
``` | ||||||
|
||||||
### message | ||||||
|
||||||
The `message` hook is called when a message is received from the client. It receives the [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients and the [`Message`](https://crossws.unjs.io/guide/message) object which contains data from the client. | ||||||
|
||||||
```js | ||||||
/** @type {import('./$types').Socket} **/ | ||||||
export const socket = { | ||||||
message(peer, message) { | ||||||
// ... | ||||||
} | ||||||
}; | ||||||
``` | ||||||
|
||||||
### close | ||||||
|
||||||
The `close` hook is called when a WebSocket connection is closed. It receives the [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients and the close details object which contains the [WebSocket connection close code](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code#value) and reason. | ||||||
|
||||||
```js | ||||||
/** @type {import('./$types').Socket} **/ | ||||||
export const socket = { | ||||||
close(peer, details) { | ||||||
// ... | ||||||
} | ||||||
}; | ||||||
``` | ||||||
|
||||||
### error | ||||||
|
||||||
The `error` hook is called when a connection with a WebSocket has been closed due to an error. It receives the [`Peer`](https://crossws.unjs.io/guide/peer) object to allow interacting with connected clients and the error. | ||||||
|
||||||
```js | ||||||
/** @type {import('./$types').Socket} **/ | ||||||
export const socket = { | ||||||
error(peer, error) { | ||||||
// ... | ||||||
} | ||||||
}; | ||||||
``` | ||||||
|
||||||
## Accessing `RequestEvent` through `Peer` | ||||||
|
||||||
The [`Peer`](https://crossws.unjs.io/guide/peer) object has been extended to include the [`RequestEvent`](@sveltejs-kit#RequestEvent) object from the initial upgrade request. It can be accessed through the `peer.event` property. | ||||||
Comment on lines
+104
to
+106
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. see prior note on tweaking the |
||||||
|
||||||
## `getPeers` and `publish` | ||||||
|
||||||
The [`getPeers`]($app-server#getPeers) and [`publish`]($app-server#publish) functions from `$app/server` can be used to interact with your WebSocket connections from anywhere on the server. | ||||||
Comment on lines
+108
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't quite understand these functions — aren't they specific to a socket? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok @eltigerchino has educated me here on how these work. My feeling is that we shouldn't expose Instead I think we should probably encourage people to handle peers manually, if you want to interact with them from outside the socket handlers: // src/lib/topics/foo.ts
export const peers = new Set(); // src/routes/foo/+server.ts
import { peers } from '$lib/topics/foo';
export const socket = {
open({ peer }) {
peers.add(peer);
peer.send('hello');
},
close({ peer }) {
peers.delete(peer);
}
}; // src/routes/elsewhere/+server.ts
import { peers } from '$lib/topics/foo';
export async function POST({ request }) {
const message = await request.json();
for (const peer of peers) {
peer.send(message);
}
} One question we're not too sure about: will this work with Durable Objects, or is there some magic for saving/restoring that we don't have access to? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Durable objects have internal persistent state. As either Sqlite like db or KV. It also has in memory store. In example bellow I think sessions is in memory. https://github.com/cloudflare/workers-chat-demo/blob/master/src/chat.mjs Often I want to store state in the DO so new joiners get the current state from the DO the incremental state via WS. So very helpful if DO can be exposed on platform.env. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Helpful if there is a general way to package DOs with Sveltekit through adapter-cloudflare as there are many nice DO things: MCP servers, partykit. I'd really like to be able to put HelloWorld.durableObject.ts in a route folder and have it added to the worker output by the build. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @philholden see #13739 |
||||||
|
||||||
## 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 | ||||||
<script> | ||||||
import { onMount } from 'svelte'; | ||||||
|
||||||
onMount(() => { | ||||||
// To connect to src/routes/ws/+server.js | ||||||
const socket = new WebSocket(`/ws`); | ||||||
|
||||||
socket.addEventListener("open", (event) => { | ||||||
socket.send("Hello Server!"); | ||||||
}); | ||||||
|
||||||
// ... | ||||||
}); | ||||||
</script> | ||||||
``` | ||||||
|
||||||
See [the WebSocket documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) for more details. | ||||||
|
||||||
## Compatibility | ||||||
|
||||||
Please refer to the crossws [`Peer` object compatibility table](https://crossws.unjs.io/guide/peer#compatibility) and [`Message` object compatibility table](https://crossws.unjs.io/guide/message#adapter-support) to know what is supported in different runtime environments. |
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); | ||
|
||
|
@@ -9,6 +10,17 @@ const app_path = `/${manifest.appPath}`; | |
const immutable = `${app_path}/immutable/`; | ||
const version_file = `${app_path}/version.json`; | ||
|
||
/** @type {import('crossws').ResolveHooks} */ | ||
let resolve_websocket_hooks; | ||
/** @type {import('crossws/adapters/cloudflare').CloudflareAdapter} */ | ||
let ws; | ||
|
||
if (server.resolveWebSocketHooks) { | ||
ws = crossws({ | ||
resolve: (req) => resolve_websocket_hooks(req) | ||
}); | ||
} | ||
|
||
export default { | ||
/** | ||
* @param {Request} req | ||
|
@@ -17,11 +29,38 @@ export default { | |
* @returns {Promise<Response>} | ||
*/ | ||
async fetch(req, env, context) { | ||
const options = /** @satisfies {Parameters<typeof server.respond>[1]} */ ({ | ||
platform: { | ||
env, | ||
context, | ||
// @ts-expect-error webworker types from worktop are not compatible with Cloudflare Workers types | ||
caches, | ||
// @ts-expect-error the type is correct but ts is confused because platform.cf uses the type from index.ts while req.cf uses the type from index.d.ts | ||
cf: req.cf | ||
}, | ||
getClientAddress() { | ||
return req.headers.get('cf-connecting-ip'); | ||
} | ||
}); | ||
|
||
await server.init({ | ||
// @ts-expect-error env contains environment variables and bindings | ||
env | ||
env, | ||
peers: ws?.peers, | ||
publish: ws?.publish | ||
}); | ||
|
||
if (req.headers.get('upgrade') === 'websocket' && ws) { | ||
const hooks = await server.resolveWebSocketHooks( | ||
req, | ||
// @ts-ignore | ||
options | ||
); | ||
resolve_websocket_hooks = () => hooks; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a possible race condition here? If two upgrade requests came in at the same moment, could the It feels like if we had the ability to create the |
||
// @ts-ignore wtf is Cloudflare doing to these types | ||
return ws.handleUpgrade(req, env, context); | ||
} | ||
|
||
// skip cache if "cache-control: no-cache" in request | ||
let pragma = req.headers.get('cache-control') || ''; | ||
let res = !pragma.includes('no-cache') && (await Cache.lookup(req)); | ||
|
@@ -67,19 +106,11 @@ export default { | |
}); | ||
} else { | ||
// dynamically-generated pages | ||
res = await server.respond(req, { | ||
platform: { | ||
env, | ||
context, | ||
// @ts-expect-error webworker types from worktop are not compatible with Cloudflare Workers types | ||
caches, | ||
// @ts-expect-error the type is correct but ts is confused because platform.cf uses the type from index.ts while req.cf uses the type from index.d.ts | ||
cf: req.cf | ||
}, | ||
getClientAddress() { | ||
return req.headers.get('cf-connecting-ip'); | ||
} | ||
}); | ||
res = await server.respond( | ||
req, | ||
// @ts-ignore wtf is Cloudflare doing to these types | ||
options | ||
); | ||
} | ||
|
||
// write to `Cache` only if response is not an error, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
more a note to self as i work through the PR than anything, but: the description here is identical to the one for
socket
. Are there cases where one would be supported but not the other? If so it might be helpful if the descriptions explain the difference