Experimental WebSocket support
SvelteKit doesn't offer WebSockets support, but if you're using @sveltejs/adapter-node, tRPC-SvelteKit can spin
up an experimental WebSocket server to process tRPC procedure calls (support courtesy of SrZorro).
If you only need subscriptions, consider streaming them over server-sent events instead, which work with every adapter.
Caveats
- Works with @sveltejs/adapter-node exclusively.
- All tRPC procedures are handled by the WebSocket server, unless you build your own client with a
splitLink. - No WebSocket server is created when building or prerendering.
Setup
Install the package and its dependencies:
pnpm add trpc-sveltekit @trpc/server @trpc/client ws
pnpm add -D @sveltejs/adapter-nodeAdd the Vite plugin, which handles WebSocket connections in development and preview. It uses the /trpc path; use trpcWebSocketPlugin({ path }) for a different one.
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { vitePluginTrpcWebSocket } from 'trpc-sveltekit/websocket';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
sveltekit({ adapter: adapter(), preprocess: vitePreprocess() }),
vitePluginTrpcWebSocket
]
});
Create the production server entry point next to package.json, and use it to start
your app (e.g. "start": "node ./wsServer.js"). SvelteKitTRPCWSServer also accepts a second argument: { path, buildDir }.
import { SvelteKitTRPCWSServer } from 'trpc-sveltekit/websocket';
SvelteKitTRPCWSServer(import.meta.url);
Attach your router to the WebSocket server in your server hooks. Since no WebSocket server exists
while building, guard the call with building:
import { building } from '$app/env';
import { createContext } from '#lib/trpc/context.ts';
import { router } from '#lib/trpc/router.ts';
import { createTRPCWebSocketServer } from 'trpc-sveltekit/websocket';
if (!building) createTRPCWebSocketServer({ router, createContext });
The context receives the WebSocket connection options instead of a SvelteKit request event:
import type { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws';
export async function createContext(_opts: CreateWSSContextFnOptions) {
return {
// context information
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;
Usage
Define your router as usual; subscriptions can be written as async generators:
import type { Context } from '#lib/trpc/context.ts';
import { initTRPC } from '@trpc/server';
import { EventEmitter, on } from 'node:events';
export const t = initTRPC.context<Context>().create();
const ee = new EventEmitter();
export const router = t.router({
allMessages: t.procedure.subscription(async function* ({ signal }) {
for await (const [message] of on(ee, 'add', { signal })) {
yield `${new Date().toLocaleTimeString()}: ${message as string}`;
}
}),
addMessage: t.procedure
.input((input: unknown) => {
if (typeof input === 'string' && input.length > 0) return input;
throw new Error('Invalid input type');
})
.mutation(({ input: message }) => {
ee.emit('add', message);
return { success: true };
})
});
export type Router = typeof router;
Create the client with createTRPCWebSocketClient, which also accepts the wsLink and createWSClient options (transformer, retryDelayMs, lazy, …) and a url path:
import type { Router } from '#lib/trpc/router.ts';
import { createTRPCWebSocketClient } from 'trpc-sveltekit/websocket';
let browserClient: ReturnType<typeof createTRPCWebSocketClient<Router>>;
export function trpc() {
browserClient ??= createTRPCWebSocketClient<Router>();
return browserClient!;
}
Then call your procedures and subscribe to subscriptions in your pages:
<script lang="ts">
import { trpc } from '#lib/trpc/client.ts';
let messages: string[] = $state([]);
let el: HTMLElement | undefined = $state();
$effect(() => {
const subscription = trpc().allMessages.subscribe(undefined, {
onData(newMessage) {
messages.push(newMessage);
}
});
return () => subscription.unsubscribe();
});
$effect(() => {
el?.scrollIntoView();
});
</script>
<div class="container">
<h1>✨ WebSocket Messages ✨</h1>
<div class="messages">
{#each messages as message, i (i)}
{#if i === messages.length - 1}
<h2 bind:this={el}>{message}</h2>
{:else}
<h2>{message}</h2>
{/if}
{/each}
</div>
</div>
<style>
h2 {
margin-block: 0.5rem !important;
}
.container {
width: 100%;
height: 100%;
padding: 6rem;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
}
.messages {
width: 100%;
overflow-y: auto;
display: flex;
flex-direction: column-reverse;
align-items: center;
}
</style>
How it works
- The Vite plugin creates a
WebSocketServer(withnoServer: true, so it doesn't interfere with Vite's own HMR WebSocket) and upgrades the requests made to the tRPC path. - The server is stored in
globalThis[Symbol.for('trpc.sveltekit.wss')], so it can be reached from your SvelteKit server hooks. - In production,
SvelteKitTRPCWSServerimports theadapter-nodebuild output and attaches the same upgrade handler to its HTTP server. - In the browser,
trpc-sveltekit/websocketresolves to a client-only entry point, so no server code ends up in your bundle.
See the complete websocket example in the repository.