Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/worker-functions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@tinloof/tanstack-wsr": minor
---

Add **worker routes** — the mirror of TanStack server routes, but the handlers run in the service worker. Declare them with the `worker` route option (`createFileRoute("/api/todos")({ worker: { handlers: { GET, POST, … } } })`); the route's path is the identity (no name, no codegen — the router is the dispatch table). This lets a single stateful client (a local-first / sync-engine client) live in the worker so reads and writes share one store, and a hard-load render reflects exactly what the user just did. Call them with `workerFetch(path, init)` — runs the handler directly in the worker (e.g. a wsr loader), `fetch`es the path on the main thread (the worker intercepts it), and returns a `503` on the origin server so callers can fall back. `broadcastToClients(message)` pushes invalidations to pages.

Also fixes two worker issues surfaced building on top of WSR:

- **Honor thrown redirects.** A `redirect()` thrown in `beforeLoad`/`loader` was captured on router state but never acted on, so the worker rendered the original route instead of redirecting (e.g. an auth gate to `/login`). It now returns a real redirect `Response`.
- **Keep the dev worker under the service-worker script-size limit.** The inline sourcemap roughly doubled the bundle and could push it past Chromium's ~6 MB SW limit (registration failing with "Failed to access storage"); the worker bundle no longer inlines a sourcemap, and resolves a single React/router copy to avoid duplicate-instance SSR hook errors.
71 changes: 69 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,81 @@ Two consequences:
- **404s follow the root.** Unmatched paths render in the worker only if the
**root** is marked; otherwise they fall through to the origin.

## API
## Worker routes

The mirror of TanStack [server routes](https://tanstack.com/start/latest/docs/framework/react/guide/server-routes),
but the handlers run in the **service worker**. Declare them with the `worker`
route option — the route's **path is the identity** (no name, no codegen; the
router is the dispatch table). This lets a single stateful client — a local-first
/ sync-engine client (Zero, Replicache, …) — live in the worker, with **reads and
writes both going through it**. Because a wsr render reads that same client, a
hard-load render reflects exactly what the user just did: one client, one store,
no cross-client round-trip, no stale flash.

```ts
// src/routes/api/todos.ts
import { createFileRoute } from "@tanstack/react-router";
import { getClient } from "@/client"; // your single in-worker client

export const Route = createFileRoute("/api/todos")({
worker: {
handlers: {
GET: async () => Response.json(await getClient().list()),
POST: async ({ request }) => {
await getClient().add(await request.json());
return new Response(null, { status: 204 });
},
},
},
// optional: a `server` handler for the same method is the no-worker
// (first-load / SSR) fallback — see TanStack server routes.
});
```

Call them with `workerFetch(path, init)`, which resolves in the right place:

- **in the worker** (e.g. a `wsr` route's `loader` during a hard load) → runs the
matched handler directly (a worker can't fetch itself);
- **on the main thread** (client navigation, event handlers) → `fetch`es the
path, which the worker intercepts — the same path it renders on;
- **on the origin server** (SSR, no worker yet) → a `503`, so the caller can fall
back (e.g. render an empty shell until the worker takes over).

```ts
import { workerFetch } from "@tinloof/tanstack-wsr/worker-fetch";

Two exports:
export const Route = createFileRoute("/")({
wsr: true,
loader: async () => {
const res = await workerFetch("/api/todos"); // worker on hard load, fetch on nav
return { todos: res.ok ? await res.json() : [] };
},
component: Todos,
});

// a mutation, from an event handler
await workerFetch("/api/todos", { method: "POST", body: JSON.stringify({ title }) });
```

Notes:

- Handlers are keyed by HTTP method (like server routes); model operations
RESTfully (`POST`/`PATCH`/`DELETE`) or put an action in the body.
- The worker must be **registered and controlling** the page for main-thread
calls (it is, right after it renders a document).
- To push live updates to pages (e.g. when synced data changes), call
`broadcastToClients(message)` from a handler and have pages listen on
`navigator.serviceWorker` to `router.invalidate()`.

## API

- `@tinloof/tanstack-wsr/vite` → `tanstackWsr({ router?, entry? })` — the Vite
plugin (`router` defaults to `./src/router`, must export `getRouter`).
- `@tinloof/tanstack-wsr/react` → `<WsrRegister hot? />` — registers `/sw.js`;
pass `import.meta.hot` for the dev auto-update bridge.
- `@tinloof/tanstack-wsr/worker-fetch` → `workerFetch(path, init)` and
`broadcastToClients(message)`; the `worker` route option declares the handlers
— see [Worker routes](#worker-routes).

## How it works

Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,17 @@
"./worker": {
"types": "./dist/worker.d.mts",
"default": "./dist/worker.mjs"
},
"./worker-fetch": {
"types": "./dist/worker-fetch.d.mts",
"default": "./dist/worker-fetch.mjs"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown src/vite.ts src/react.tsx src/worker.tsx src/devtools-stub.ts --format esm --dts --clean --no-config",
"build": "tsdown src/vite.ts src/react.tsx src/worker.tsx src/worker-fetch.ts src/devtools-stub.ts --format esm --dts --clean --no-config",
"prepare": "pnpm run build && (git config core.hooksPath .githooks || true)",
"dev": "pnpm -C examples/recipes dev",
"build-install": "pnpm install --frozen-lockfile",
Expand Down
32 changes: 29 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
// Augments TanStack Router with a first-class `sw` route option. Imported (for
// its types) by both ./worker and ./react, so an app picks it up whether it
// imports the worker helper or the <WsrRegister> component.
// Augments TanStack Router with first-class `wsr` and `worker` route options.
// Imported (for its types) by both ./worker and ./react, so an app picks them up
// whether it imports the worker helper or the <WsrRegister> component.
//
// `UpdatableRouteOptionsExtensions` is TanStack's sanctioned empty-interface
// hook for adding top-level route options (the same pattern `StaticDataRouteOption`
// uses for `staticData`). So `createFileRoute('/x')({ wsr: true })` type-checks,
// and the value is kept on `route.options.wsr` at runtime.
import type {} from "@tanstack/router-core";

/** HTTP methods a worker route can handle. */
export type WorkerRouteMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";

/**
* A worker route handler — the service-worker mirror of a server-route handler.
* Runs in the service worker (where a single stateful client can live) and
* returns a `Response`, just like `server.handlers`.
*/
export type WorkerRouteHandler = (ctx: {
request: Request;
}) => Response | Promise<Response>;

declare module "@tanstack/router-core" {
interface UpdatableRouteOptionsExtensions {
/**
Expand All @@ -28,6 +40,20 @@ declare module "@tanstack/router-core" {
* the lazy chunk loads, so it would be invisible.
*/
wsr?: boolean;

/**
* Request handlers that run in the SERVICE WORKER — the mirror of `server`
* route handlers, addressed by the route's own path. Reach them with
* `workerFetch(path, init)`: it runs the handler directly when already in
* the worker (e.g. a wsr loader on a hard load) and `fetch`es the path from
* the main thread (the worker intercepts it). Lets a single stateful client
* (a local-first / sync-engine client) live in the worker so reads and
* writes share one store. Pair with `server.handlers` for the same method to
* get an origin fallback when no worker is in control yet.
*/
worker?: {
handlers?: Partial<Record<WorkerRouteMethod, WorkerRouteHandler>>;
};
}
}

Expand Down
41 changes: 40 additions & 1 deletion src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,38 @@ export const DEVTOOLS_ALIAS = {
// to a caller-provided URL. `enforce: 'pre'` so it intercepts before Vite's core
// asset handling — which, in this separate build, would otherwise emit a DUPLICATE
// stylesheet instead of pointing at the app's already-hashed one.
// Force a SINGLE copy of React and the TanStack router framework in the worker
// bundle. The router is created by the app (with the app's @tanstack/react-router
// + React) but rendered here; if the worker resolves its OWN copy — which happens
// when this package is pnpm-linked for local dev, since the linked package has its
// own node_modules tree — then RouterProvider/HeadContent and React's hook
// dispatcher come from a different instance than the router/components, and SSR
// hooks read null ("Cannot read properties of null (reading 'useRef' / 'options')").
// dedupe + path aliases don't cut it (path aliases bypass exports maps for
// exports-only packages like @tanstack/router-core), so re-resolve these shared
// specifiers from the APP root using the bundler's own (exports-aware) resolution.
function singleCopyPlugin(): Plugin {
const root = process.cwd();
const SHARED =
/^(react|react-dom|@tanstack\/react-router|@tanstack\/router-core)(\/.*)?$/;
return {
name: "tanstack-wsr:single-copy",
enforce: "pre",
async resolveId(source, _importer, options) {
if (!SHARED.test(source)) return null;
const resolved = await this.resolve(
source,
path.join(root, "index.html"),
{
...options,
skipSelf: true,
},
);
return resolved;
},
};
}

function urlAssetPlugin(resolveUrl: (absPath: string) => string): Plugin {
// Opaque ids (no real extension) so Vite's core CSS/asset pipeline doesn't
// claim them and turn our JS shim into a stylesheet.
Expand Down Expand Up @@ -418,6 +450,7 @@ export async function bundleWorker(
// createClientRpc, giving browser parity), OR — if Start couldn't be
// loaded — the fallback guard that fails the build rather than letting
// raw server code leak into /sw.js.
singleCopyPlugin(),
...(hasStart
? plugins
: [serverCodeGuard(path.resolve(process.cwd(), "src") + path.sep)]),
Expand All @@ -429,7 +462,13 @@ export async function bundleWorker(
write: false,
minify: !dev,
target: "esnext",
sourcemap: dev ? "inline" : false,
// Never inline a sourcemap into the worker. The base64 map roughly
// DOUBLES the served script size, and browsers cap the service-worker
// script size (~6MB in Chromium) — an app graph that pulls in a sync
// engine plus an inline map blows past it, and registration fails with
// "Failed to access storage". (Can't emit a sibling .map from this
// in-memory build, so dev loses worker sourcemaps; the app stays mapped.)
sourcemap: false,
rollupOptions: {
input,
// One self-contained file (no code-splitting / vendor chunks).
Expand Down
118 changes: 118 additions & 0 deletions src/worker-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Worker routes — the mirror of TanStack server routes, but handlers run in the
// SERVICE WORKER. Declare them with the `worker` route option (see types.ts):
//
// createFileRoute("/api/todos")({
// worker: { handlers: { GET: async () => Response.json(await read()) } },
// })
//
// Reach them with `workerFetch(path, init)`. The route's PATH is the identity —
// no name, no build-time transform — and the router is the dispatch table. A
// single stateful client (a local-first / sync-engine client) can live in the
// worker so reads and writes share one store; a hard-load render reads back the
// same client.

import type { AnyRouter, RouterHistory } from "@tanstack/react-router";
import { createMemoryHistory } from "@tanstack/react-router";
import type { WorkerRouteHandler } from "./types";

export type { WorkerRouteHandler, WorkerRouteMethod } from "./types";

type GetRouter = (history?: RouterHistory) => AnyRouter;

function inWorker(): boolean {
return (
(globalThis as { __WSR_IN_WORKER__?: boolean }).__WSR_IN_WORKER__ === true
);
}

/**
* SW-side: match the request path against the route tree and run the matched
* route's `worker` handler for the method. Returns the handler's Response, or
* `undefined` if no worker handler matches (the caller then falls back to the
* network). Leaf-first, so the most specific route wins.
*/
export async function dispatchWorkerRoute(
getRouter: GetRouter,
request: Request,
): Promise<Response | undefined> {
const url = new URL(request.url);
const router = getRouter(
createMemoryHistory({ initialEntries: [url.pathname + url.search] }),
);
const { matchedRoutes } = router.getMatchedRoutes(url.pathname);
for (let i = matchedRoutes.length - 1; i >= 0; i--) {
const handlers = (
matchedRoutes[i].options as {
worker?: { handlers?: Record<string, WorkerRouteHandler> };
}
)?.worker?.handlers;
const handler = handlers?.[request.method];
if (handler) return handler({ request });
}
return undefined;
}

/**
* Whether the worker should try worker-route dispatch for a request before the
* navigation gate. Non-GET (mutations) always qualify; GET only when it asks for
* JSON — so document navigations (`Accept: text/html`) and assets are skipped.
*
* @internal exported for tests.
*/
export function isWorkerRouteRequest(request: Request): boolean {
if (request.method !== "GET") return true;
return (request.headers.get("accept") ?? "").includes("application/json");
}

/**
* Call a worker route, resolving in the right place:
* - in the worker (e.g. a wsr loader on a hard load) → runs the handler directly;
* - on the main thread → `fetch`es the path, which the worker intercepts;
* - on the origin server (no worker) → a 503, so callers can fall back (e.g.
* render an empty shell until the worker takes over).
*
* Returns a `Response`, like `fetch`.
*/
export async function workerFetch(
input: string,
init?: RequestInit,
): Promise<Response> {
if (inWorker()) {
const getRouter = (globalThis as { __WSR_GET_ROUTER__?: GetRouter })
.__WSR_GET_ROUTER__;
if (!getRouter) return new Response(null, { status: 503 });
// Base only matters to build a URL; route matching uses the path.
const request = new Request(new URL(input, "http://wsr.local"), init);
const res = await dispatchWorkerRoute(getRouter, request);
return res ?? new Response(null, { status: 404 });
}
if (typeof navigator !== "undefined" && navigator.serviceWorker) {
const headers = new Headers(init?.headers);
const method = (init?.method ?? "GET").toUpperCase();
// Mark GETs as data so the worker treats them as a worker route, not a
// document navigation or an asset (see isWorkerRouteRequest).
if (method === "GET" && !headers.has("accept")) {
headers.set("accept", "application/json");
}
return fetch(input, { ...init, headers });
}
return new Response(null, { status: 503 });
}

/** SW-side: post a message to every page (e.g. to trigger router.invalidate()). */
export async function broadcastToClients(message: unknown): Promise<void> {
const sw = globalThis as unknown as {
clients?: {
matchAll(opts?: {
includeUncontrolled?: boolean;
type?: string;
}): Promise<Array<{ postMessage(m: unknown): void }>>;
};
};
const list =
(await sw.clients?.matchAll({
includeUncontrolled: true,
type: "window",
})) ?? [];
for (const c of list) c.postMessage(message);
}
Loading
Loading