Define things once.
Use them boundlessly.
One secure connection for your whole app. Call server functions with typed actions. Share live, server-owned state with realms, with optimistic writes that every client sees live. Both share one connection that reconnects itself, errors stay typed from throw to catch, and devtools are included.
// @nice-code/error — a typed error domain const err_user = defineNiceError({ domain: "err_user", schema: { not_found: err<{ userId: string }>({ httpStatusCode: 404 }) }, }); // @nice-code/action — input, output & the errors it throws const getUser = actionSchema() .input({ schema: v.object({ userId: v.string() }) }) .output({ schema: v.object({ id: v.string() }) }) .throws(err_user, ["not_found"]); // Call it anywhere — local, WebSocket, HTTP — fully typed const user = await act_user.action.getUser .request({ userId: "u_1" }).runToOutput(); // @nice-code/realm — or share it live: authoritative state, optimistic writes gameRealm.update((s) => { s.online[user.id] = user.name; });
Take only what you need.
Start with what you're building.
The @nice-code suite is a set of libraries you pick up as you need them, with no framework to buy into whole. Find the shape closest to your project. Each one is a complete, runnable round trip.
Write the contract once.
Import it on every side.
Every client–server app has a contract: where each side lives on the network, which calls exist, what shapes they take, which errors they can raise, and what state is shared. Usually it's scattered across an API spec, duplicated types, and error handling by convention. In nice-code the contract is one ordinary TypeScript module that every side imports directly. There is no schema compiler and no generated client, so nothing can drift.
- 01 connection one secure link per peer
Every app holds exactly one connection to each peer it talks to. It is authenticated with the coordinates from the shared module, so the handshake proves who's who. It is encrypted, and a dropped socket redials and resumes on its own. Actions and realms share that one socket. A realm-only app opens it with createWireClient and needs no actions at all. An actions app opens the same kind of link by connecting its channel.
// shared/runtimeCoordinates.ts — the network label, its own file, shared by all export const frontendCoord = RuntimeCoordinate.env("frontend"); // frontend.ts — a realm-only app: @nice-code/wire alone opens the connection const client = createWireClient({ identity: frontendCoord, peer: serverCoord, storage, transports: [{ carrier: wsCarrier(() => ({ url })) }], }); // …an actions app opens the same kind of link — see channel, below
frontend shared/runtimeCoordinates.ts → frontend.ts - 02 action a typed call
A single typed operation: a command, a query, or a server-to-client push. You declare its input, its output, and the errors it may throw. That one declaration is both the compile-time type and the runtime validator, so there is no generated client to drift. Actions live in their own file, because a domain grows.
getUser: actionSchema() .input({ schema: v.object({ userId: v.string() }) }) .output({ schema: v.object({ name: v.string() }) }) .throws(err_user, ["not_found"]),
shared shared/userActions.ts - 03 channel who calls what
A channel declares which actions may flow each way between two apps: what your frontend may call on the server, and what the server may push back. It imports the action domains from their own files and lists them. It doesn't define them. Each actions app builds one ActionRuntime from its shared coordinate and connects the channel through it, and your server accepts. The one connection this opens carries realms too.
import { act_user } from "./userActions"; // its own file export const appChannel = defineChannel({ toAcceptor: [act_user], // frontend → server calls toConnector: [act_feed], // server → frontend pushes });
shared shared/channel.ts - 04 realm shared live state
Where actions are things that happen, a realm is state many parties watch: positions, presence, inventory, lobbies. The server owns the authoritative tree. Every client gets a live projection it can write to optimistically, limited to what the realm's visibility rules let it see.
export const gameRealm = defineRealm({ id: "game_realm", state: { players: t.record(…) }, // the shared tree rules: (r) => [ … ], // who sees + writes what });
shared shared/gameRealm.ts - 05 avatar you, inside a realm
When a runtime client attaches to a realm it does so as an avatar, its identity inside that world. Rules read the avatar to decide what it may see and change, and the authenticated connection proves the avatar is who it claims to be.
// each connected client attaches as an avatar avatars: { player: { persistentId: t.string() } }, // …a rule then reads the avatar to authorize a write .alter(({ avatar, params }) => avatar.persistentId === params.playerId),
shared shared/gameRealm.ts
The shared contract, assembled — a few small files, one import away
Your actions live in their own file and your realm in its own, because a domain and a state tree both grow large. Your error domains sit in a shared file too. Two more small, independent files handle the network: runtimeCoordinates.ts names where each side lives, and a thin channel.ts lists what flows each way. The connection it opens carries the realm too. Every file here is shared, imported unchanged by the frontend and the backend. The one piece each side builds for itself is its runtime, from a shared coordinate, which is what 02 and 03 do next.
// Actions get their own file — a domain grows; keep it self-contained import { err_user } from "./errors"; // A root domain is a namespace; a child domain groups related actions export const act_app = createActionRootDomain({ domain: "act_app" }); export const act_user = act_app.createChildDomain({ domain: "act_user", actions: { getUser: actionSchema() .input({ schema: v.object({ userId: v.string() }) }) .output({ schema: v.object({ name: v.string() }) }) .throws(err_user, ["not_found"]), // errors are part of the shape }, });
// State many parties watch — the tree, the avatars, the rules export const gameRealm = defineRealm({ id: "game_realm", avatars: { player: { persistentId: t.string() } }, state: { players: t.record(t.id("playerId"), t.object({ x: t.number(), y: t.number() })), }, rules: (r) => [ r.path("players.$playerId.{x,y}") .view(r.everyone) // everyone sees every player .alter(({ avatar, params }) => // only you may move yours avatar.persistentId === params.playerId || err_game.fromId("not_yours")), // a typed rejection ], });
…and two small, independent files connect those definitions to the network: where each side lives, and what flows each way. Both are imported, never redefined:
// Coordinates: the network label for each side. Defined ONCE, in their own // file, so the frontend, the server, and any worker agree on who's who. export const frontendCoord = RuntimeCoordinate.env("frontend"); export const serverCoord = RuntimeCoordinate.env("backend");
// The channel: which action domains flow each way. Imported by every side. import { act_user } from "./userActions"; // actions live in their own file import { act_feed } from "./feedActions"; // server → frontend pushes // The channel routes actions — the connection it opens carries realms too export const appChannel = defineChannel({ toAcceptor: [act_user], // frontend → server calls toConnector: [act_feed], // server → frontend pushes });
Call from one side.
Implement on the other.
Actions are the things that happen: commands, queries, server pushes. The client connects once, then calls actions like local functions. The server wraps the shared domain with real implementations and serves the channel. nice-code handles the transport, runs the validation, and keeps the types correct on the far side.
// Both coordinates come straight from the shared module — no magic strings import { appChannel, act_user, frontendCoord, serverCoord } from "./shared"; // One runtime per app, built from the shared frontend coordinate const frontendRuntime = new ActionRuntime(frontendCoord); // Connect once to the server coordinate you're dialing connectChannel(frontendRuntime, appChannel, { peer: serverCoord, storage, transports: [{ carrier: httpCarrier(() => ({ url: apiUrl })) }], }); // ...then call from anywhere — output fully typed const user = await act_user.action.getUser .request({ userId: "u_1" }).runToOutput();
// The channel + the server's own coordinate — both from the shared module import { appChannel, act_user, serverCoord } from "./shared"; // The server is just another peer — one runtime, from the shared coordinate const serverRuntime = new ActionRuntime(serverCoord); // Implement the action, then serve the channel const userHandler = act_user.wrapAsLocalHandler({ getUser: async ({ userId }) => db.users.find(userId), }); const server = serveChannel(serverRuntime, appChannel, { storage, handlers: [userHandler], carriers: [httpAcceptorCarrier()], }); // server.fetch is a standard web handler — Bun · Deno · Workers run it directly export default { fetch: server.fetch };
One state tree on the server,
live on every client.
A realm is for state that many parties watch: presence, positions,
inventory, lobbies. The gameRealm you defined above is everything the client needs to
predict and everything the server needs to enforce. Attach over your app's one connection, either
the actions channel you just built or a bare wire client with no actions at all. You get
a live projection you can write to optimistically, while the server hosts it, persists it and
checks the rules. A realm-only app never imports actions.
// Attach over your app's one connection — the wire client from 01 (realm-only), // or an actions app's connectChannel connector. Same realm either way. const realm = connectRealm(gameRealm, { avatar: { type: "player" }, connection: realmConnection(client), // ← or realmConnection(connector) }); const me = realm.avatar.persistentId; // who you are in this realm // Optimistic write — instant locally, confirmed (or rolled back) by the server. // There is no join step: your first write creates your entry. realm.update((draft) => { draft.players[me] = { x: 412, y: 96 }; }); // React — re-renders only when your slice changes const pos = useRealmValue(realm, (v) => v.players[me]); // Read-your-writes: after await, the store reflects the authoritative outcome await realm.intents.purchase({ purchaseId: newId(), itemId });
// Host it — on Cloudflare, persistence + hibernation come free const realms = createRealmHost(this.ctx, { realms: { game_realm: hostRealm(gameRealm, { avatarType: "player", // identity = the authenticated socket engine: { ctx: serverCtx, initialState: (info) => makeGenesis(info.instanceId), }, }), }, }); // A realm-only DO: wire serves the sockets — no actions, no channel const server = serveWireDurableObject(this.ctx, { identity: serverCoord.specify({ insId: this.ctx.id.toString() }), protocols: [realms.protocol], // ← the whole integration }); // Also serving actions? Register realms.protocol on action's // serveDurableObject instead — both planes share the same sockets.
Deployed and running,
right now.
Everything above is live on Cloudflare, with real Durable Objects, hibernation and reconnects. Open one in two tabs (or on two devices) and watch the same realm from both sides.
Small packages,
one type-safe system.
The rest of the stack is the shared foundation that actions and realms are built on.
Declare a thing once (an error, a schema, an identity) and it stays consistent, validated, and typed
everywhere it travels: through an action call, into a realm rule, down to storage, and back out of a
catch block. Each package also works on its own.
Errors as data
Declare your errors up front. Each id, context field and status is typed, and readable from any catch block.
- Typed context: optional / required
- Multi-id errors in one object
- Parent/child domain hierarchies
- castNiceError · handleWith · matchFirst
Reactive state
An Immer-backed store with fine-grained selectors, derived reactions and patch streams, plus a tear-free React adapter.
- Mutate via Immer drafts
- Selector subscriptions, no provider
- Reactions derive state from state
- Patches + devtools timeline
Validation, shared
A validation error domain for any Standard Schema library, plus Hono middleware that throws a NiceError instead of a bare 400.
- err_validation domain
- niceSValidator drop-in
- niceCatchSValidation interceptor
- Works with Valibot, Zod, …
Storage + crypto
Typed storage adapters for the browser, Durable Objects and memory, plus WebCrypto helpers for Ed25519, X25519 and AES-GCM.
- ITypedStorage over any backend
- Ed25519 sign / verify
- X25519 + AES-GCM shared keys
- ClientCryptoKeyLink
Built for production.
The manual is already written.
When the prototype becomes the product, the answers are already written down: what the engine
limits for you, what upgrades promise, and every error the stack can raise. Your AI pair
programmer can have the whole API as one file too. Point it at /llms.txt.