Keep SvelteKit remote functions live across tabs and users, on Cloudflare Workers.
A command publishes what it changed. A query declares what it follows. A Durable Object per tenant sends each open screen a small hint: a kind, and the id of the one record it's about, never the data itself. A screen holding reads that follow the hint reads them again, in one request. A screen that missed a hint reads everything again, and one on an older build has Kit check for the newer one.
There's no sync engine, no client cache to keep consistent and no polling, just invalidation with the reads you already have. docs/design.md has the decisions and their reasons.
Status: first version.
It's not on npm yet. Installed from GitHub, it builds itself, which pnpm runs only once the app allows it: add the key pnpm prints to allowBuilds in pnpm-workspace.yaml, then install again.
pnpm add github:xyrolle/sveltekit-liveIt needs:
- SvelteKit 3.1 or later with
experimental.remoteFunctions(remote functions are still experimental in Kit), and Svelte 5; @sveltejs/adapter-cloudflare, and a Workers compatibility date of 2026-04-07 or later.
examples/todos is a complete app. Its pieces:
1. The Worker exports the hub. adapter-cloudflare has no option for a Worker's other exports. So the adapter reads a config of its own (wrangler.kit.jsonc, with main: ".svelte-kit/kit-worker.js"), and Wrangler runs an entry that adds the hub to that Worker:
// worker.js
export { LiveHub } from "sveltekit-live/hub";
export { default } from "./.svelte-kit/kit-worker.js";2. Reads declare what they follow, in one module that the browser and the server both import:
// src/lib/live.ts
import { follows } from "sveltekit-live";
import { todo, todos } from "./todos.remote.ts";
export const followed = [
follows(todos, "todos"),
follows(todo, { kind: "todo", id: (id) => id }),
];todos is read again after any hint of kind todos. todo(id) is read again after a hint of kind todo that names its id, or that names none.
The kinds are declared once, in src/app.d.ts. A kind that is not declared then fails the typecheck, in follows and in publish alike. An app that declares none takes any string as a kind.
// src/app.d.ts
declare module "sveltekit-live" {
interface Register {
kind: "todos" | "todo";
}
}3. One command reads again:
// src/lib/live.remote.ts
import { readAgainCommand } from "sveltekit-live/server";
import { followed } from "./live.ts";
export const readAgain = readAgainCommand(followed);4. Commands publish what they changed. A command refreshes its own screen's reads as any Kit command does; publish tells everyone else, once the write has committed, through the hub of the tenant it changed. A Worker can die between the commit and the publication, and no screen would learn of the change. So the hints go in an outbox, in the transaction of the change, until the hub has them:
// src/lib/server/outbox.ts
export async function commit(change: D1PreparedStatement, hints: Hint[]) {
const kept = crypto.randomUUID();
await env.DB.batch([
change,
env.DB.prepare("insert into outbox (id, hints) values (?, ?)").bind(
kept,
JSON.stringify(hints),
),
]);
try {
await publish(env.LIVE.getByName("board"), hints);
} catch (failure) {
console.error(
`Publishing the hints of outbox row ${kept} failed; the cron publishes them.`,
failure,
);
return;
}
await env.DB.prepare("delete from outbox where id = ?").bind(kept).run();
}// src/lib/todos.remote.ts
export const toggle = command(v.string(), async (id) => {
await commit(
env.DB.prepare("update todos set done = 1 - done where id = ?").bind(id),
[{ kind: "todo", id }],
);
await todo(id).refresh();
});A command whose publication fails still succeeds, as its change did: the hints wait in the outbox. A Cron Trigger, every minute, publishes what the outbox holds, whether the hub refused it or the Worker died. It publishes through the hub's own publish, which Worker code that Vite doesn't build calls. A publication sent twice (the Worker died after the hub had it) costs each screen one more read.
// worker.js
import kit from "./.svelte-kit/kit-worker.js";
export { LiveHub } from "sveltekit-live/hub";
export default {
...kit,
async scheduled(_controller, env) {
const { results } = await env.DB.prepare(
"select id, hints from outbox",
).all();
for (const { id, hints } of results) {
await env.LIVE.getByName("board").publish(JSON.parse(hints));
await env.DB.prepare("delete from outbox where id = ?").bind(id).run();
}
},
};The screen that ran the command hears its own hint too, and reads the change once more.
5. A route takes the sockets. It decides who the user is and which tenant's hub they hear, or refuses with Kit's error():
// src/routes/live/+server.ts
export const GET: RequestHandler = ({ request, locals }) =>
connect(request, { hub: env.LIVE.getByName("board"), user: locals.user });An app whose sessions expire also passes when, connect(request, { hub, user, expires: session.expiresAt }), and the hub closes the socket then. When a user loses access before that (they signed out, or were removed), the app closes their sockets on every hub they hear. Each of their screens reconnects, and the route admits again only those it still would:
await disconnect(env.LIVE.getByName(tenant), user);6. The root layout follows changes, and says when the screen may be out of date:
<script lang="ts">
import { followChanges } from "sveltekit-live/client";
import { readAgain } from "#lib/live.remote.ts";
import { followed } from "#lib/live.ts";
const live = followChanges({ socket: "/live", followed, readAgain });
</script>
{#if live.staleSince}
<p role="status">
This may be out of date since {live.staleSince.toLocaleTimeString()}.
<button type="button" onclick={() => live.retry()}>Retry</button>
</p>
{/if}The screen may be out of date from the moment its socket is lost or a read fails, until the socket is back and the reads it calls for have landed. staleSince says so only once that has lasted 15 s, so a blip says nothing.
A read that fails on the server is read again with the next hint of its kind, or once the socket is back. Kit keeps its last answer in .current meanwhile, and the example shows that answer rather than awaiting the failed read again:
<!-- src/routes/Todo.svelte -->
<script lang="ts">
const read = $derived(todo(id));
const item = $derived(read.current ?? (await read));
</script>7. Pages run their commands in turn. A command that also refreshes its own screen's reads goes through inTurn, which runs it after the follower's read on its way, and holds the follower's next read until it lands. Kit keeps whichever command's answer lands last, so without it an older read-again could land over the command's newer answer. Pages wire nothing for reads.
<!-- src/routes/Todo.svelte -->
<input type="checkbox" checked={item.done} onchange={() => inTurn(() => toggle(id))} />In the example, pnpm dev runs it under Vite, where a tab sees another's changes once it is shown again. pnpm build and then pnpm preview (wrangler dev) run it live across tabs.
follows(read, ...what)(sveltekit-live): what a remote query follows. Each ofwhatis a kind, followed whole, or{ kind, id }, whereid(arg)names the record that the query's argument shows.Hint(sveltekit-live):{ kind; id?: string; userId?: string }, itskindone of the app's kinds. A hint with auserIdreaches only that user's sockets.Register(sveltekit-live): what the app'sapp.d.tsdeclares:kind, the union of its kinds. Undeclared, a kind is any string.publish(hub, hints)(sveltekit-live/server): sends the hints, numbered, to every socket onhub. Undervite devit does nothing.readAgainCommand(followed)(sveltekit-live/server): the command that screens read again through. Each read refreshes through its own query and that query's checks; the command has no guard of its own. It answers with the kinds and records of the reads that failed, which the screen keeps to read again. A refusal (4xx) is a read's own answer, not a failure.connect(request, { hub, user, expires })(sveltekit-live/server): hands the socket tohub, foruser. Given when the user's sessionexpires, the hub closes the socket then, and the screen reconnects to be admitted again, or refused; an invalidDatethrows aTypeError. It answers 426 to a request that is no WebSocket upgrade, and 403 to one from another origin.disconnect(hub, user)(sveltekit-live/server): closes every socket ofuseronhub: none of them hears anything more. Each screen reconnects, and one the socket route refuses keeps retrying, as after any lost connection. Undervite devit does nothing.followChanges({ socket, followed, readAgain })(sveltekit-live/client): called once, in the root layout. It opens one socket per tab, to the route atsocket, and answers with the screen'sFreshness. Undervite devit opens none, and reads everything again whenever the tab is shown again.Freshness(sveltekit-live/client):staleSince, since when the screen may be out of date, once that has lasted 15 s, orundefinedwhile it is current (reactive); andretry(), which reconnects at once if the socket is down and reads again what failed. Undervite devit is always current.inTurn(work)(sveltekit-live/client): runs a command after the follower's read on its way, and holds the follower's next read until it lands. It waits for that read 15 s at most. Before the root layout has mounted the follower, it runs the command at once.LiveHub(sveltekit-live/hub): the Durable Object that holds a tenant's sockets. Worker code that Vite doesn't build (a cron, a queue consumer, another Durable Object) calls its ownpublish(hints)anddisconnect(user)on the stub.publishanddisconnectfromsveltekit-live/servercall those same methods, and only add a no-op undervite dev.
- Changes from other tabs and users reach a screen as they happen only in the built Worker: under
vite devthere are no Durable Objects, sopublishdoes nothing and a screen reads everything again when it is shown again. Build, then runwrangler dev. - Sockets are for browsers.
connectrefuses a request whoseOriginis not the app's own, including one with noOrigin. - A hint without a
userIdreaches every socket on its hub: its kind and id are visible to every user connected there, as metadata, never data. Users who must not see each other's activity (staff and customers, say) belong on separate hubs,getByName(`${tenant}:staff`), with each change published to every hub that should hear it. - Access is checked at the handshake. Afterwards a socket closes when the app disconnects its user, or when the session
connectwas given expires. The screen then reconnects and is admitted again, or refused. - A command that publishes without an outbox (step 4) loses its change's hints if the Worker dies after the commit, and nothing notices, as no number was taken.
- The user id is the socket's one hibernation tag: at most 256 characters. A hub holds at most 32,768 sockets.
- A query missing from
followedis not live, and nothing checks that the list is complete. - The library has Kit check for a newer build, which turns Kit's
updated.currenttrue. Reloading is the app's decision, as only the app knows its unsaved drafts.
docs/followups.md lists what comes next.
MIT