tRPC-SvelteKit v4.0.2

End-to-end typesafe APIs for your SvelteKit applications

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
npm
pnpm add trpc-sveltekit @trpc/server @trpc/client ws
pnpm add -D @sveltejs/adapter-node

Add the Vite plugin, which handles WebSocket connections in development and preview. It uses the /trpc path; use trpcWebSocketPlugin({ path }) for a different one.

vite.config.ts
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 }.

wsServer.js
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:

hooks.server.ts
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:

lib/trpc/context.ts
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:

lib/trpc/router.ts
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:

lib/trpc/client.ts
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:

routes/messages/+page.svelte
<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 (with noServer: 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, SvelteKitTRPCWSServer imports the adapter-node build output and attaches the same upgrade handler to its HTTP server.
  • In the browser, trpc-sveltekit/websocket resolves to a client-only entry point, so no server code ends up in your bundle.

See the complete websocket example in the repository.