Skip to content

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

Draft
wants to merge 232 commits into
base: main
Choose a base branch
from
Draft

Conversation

LukeHagar
Copy link
Contributor

@LukeHagar LukeHagar commented Nov 8, 2024

This PR is a replacement to #12961 with a completely different Websocket implementation using crossws that should be fully compatible with all major runtimes.

Functionality has been validated locally using basic tests in the options-2 test app.

Here is the new usage experience.
+server.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 an error
                error(401, 'unauthorized');
	},

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

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

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

	error(peer, error) {
		//... handle socket error
	}
};

The newest implementation allows different sets of handlers to be implemented on a per-route basis. I have tested some basic uses of websockets locally to much success.

This PR is intended to:
Resolve #12358
Resolve #1491

Steps left

  • Ensure handle runs before upgrading requests
  • Gather feedback
  • Add or update tests
  • Fix the types
  • Update the adapters
  • Update the documentation
  • Add a changeset
  • Update language tools +server exports validation
  • Automatic typing for sockets

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests

  • Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpm changeset and following the prompts. Changesets that add features should be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.

Edits

  • Please ensure that 'Allow edits from maintainers' is checked. PRs without this option may be closed.

…ionality for different handlers at different URLs, added example use cases to options-2 test app, added upgrade function for supporting additional adapters, and much more.
Copy link

changeset-bot bot commented Nov 8, 2024

🦋 Changeset detected

Latest commit: d8d803f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@sveltejs/adapter-cloudflare Minor
@sveltejs/adapter-node Minor
@sveltejs/kit Minor
@sveltejs/adapter-auto Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@LukeHagar LukeHagar mentioned this pull request Nov 8, 2024
6 tasks
@Rich-Harris
Copy link
Member

preview: https://svelte-dev-git-preview-kit-12973-svelte.vercel.app/

this is an automated message

@eltigerchino eltigerchino changed the title Native support for Websockets feat: native support for Websockets Nov 11, 2024
@eltigerchino eltigerchino added the feature / enhancement New feature or request label Nov 11, 2024
@eltigerchino eltigerchino marked this pull request as draft November 11, 2024 03:18
@pi0
Copy link

pi0 commented Jan 22, 2025

@LukeHagar LMK, if you need any help with this (you can reach me also on discord @pi0)

@LukeHagar LukeHagar marked this pull request as ready for review January 23, 2025 14:52
@notYou263
Copy link

I am testing this and have a problem with the production build using adapter-node.
getPeers imported from $app/server returns a Set with a length of 0 and publish does not work even there is a peer connected and has subscribed to some topics.

But publish and getPeers are supported by adapter-node, right?

It is working fine with the dev server.

{
"devDependencies": {
    "@sveltejs/adapter-node": "https://pkg.pr.new/sveltejs/kit/@sveltejs/adapter-node@12973",
    "@sveltejs/kit": "https://pkg.pr.new/sveltejs/kit/@sveltejs/kit@12973",
  },
}
vite build

and then

HOST=127.0.0.1 PORT=3000 ORIGIN=http://localhost:3000 node build

@eltigerchino
Copy link
Member

I am testing this and have a problem with the production build using adapter-node. getPeers imported from $app/server returns a Set with a length of 0 and publish does not work even there is a peer connected and has subscribed to some topics.

But publish and getPeers are supported by adapter-node, right?

It is working fine with the dev server.

{
"devDependencies": {
    "@sveltejs/adapter-node": "https://pkg.pr.new/sveltejs/kit/@sveltejs/adapter-node@12973",
    "@sveltejs/kit": "https://pkg.pr.new/sveltejs/kit/@sveltejs/kit@12973",
  },
}
vite build

and then

HOST=127.0.0.1 PORT=3000 ORIGIN=http://localhost:3000 node build

Have you tried installing the latest version of this PR? (using the commit hash instead of the PR number)
For example: https://pkg.pr.new/sveltejs/kit/@sveltejs/kit@276c828

Otherwise, can you provide a minimal reproduction as a repository?

@philholden
Copy link

Looking at the Cloudflare implemention it does not expose the power of durable object based websockets. These sockets are hibernatable meaning clients can stay connected at zero cost when no messages are being sent. People leave browser tabs open so this saves tons of money and make the code much simpler as you don't need to come up with a heuristic of when it is fine to auto disconnect people.

I'd much prefer something lower level to this. The big problem for me is Sveltekit does not see wss:// requests.

Exposing platform features is more important to me than than consistent crossplatform API.

@LukeHagar
Copy link
Contributor Author

Hey @philholden,

The implementation uses crossws under the hood, and I imagine any changes you think should be made could be made to get the support you want.

Fancy having a look there to see how the implementation differs?

@Sillyvan
Copy link

Sillyvan commented May 2, 2025

Hey @philholden,

The implementation uses crossws under the hood, and I imagine any changes you think should be made could be made to get the support you want.

Fancy having a look there to see how the implementation differs?

they already support this in crossws https://crossws.h3.dev/adapters/cloudflare#durable-objects

@LukeHagar
Copy link
Contributor Author

Then it should be as easy as adjusting the cloudflare adapter

@Sillyvan
Copy link

Sillyvan commented May 2, 2025

portant to

Im not sure how you imagine this btw. The Durable Objects is its own thing, not related to the Sveltekit app in any way. as long as you use import crossws from "crossws/adapters/cloudflare-durable"; we should get hibernation support if you setup your DO to use them

Edit: actually no, the client dosent need to be aware of this what so ever from my understanding

@philholden
Copy link

Thanks for reply and pointing out the crossws Durable Object (DO) adapter.

I think the Hibernated DO is what is needed for most people using Cloudflare. Without it each CF Worker request spins up its own Worker at the edge.

  • Each WS connection stays pinned to its own Worker instance in a given datacenter, with no way to directly talk to another instance.
  • No broadcast. You cannot loop over “all connected sockets” in one Worker, because they’ll be scattered across hundreds of edge nodes.
  • No multi-client coordination. For things like chat rooms or multiplayer games.

Cloudflare’s docs call this out:

“If your application needs to coordinate among multiple WebSocket connections… you will need clients to send messages to a single-point-of-coordination. Durable Objects provide a single-point-of-coordination for Cloudflare Workers, and are often used in parallel with WebSockets to persist state over multiple clients and connections.”

https://developers.cloudflare.com/workers/examples/websockets/

So I think configuring this in @sveltejs/adapter-cloudflare would work. The DO classes would need to be appended to _worker.js in the output. Either automatically or through a warning in the console the user would be prompted to add bindings for these in the wrangler.jsonc.


I am trying to experiment with a modular full stack architecture in SvelteKit based on route folders. Where each folder is a vertical slice for a different functionality e.g. Posts, Images, Polls, Profiles, Wiki. Under that route you can have REST endpoints, WS, MCP (tools, prompts, resources), UI Editors and Viewers (ideally collaborative). These route folders behave like modules so you can compose an app by just dragging and dropping route folders into a project. If the design system is also in a route folder you could potentially swap out the design system too. Modules are just SvelteKit apps with a single route folder and a CLI tool can compose them.

@lts20050703
Copy link

Thank you for making this! I really appreciate it!
I just have one question: Is it possible to type both event.context and peer.context? I would love to have autocomplete and making sure I'm not having any typos when referring to a key later on in open/message

Comment on lines +44 to +48
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
},
Copy link
Member

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

```js
/** @type {import('./$types').Socket} **/
export const socket = {
open(peer) {
Copy link
Member

Choose a reason for hiding this comment

The 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 RequestEvent without adding the event property to the peer object (which is also a deviation from the crossws API, but a more subtle one, and potentially a more breaky one if crossws were to later add an event property of their own to peer)

Suggested change
open(peer) {
open({ peer, event }) {

Similarly for message etc — could be message({ peer, message, event }) and so on

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Comment on lines +104 to +106
## 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see prior note on tweaking the socket signature rather than monkey-patching peer

Comment on lines +108 to +110
## `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.
Copy link
Member

Choose a reason for hiding this comment

The 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? publish takes a topic but it's not clear how I would subscribe to a topic in the first place

Copy link
Member

Choose a reason for hiding this comment

The 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 getPeers and publish, because if getPeers returns all peers for all socket exports, then it becomes a source of bugs. Say I'm using getPeers somewhere in my app and then another developers adds another +server.ts with a socket export. Suddenly I'll start broadcasting to the wrong peers.

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?

Choose a reason for hiding this comment

The 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.

Choose a reason for hiding this comment

The 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.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// @ts-ignore
options
);
resolve_websocket_hooks = () => hooks;
Copy link
Member

Choose a reason for hiding this comment

The 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 resolve_websocket_hooks called by the ws instance's resolve hook be (incorrectly) shared by the two requests?

It feels like if we had the ability to create the ws instance here instead of sharing it between all requests, we'd be able to eliminate this risk and also reduce some of the indirection. But IIUC we can only call it once otherwise peers can't talk to each other

Comment on lines +507 to +516
if (node.socket?.upgrade) {
Object.defineProperty(event, 'context', {
enumerable: true,
value: context
});
result =
(await node.socket.upgrade(
/** @type {import('@sveltejs/kit').RequestEvent & { context: {} }} */ (event)
)) ?? undefined;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the upgrade handler should probably look like this, instead of adding event.context:

/** @type {import('./$types').Socket} **/
export const socket = {
	upgrade({ event, context }) {...}
};

This would match the change suggested in https://github.com/sveltejs/kit/pull/12973/files#r2073085655

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed above, this signature is a good experience IMO

@Sillyvan
Copy link

Sillyvan commented May 5, 2025

Small input to the Durable Object thematic. No there shouldn't be any magic needed. a simple browser WebSocket can connect to it even with hibernation.

BUT, due to websockets not supporting reconnecting out of the box, partysocket is often recommended. maybe thats a good reference if we want to add that as well

@Rich-Harris
Copy link
Member

Hey everyone, little update here. A group of maintainers are having a team offsite ahead of Svelte Summit later this week, and we devoted much of today's session to talking about websockets. It was a great conversation and we had some realisations that would have been much harder to reach without the in-person aspect.

We concluded that this PR makes some great headway BUT from an authoring perspective there's a bit of an impedance mismatch between the crossws design and where we'd ultimately like to be, so we need to iterate on the design some more. Specifically:

  • peers is a global set of all peers, even though it looks like they belong to the current hook. But even that's not really what you want — you want your peers to be other clients that are connected to the same URL. In other words if I have a /chat/[id]/+server.ts file that exports a socket, and I connect to /chat/1, I don't want my set of peers to include /chat/2 and /friends or whatever other sockets I've created
  • the crossws approach to this problem is topics/channels. but that's not totally ideal — it's a global namespace which is prone to clobbering, and it means (AFAICT) you have to do clunky things like peer.context.key = 'chat-' + event.params.id
  • combine the previous two bullet points, and it becomes obvious that the url.pathname of the initial request is the topic/channel. so we shouldn't expose peer, we should expose an abstraction that manages that for us
  • by extension, it should be possible to e.g. publish('/chat/1', 'hello') from a POST handler etc
  • an app might have many streams of realtime information (different stocks we're tracking the prices of or whatever). conceptually these are distinct, but treating each of them as an individual websocket is suboptimal. ideally you only need to create the actual connection once, and each socket is a purely logical construct
  • on the client side, this means exposing something like chat = connect('/chat/' + id)
  • while crossws is useful for implementing this stuff, from an API standpoint we don't really want to be tied to it
  • in fact it's advantageous if we're not tied to websockets at all! websockets are kinda difficult to scale horizontally. it would be great if an adapter could specify (for example) a POST+SSE mechanism that achieved the same goal with the same API

We have some ideas we really like on what connect and an updated socket API could look like, but they're not fleshed out enough to share just yet. Please bear with us!

@LukeHagar
Copy link
Contributor Author

I appreciate the thought being put in here, and I can agree with all of the points on the interface and usage experience.

And I'm very happy the conversation is ongoing :D

Thanks for the update!

@tcurdt
Copy link

tcurdt commented May 6, 2025

Cool this is being worked on. Enjoy the summit.
And sorry for jumping in here with a question but...

combine the previous two bullet points, and it becomes obvious that the url.pathname of the initial request is the topic/channel.

I am just thinking about a notification counter that sits in the page header on all pages.
What would be the channel name be with that approach?

publish('/notifications', 'hello')
notifications = connect('/notifications')

with or without requiring /notifications/+server.ts to exist?

@philholden
Copy link

an app might have many streams of realtime information (different stocks we're tracking the prices of or whatever). conceptually these are distinct, but treating each of them as an individual websocket is suboptimal. ideally you only need to create the actual connection once, and each socket is a purely logical construct.

websockets are kinda difficult to scale horizontally.

This really depends on your backend and usecase. Durable Objects are near perfect for scaling many kinds of collaborative apps. E.g. game servers, or an educational apps designed to serve a classroom where all the pupils are in the same location. The DO servers will end up getting automatically located at the edge near the users. There is no real penalty in terms of pricing for having one Durable Object per game or classroom instance. game/123 would be one DO lobby/345 would be another. This makes WebSockets pretty horizontally scalable. True it is hard to have a server single server with more than 100,000 WebSocket requests per second. But if you have one server per room then I think you mostly don't need that.

One of the problems I see for crossws is that while it did allow for a Durable Object backed DO. I think it is hard to make this work for different classes of DO in the same app E.g. the game server and the lobby server (each requires their own DO). The web socket adapter would need to be set up in adapter-cloudflare so it is unclear how you would route thing so the correct class of DO is backing your new WebSocket from SvelteKit.

https://crossws.h3.dev/adapters/cloudflare#durable-objects

So for me there are two problems how to hear a wss// request from SvelteKit and forward to my DO and secondly how to bundle DOs with my SvelteKit app (and have them work in dev #1712). CrossWs seems like a too high level abstraction for what I am trying to achieve. Not against a highlevel abstraction that makes an app portable across different server platforms. But would be good to have some escape hatches for those building against a specific platform and wanting to use its features.

@pi0
Copy link

pi0 commented May 7, 2025

Thanks for the feedback @Rich-Harris (just found about updates from a chat with @benmccann)

Understandably, you might prefer to implement WebSockets standalone and design differently.

I would love to have a chat to discuss more with svelekit team. There are a lot of compatibility details part from end user API that could be reused.

Only to answer some of your concerns:

peers is a global set of all peers, even though it looks like they belong to the current hook.

Peers only get notified about the topics they subscribe, but this is true; they are not isolated (between different resolved hook-sets). I had been talking with other folks before, and we might change that.

by extension, it should be possible to e.g. publish('/chat/1', 'hello') from a POST handler, etc

The global publish method is available on the main instance. Peers can auto-join topics mapped from their routes to have an API like publish('/chat/1')

Great if an adapter could specify (for example) a POST+SSE mechanism that achieved the same goal with the same API

CrossWS also exposes an abstraction over SSE to give the same API: https://crossws.h3.dev/adapters/sse

--

@philholden Re CF DO, we recently added new resolver option that gives full flexibility on routing upgrade request, to Durable instance. h3js/crossws#130

@arxpoetica
Copy link
Member

Hell to the yes about a << POST+SSE mechanism >>

@philholden
Copy link

Thanks @pi0 the routing looks helpful.

@eltigerchino eltigerchino marked this pull request as draft June 4, 2025 01:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature / enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

implement "Upgrade" as a function in a api route to support websocket Native support for web sockets