Skip to content
TypeScriptTypes that survive the network.

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.

$bun add @nice-code/action @nice-code/realm @nice-code/error valibot
app.ts ts
// @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; });
One secure connection
Authenticated, encrypted and self-reconnecting. Everything below runs over it.
Typed actions
Call into a server, a worker or a Durable Object with full type inference.
Live realms
One state tree the server owns. Clients watch it live and write optimistically.
Devtools included
One call in dev opens a window onto every action, realm and wire frame.
What are you building? v0.109.0 — what's new

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.

See every project shape + the supporting packages
01 · The shared contract — define once

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.

shared/ frontend server worker · DO one import on each side gives the same coordinates, types, validation, errors and state shape
shared/ one contract that both sides import unchanged frontend your client app backend server, worker, or Durable Object
Five words cover the whole system
connection
One authenticated socket per peer that reconnects itself. Actions and realms share it.
action
A typed call: input, output, and the errors it may throw.
channel
Which actions flow each way between two apps. Your client connects; your server accepts.
realm
A live state tree the server owns and every client watches.
avatar
Who a connected client is inside a realm. Rules check it.
Each word spelled out, and where it lives in the code
  1. 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
  2. 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
  3. 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
  4. 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
  5. 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.

shared/userActions.ts shared ts
// 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
  },
});
shared/gameRealm.ts shared ts
// 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:

shared/runtimeCoordinates.ts shared ts
// 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");
shared/channel.ts shared ts
// 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
});
No codegen, no drift
The schema is the compile-time type and the runtime validation. There is no generated artifact to rebuild, version, or watch go stale.
Errors are part of the contract
Each action declares what it throws. The far side catches the same NiceError, typed context intact, instead of parsing a message string.
Bring your own schema library
Any Standard Schema library works: Valibot, Zod, ArkType. The schemas you already validate with become the wire contract.
Follow the quick start
02 · @nice-code/action — things that happen

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.

frontend.ts frontend ts
// 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();
server.ts backend ts
// 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 };
Bi-directional
Over a live connection the server calls back to the client through the same channel. A push is a typed action going the other way.
No surprise errors
Every action declares what it throws. Expected errors arrive typed with their context. Anything else is cleanly "unhandled", so no catch block matches on strings.
Swap the wire, not the code
In-memory in tests, HTTP in dev, secure WebSocket in prod. The request and handler signatures never change.
Read the action guide
03 · @nice-code/realm — state everyone watches

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.

frontend.tsx frontend tsx
// 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 });
GameServerDO.ts backend ts
// 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.
Authoritative + optimistic
The server owns the tree. Clients predict with the same rules and rebase on confirm, and a rejected write rolls back cleanly.
Per-client visibility
View rules decide per avatar what leaves the server. A hidden branch's bytes are never sent.
Durable by default
On Cloudflare DOs you get a SQLite patch log, snapshots, and hibernation wake with patch replay, all automatic.
Read the realm guide
Kick the tires

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.

Better together

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.

@nice-code/error

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
Read the guide
@nice-code/state

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
Read the guide
@nice-code/common-errors

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, …
Read the guide
@nice-code/util

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
Read the guide
Past hello-world

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.

Start building — the quick start takes minutes