tRPC recipes
Subscribe to a typed procedure over WebSocket or SSE.
tRPC over SSE · Vite · Svelte
Before you start
Section titled “Before you start”Start with an existing Vite · Svelte app. Keep its renderer, routes and plugins. These files add one live view; Spinetab does not create your API.
Use a tRPC 11 server with a queue subscription returning a complete { open: number } value. The server router below defines that contract; mount it at /trpc with tRPC's HTTP/SSE adapter. The browser imports only its type. This standalone client recipe uses untracked full-state results; tracked events and transformers require their corresponding result handling.
The examples use same-origin URLs and declare
anonymous: true: no token is supplied, although cookies
still flow. For a separate API origin, use its URL and configure CORS or
your application's proxy. For private data, add
credentials and user scopes;
never put a secret in these files.
1. Install
Section titled “1. Install”pnpm add spinetab @trpc/client@^11.19.0 @trpc/server@^11.19.0Keep your framework's existing dependencies. See compatible versions if upgrading an older app.
2. Configure your app
Section titled “2. Configure your app”Merge this addition into your configuration; preserve existing plugins and options. Restart the dev server afterwards. The plugin bundles the worker and discovers the adapters imported below.
import { svelte } from "@sveltejs/vite-plugin-svelte";import { spinetab } from "spinetab/vite";import { defineConfig } from "vite";
export default defineConfig({ plugins: [svelte(), spinetab()] });3. Connect and render
Section titled “3. Connect and render”Save these files together in src/recipe/.
reconcile: "latest" fits this full-state contract: the next complete event restores the displayed value after a gap. Use a refresh/merge policy for deltas or partial GraphQL results. Reconnecting alone does not reconstruct missed state.
import { createSpinetab } from "spinetab";
export const spinetab = createSpinetab({ anonymous: true });export type Queue = { open: number };export type QueueView = { open?: number; problem?: string };import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
// Server-side contract. Mount this router on your existing tRPC server.export const appRouter = t.router({ queue: t.procedure.subscription(async function* ({ signal }) { let open = 0; while (!signal?.aborted) { yield { open: open++ }; await new Promise<void>((resolve) => { const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", finish); resolve(); }; const timer = setTimeout(finish, 1_000); signal?.addEventListener("abort", finish, { once: true }); }); } }),});export type AppRouter = typeof appRouter;import { createTRPCClient, httpBatchLink, splitLink } from "@trpc/client";import { spinetabSseLink } from "spinetab/trpc";import { spinetab } from "./live";import type { QueueView } from "./queue-types";import type { AppRouter } from "./router";
const trpc = createTRPCClient<AppRouter>({ links: [ splitLink({ condition: (operation) => operation.type === "subscription", true: spinetabSseLink<AppRouter>({ client: spinetab, url: "/trpc", reconcile: "latest", }), false: httpBatchLink({ url: "/trpc" }), }), ],});
export function watchQueue( next: (value: QueueView) => void, fail: (message: string) => void,) { const subscription = trpc.queue.subscribe(undefined, { onData: (value) => { fail(""); next(value); }, onConnectionStateChange: (state) => { if (state.error) fail(state.error.message); }, onError: (error) => fail(error.message), }); return () => subscription.unsubscribe();}<script lang="ts">import { onMount } from "svelte";import type { QueueView } from "./queue-types";import { watchQueue } from "./watch";
let data = $state<QueueView>();let error = $state("");onMount(() => watchQueue( (value) => { data = value; error = ""; }, (message) => { error = message; }, ),);</script>
{#if error} <p role="alert">{error}</p>{:else if data?.open === undefined} <p role="status">Loading queue…</p>{:else} <p><output>{data.open}</output> open</p>{/if}4. Mount the view
Section titled “4. Mount the view”<script lang="ts">import Recipe from "./recipe/Recipe.svelte";</script><Recipe />Your existing entry point mounts App as usual. Keep its renderer plugin; no additional application provider is required beyond those shown.
Check the result
Section titled “Check the result”Open the page in two tabs of the same browser profile. Both should show advancing queue values. Matching subscriptions share upstream work when SharedWorker is available; fallback runs independently in each tab. Remove one view and the other should keep updating. Removing the last view releases its subscription; connection closure can follow the adapter's idle delay.