RPC Data Boundaries
Boundary.rpc is Weft's primitive for server-resolved, client-refreshable data. One Rpc from the app's merged RpcGroup backs a single render boundary across four lifecycles: server-side render, hydrate-replay, client refetch, and client-first SPA mount.
Quick example
import { Boundary, h, Subscribable } from "@weftui/core";
import { Stream } from "effect";
import { GetStock } from "./data/inventory";
Boundary.rpc(
GetStock, // the rpc: its _tag + schemas drive the boundary
() => ({ id: product.id }), // payload thunk: a fresh typed input per call
(
resource, // render: receives a reactive Resource, not a bare value
) =>
h.p([
"in stock: ",
h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
]),
{ fallback: h.p("loading stock…") }, // shown only on a client-first mount
);render receives a reactive Resource, resolved through the ambient AppRpcClientTag seam that @weftui/router provides. The same rpc serves every lifecycle, so SSR-replay, refetch, and client-first mount are one mechanism, not three.
The contract / handler split
The rpc contract (pure Schema) is shared with the client. The rpc handler, the only code that touches server-only services, lives in a Layer the client never imports. Tree-shaking keeps it and its transitive imports out of the browser bundle:
// data/inventory.ts
import { Rpc, RpcGroup } from "effect/unstable/rpc";
import { Context, Effect, Layer, Schema } from "effect";
// --- Contract (shareable with the client) ---
export const Stock = Schema.Struct({ units: Schema.Number });
export const StockKey = Schema.Struct({ id: Schema.Number });
// `_tag` ("GetStock") = the stable boundary id; payload schema = the typed input.
export const GetStock = Rpc.make("GetStock", { payload: StockKey, success: Stock });
// The app's merged RpcGroup: shared by both the client and server router wiring.
export const StockRpcs = RpcGroup.make(GetStock);
// --- Handler (server-only; the client never imports this) ---
class Inventory extends Context.Service<
Inventory,
{ readonly stockFor: (id: number) => Effect.Effect<typeof Stock.Type> }
>()("Inventory") {}
const InventoryLive = Layer.succeed(Inventory, {
stockFor: (id) => Effect.succeed({ units: 7 + (id % 5) }),
});
// `toLayer` binds each rpc to its handler; `Layer.provide` discharges its deps so R = never.
export const StockLive = StockRpcs.toLayer({
GetStock: (payload) => Effect.flatMap(Inventory, (inv) => inv.stockFor(payload.id)),
}).pipe(Layer.provide(InventoryLive));The split is enforced structurally by which files each entry imports, not by a bundler plugin. Declare server-only services with ServerTag (not Context.Service) when they might be referenced from universal code: the brand makes a leak into render a compile error at the hydrate call site, rather than a runtime surprise.
Wiring the router
Pass the merged group to both sides, plus the handler Layer on the server:
// entry-server.ts: in-process client over the handlers + POST /_eui/rpc endpoint
import { RouterServer } from "@weftui/router/server";
import { StockLive, StockRpcs } from "./data/inventory";
const rpc = { group: StockRpcs, handlers: StockLive } as const;
export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });
export const render = (url: string) =>
Effect.runPromise(RouterServer.render(App, { document: documentShell, rpc, url }));// entry-client.ts: network client posting to /_eui/rpc
import { WeftApp } from "@weftui/dom/client";
import { RouterApp, RouterLive } from "@weftui/router/client";
import { Effect } from "effect";
import { StockRpcs } from "./data/inventory";
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));- Server (
RouterServer) mounts the handler Layer atPOST /_eui/rpc, so a client refetch re-runs it on the server. It also exposes an in-process client over the same handlers for SSR resolution, never a network hop. - Client (
RouterLive) provides a network flat rpc client over the merged group, posting to<origin>/_eui/rpc.
In a router-less mount there is no AppRpcClientTag, so a Boundary.rpc resolves to a typed, descriptive "needs router/rpc" error, not a defect.
The four lifecycles
| Lifecycle | Trigger | What happens |
|---|---|---|
| SSR | server render | Resolve the rpc in-process, successSchema-encode the result inline as <script type="application/json">, render render(seededResource) to HTML. |
| Hydrate | hydrate on the client |
Read the inline payload at the cursor, successSchema-decode it, seed the Resource, adopt the DOM. Never re-calls the rpc (replay). |
| Refetch | resource.refetch |
Call the rpc again over POST /_eui/rpc (re-runs the handler on the server), patch the subtree in place (stale-on-error). |
| Client-first mount | SPA nav into a boundary with no payload | Render options.fallback, fork the rpc call, swap in render(resource) once it resolves. |
Because the SSR path seeds value await-first, the SSR HTML and the adopted DOM are byte-identical. There is no fallback flash on the SSR/hydrate path; fallback renders only on a client-first mount:
Boundary.rpc(GetStock, () => ({ id: product.id }), render, {
fallback: h.p("loading stock…"), // unused on SSR/hydrate; shown only client-first
});The Resource handle
render receives a Resource<A> (A = the rpc's decoded success), not a bare value. After hydrate the region is live:
| Field | What it gives you |
|---|---|
value |
A Subscribable of the current data: seeded with the SSR payload, updated on a successful refetch. |
refetch |
An Effect<void> that re-resolves the rpc with a fresh payload() and pushes the new value. |
pending |
A Subscribable<boolean>: true while a refetch is in flight. |
error |
A Subscribable<Option<unknown>>: Some with the last refetch error (stale-on-error). |
(resource) =>
h.section({ class: "product" }, [
h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
h.span([Stream.map(Subscribable.changes(resource.pending), (p) => (p ? "refreshing…" : ""))]),
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh stock"),
]);Wire refetch to an event with onclick: () => resource.refetch. The handler returns the Effect, which the renderer runs in a detached fiber. A failed refetch leaves the previous value intact (stale-on-error): it does not unmount the subtree or raise into a failure Boundary.
Channel algebra
Boundary.rpc<R extends Rpc.Any, C extends Node<any, any>>(
rpc: R,
payload: () => Rpc.Payload<R>,
render: (resource: Resource<Rpc.Success<R>>) => C,
options?: { fallback?: Renderable },
): Node<Node.Error<C> | Rpc.Error<R>, Node.Context<C>>;- Error =
render's error union plus the rpc's typedRpc.Error<R>(neverfor an rpc with noerrorschema). - Requirement = exactly
render'sR, untouched: there is noprovide/RServerto discharge (the handler lives in the rpc Layer) and noExcludeis applied. A server-only tag leaked intorenderstays inR, wherehydrate'sAssertNoServerOnlyrejects it.
Typed-failure replay
Give the rpc an error schema and a resolved rpc error on the SSR pass is errorSchema-encoded into an inline failure payload:
import { Rpc, RpcGroup } from "effect/unstable/rpc";
import { Schema } from "effect";
export class OutOfStock extends Schema.TaggedErrorClass<OutOfStock>()("OutOfStock", {
reason: Schema.String,
}) {}
export const GetStock = Rpc.make("GetStock", {
payload: StockKey,
success: Stock,
error: OutOfStock,
});The nearest enclosing failure Boundary renders its fallback. On the client, hydrate decodes that payload and re-raises the same error into the same boundary, reproducing the identical fallback DOM, flash-free and without re-resolving the rpc (replay, never retry):
Boundary.catchTag({ tag: "OutOfStock", fallback: (e) => h.p({ class: "error" }, e.reason) }, [
Boundary.rpc(
GetStock,
() => ({ id: product.id }),
(resource) => h.p([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
),
]);A transport defect (no Cause.findErrorOption), or an rpc with no error schema, is not replayed; it propagates: a server-side fallback and a client mismatch.
Full example
A /products/:id page with a refetchable live-stock Boundary.rpc, sealed into a single-route router app. This is the whole file set for the router + rpc parts (drop it alongside a dev server that bridges entry-server.ts's handler into Vite or any Web-platform server; see examples/router-ssr/server.ts and its co-located vite.config.ts for a working one).
// src/data/inventory.ts
/**
* The product's live stock: rpc contract + server-only handler. See "The
* contract / handler split" above for why this file is safe to import from
* both `app.ts` (contract only) and `entry-server.ts` (contract + handler).
*/
import { Rpc, RpcGroup } from "effect/unstable/rpc";
import { Context, Effect, Layer, Schema } from "effect";
export const Stock = Schema.Struct({ units: Schema.Number });
export const StockKey = Schema.Struct({ id: Schema.Number });
export const GetStock = Rpc.make("GetStock", { payload: StockKey, success: Stock });
export const StockRpcs = RpcGroup.make(GetStock);
class Inventory extends Context.Service<
Inventory,
{ readonly stockFor: (id: number) => Effect.Effect<typeof Stock.Type> }
>()("Inventory") {}
const InventoryLive = Layer.succeed(Inventory, {
stockFor: (id) => Effect.succeed({ units: 7 + (id % 5) }),
});
export const StockLive = StockRpcs.toLayer({
GetStock: (payload) => Effect.flatMap(Inventory, (inv) => inv.stockFor(payload.id)),
}).pipe(Layer.provide(InventoryLive));// src/app.ts
/**
* Shared, isomorphic router app: one `/products/:id` page with a refetchable
* live-stock `Boundary.rpc`. Side-effect-free (no mount/hydrate call), so
* both entries and any test can import `App` directly.
*/
import { Boundary, Component, h, Subscribable } from "@weftui/core";
import { notFound, Router } from "@weftui/router";
import { Schema, Stream } from "effect";
import { GetStock } from "./data/inventory";
const idParam = { id: Schema.NumberFromString };
const productRoute = Router.route("products/:id", {
path: idParam,
component: ({ path }) => {
if (!Number.isFinite(path.id) || path.id < 0) return notFound();
return Boundary.rpc(
GetStock,
() => ({ id: path.id }),
(resource) =>
h.section({ id: "page" }, [
h.h2(`Product ${path.id}`),
h.p([
"in stock: ",
h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
]),
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh stock"),
]),
{ fallback: h.p("loading stock…") },
);
},
});
const Shell = Component.gen(function* () {
const outlet = yield* Router.Outlet;
return yield* h.div({ id: "app" }, [outlet]);
});
export const App = Router.router(Router.layout({ component: Shell }, [productRoute]), {
notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]),
});// src/entry-server.ts
/**
* Server entry: renders the matched route to a hydratable HTML document,
* wiring the rpc's shared group and its server-only handler Layer.
*/
import { Component, h } from "@weftui/core";
import { Router } from "@weftui/router";
import { RouterServer } from "@weftui/router/server";
import { Effect } from "effect";
import { App } from "./app";
import { StockLive, StockRpcs } from "./data/inventory";
const rpc = { group: StockRpcs, handlers: StockLive } as const;
const documentShell = Component.gen(function* () {
const app = yield* Router.Outlet;
return yield* h.html({ lang: "en" }, [
h.head([h.meta({ charset: "utf-8" }), h.title("Weft shop")]),
h.body([
h.div({ id: "root" }, [app]),
h.script({ type: "module", src: "/src/entry-client.ts" }),
]),
]);
});
export const render = (url: string): Promise<{ html: string; status: number }> =>
Effect.runPromise(RouterServer.render(App, { document: documentShell, rpc, url }));
export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });// src/entry-client.ts
/**
* Client entry: hydrates the server-rendered markup in `#root`, wiring the
* rpc's shared group so `resource.refetch` posts to `/_eui/rpc`.
*/
import { WeftApp } from "@weftui/dom/client";
import { RouterApp, RouterLive } from "@weftui/router/client";
import { Effect } from "effect";
import { App } from "./app";
import { StockRpcs } from "./data/inventory";
const root = document.getElementById("root")!;
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));When to use
Boundary.rpc: data resolved on the server (behind a server-only service, credential, or private network) and rendered into the initial HTML. It stays refreshable on the client over the same rpc.Boundary.suspend: async data that loads purely on the client; see the Boundary API.
See also
Boundary.rpcAPI reference: signature,Resource,RpcOptions,AppRpcClientTag- Server-Side Rendering: the SSR + hydration model this builds on
- Routing:
@weftui/router, which provides theAppRpcClientTagseam - examples/router-ssr: a runnable shop with an SSR-replayed, refetchable live-stock
Boundary.rpc