Skip to content
7 packagesOne type-safe spine.

Define things once.
Use them boundlessly.

One secure connection. Two typed planes. Call functions across the network with typed actions; share live, server-owned state with realms — optimistic writes, instant everywhere. Both multiplex over a single self-healing socket, errors stay typed from throw to catch, and devtools come along for free.

$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, self-healing — everything below rides it.
Typed actions
Call across the network, a worker, or a Durable Object — full inference.
Live realms
One authoritative state tree — observed live, written optimistically.
Devtools included
One call in dev opens a window over every action, realm, and wire frame.
What are you building? v0.57 — what's new

Adopt what you need.
Start where it points.

The @nice-code suite is a set of libraries you pick up as you need them, not a framework you 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

One module is the contract.
Every side imports it.

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, what state is shared. Usually it's scattered — an API spec here, duplicated types there, error handling by convention. In nice-code the contract is one ordinary TypeScript module that every side imports directly: no schema compiler, no generated client, nothing to drift.

shared/ frontend server worker · DO one import each side — the same coordinates, types, validation, errors, and state shape
shared/ one contract — both sides import it, unchanged frontend your client app backend server, worker, or Durable Object
Five words carry the whole system
connection
One authenticated, self-healing socket per peer — actions and realms multiplex over 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 — authenticated with the coordinates from the shared module (the handshake proves who's who), encrypted, and self-healing: a dropped socket redials and resumes on its own. Actions and realms are protocols multiplexed over 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; there is no generated client to drift. Actions live in their own file — 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 — it lists them, it doesn't define them. Each actions app builds one ActionRuntime from its shared coordinate and connects the channel through it; your server accepts — and 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, sliced by the realm's own visibility rules.

    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 what it may change, and the authenticated connection is what proves the avatar is who it claims.

    // 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 — a domain and a state tree both grow large, so both stand alone (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 — the frontend and the backend import it unchanged. The one piece each side builds for itself is its runtime, from a shared coordinate — and that's exactly 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),
  ],
});

…and two small, independent files wire those definitions to the network — where each side lives, and what flows each way. Both 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
Standard Schema in, inference out: Valibot, Zod, ArkType… whatever you already validate with becomes the wire contract.
Follow the quick start
02 · @nice-code/action — the action plane

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 carries the transport, runs the validation, and keeps the types true 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 — pushes are just typed actions in the other direction.
Deterministic errors
Every action declares what it throws. Expected errors arrive typed with their context; anything else is cleanly "unhandled" — no stringly-typed catch blocks.
Swap the wire, not the code
In-memory in tests, HTTP in dev, secure WebSocket in prod — the request/handler signatures never change.
Read the action guide
03 · @nice-code/realm — the state plane

One authoritative state tree,
every client live.

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 — the actions channel you just built, or a bare wire client with no actions at all — and you get a live, optimistically-writable projection, hosted, persisted, and rule-checked on the other side. Two planes, one connection. Or just the one plane — 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)
});

// Optimistic write — instant locally, confirmed (or rolled back) by the server
realm.update((draft) => { draft.players[me].x = 412; });

// 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 = serveRealmDurableObject(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 — rejects roll back cleanly.
Sliced visibility
View rules decide per avatar what leaves the server. A hidden branch's bytes are never sent.
Durable by default
On Cloudflare DOs: SQLite patch log, snapshots, hibernation wake with patch replay — automatic.
Read the realm guide
Kick the tires

Deployed and running,
right now.

Everything above is live on Cloudflare — real Durable Objects, real hibernation, real reconnects. Open one in two tabs (or two devices) and watch the same realm from both sides.

Better together

Eight packages,
one type-safe system.

The rest of the stack isn't add-ons — it's the shared substrate both planes are built from. 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 still stands perfectly well on its own.

@nice-code/error

Errors as data

Declare your error schema up front. Every id, every context field, every status — typed and reachable from any catch.

  • 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

Immer-backed store with fine-grained selectors, derived reactions, and patch streams. 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 Standard Schema validation error domain 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 (browser, Durable Objects, memory) and WebCrypto helpers — Ed25519, X25519, AES-GCM.

  • ITypedStorage over any backend
  • Ed25519 sign / verify
  • X25519 + AES-GCM shared keys
  • ClientCryptoKeyLink
Read the guide
Past hello-world

Built to ship,
documented to run.

When the prototype becomes the product, the answers are already written down — what the engine bounds for you, what upgrades promise, and every error the stack can raise. And your AI pair programmer gets the whole API as one file: point it at /llms.txt.

Start building — the quick start takes minutes