Point0by 1gr14
  • Menu
    • GitHub
    • Blog
  • Introduction
    • Overview
    • Getting Started
    • Full Overview
    • Benchmarks
    • Points
  • Points
    • Page
    • Layout
    • Component
    • Provider
    • Mountable
    • Query
    • Infinite Query
    • Mutation
    • Action
    • Subscription
    • Root
    • Base
    • Plugin
  • Methods
    • Validation
    • Loader
    • Context
    • Middleware
    • Loading & error
    • .with
    • Mapper
    • Transformer
    • Stage Methods
  • Core
    • Navigation
    • SSR
    • RSC
    • Socket
    • Request
    • Response
    • Error handling
    • Env
    • Head
    • MDX
    • Assets
    • File upload
    • OpenAPI
    • Query client
    • Events
    • Infer
  • Engine
    • Engine Config
    • Engine Runtime
    • CLI
    • Dev
    • Build
    • Compiler
    • Generator
    • Project MCP
    • Docs MCP
    • Importer
    • Public dir
    • Testing
    • Deploy
    • Bun or Vite
  • Extra
    • SsrStore
    • CookieStore
    • Basic Auth
    • CORS
    • Cache-Control
    • Compress
  • Examples
    • Basic
    • Vite
    • Better Auth
    • Socket
    • Capacitor
    • Expo
  • 1gr14
    • Home
    • Start0
    • Support
    • Education
    • Blog
    • Author
  • Community
    • Discord
    • Telegram
  • Open Source
    • Point0
    • Route0
    • Error0
    • Flat
  • Account
    • Sign In
    • Sign Up
Point0by 1gr14
Building open-source software for the glory of the Lord Jesus Christ ☦️
With love for developers of all backgrounds around the world ❤️
Terms of ServicePrivacy PolicySergei Dmitriev 2026 😎

Socket

  • Category: Core

On this page

  1. Declaring a channel
  2. Connecting
  3. The two connect paths
  4. The connection identity
  5. Spaces
  6. Joining from the client
  7. Enrolling from the server — .enroller
  8. Join guards and the local floor
  9. Server handlers: client → server
  10. Sending from the client
  11. The handler as a mutation, query, or infinite query
  12. Answering early — the imperative reply
  13. A reply never streams
  14. Guards and audit
  15. Client handlers: server → client
  16. Sending from the server — the addressing watershed
  17. Collecting replies
  18. Listening in components
  19. Iterating the pushes
  20. Managing connections
  21. Patching the identity — amendIdentity
  22. Kicking from rooms — space.kick
  23. Enumerating — connections and memberships
  24. Self-referential callbacks
  25. Where it's stored — the engine server.backplane
  26. Ready-made adapters
  27. The contract
  28. Connections and memberships on the client
  29. On pages and components — .with(channel) and .with(space)
  30. Client helpers
  31. Reconnect
  32. Resumable connections
  33. Delivery
  34. Observability
  35. The events
  36. Connect vs open — the claim is the watershed
  37. The join family straddles the registration
  38. The client families carry the counters and markers
  39. The socket singles
  40. Recipes
  41. Live query
  42. User space — hot personal pushes
  43. Global space vs channel handlers
  44. Presence
  45. Origin allowlist for the socket
  46. The usual point machinery
  47. Under the hood
  48. Reference
  49. What each callback receives
  50. Options
  51. Method surface

Sockets are four point types built in three levels. A channel is a live, authenticated connection: the client connects with an input, the server checks it and returns an identity for the connection. A space grows from a channel — a family of rooms of one shape; the client joins it and the server's .joiner decides which rooms it enters (or, with no .joiner, the server enrolls it and nobody joins from outside). Inside live the message types — the handlers: a serverHandler (client sends → .serverReply answers) and a clientHandler (server sends → targeted clients receive, their .clientReply optionally answers back). A handler grows from a channel (it addresses connections) or from a space (it addresses rooms).

One WebSocket per client carries everything — every message arrives with its channel, and a room message with its space and room, so the client knows which connections to wake and the server knows which rooms each socket is in. The socket opens with the first connection and closes when the last is gone. Connecting is not a WebSocket frame — it's a normal HTTP request with everything a request has (headers, cookies, middleware); only the messages after it travel over the socket. Joining and leaving spaces are cheap socket frames.

import { root } from '@/lib/root'
import { authorizedOnlyPlugin } from '@/lib/auth'
import { z } from 'zod'

// the channel: authentication. The connector returns the connection identity.
export const appChannel = root.lets
  .channel()
  .use(authorizedOnlyPlugin) // ctx.me — the current user
  .connector(async ({ ctx }) => ({ userId: ctx.me.user.id, role: ctx.me.role }))
  .channel()

// a space of chat rooms — one room per chat, its room shape declared at the opener
export const chatSpace = appChannel.lets
  .space<{ chatId: string }>()
  .input(z.object({ chatId: z.string() }))
  .joiner(async ({ input, identity }) => {
    const ok = await isMember(identity.userId, input.chatId)
    return ok ? { chatId: input.chatId } : undefined // undefined = joined nothing
  })
  .space()

// client → server — a space handler: its callbacks get the typed room
export const messageSendHandler = chatSpace.lets
  .serverHandler()
  .clientSend(z.object({ text: z.string().min(1) }))
  .serverReply(async ({ input, identity, room }) => {
    const message = await prisma.message.create({
      data: {
        text: input.text,
        chatId: room.chatId,
        authorId: identity.userId,
      },
    })
    void messageReceivedHandler.sendToClient(message, { room }) // to the room
    return message // what the sender gets back
  })
  .serverHandler()

// server → client — components subscribe themselves, no reply needed
export const messageReceivedHandler = chatSpace.lets
  .clientHandler()
  .serverSend(messageSchema)
  .clientHandler()
export const ChatPage = ({ chatId }: { chatId: string }) => {
  const membership = chatSpace.useMembership({ chatId })
  const [messages, setMessages] = useState<Message[]>([])

  messageReceivedHandler(membership).useOnMessageFromServer(({ message }) => {
    setMessages((prev) => [...prev, message])
  })

  const onSend = async (text: string) => {
    await messageSendHandler(membership).sendToServer({ text })
  }
  // ... a <appChannel.Connection> at the app root holds the connection
}

The channel connection lives once at the app root; every space rides it. That split is the whole design: one authenticated pipe, many cheap room memberships over it.

Declaring a channel

Open with .channel(), close with .channel(options?). The .connector returns the connection identity — the value itself, bare:

export const appChannel = root.lets
  .channel()
  .connector(async ({ request }) => {
    if (!request.state.userId) {
      throw new AppError('Unauthorized', { code: 'UNAUTHORIZED' })
    }
    return { userId: request.state.userId } // this connection's identity
  })
  .channel()

See points for the .lets notation. A channel needs no input and no connector — with neither, it's a bare authenticated pipe with an empty identity. That identity is typed strictly empty ({}): reading a field off it — in a joiner, an enroller, a .serverReply — is a compile error, and its $identity matchers admit no keys (an empty matcher still means "every connection"). And amendIdentity is refused on it by both layers — a compile error on the call and a runtime throw underneath it: a connectorless channel has nothing declared to amend, so the runtime identity can never grow keys the {} type never admitted.

Sockets also need the engine's socket server option on:

Engine.create({ server: { socket: true /* … */ } /* … */ })

It turns on the bare WebSocket endpoint (GET /_point0/<scope>/websocket) that every channel of the scope multiplexes over. The default is false — the endpoint does not exist unless enabled, and with it off declaring socket points is refused outright (the closer throws at startup: the feature is off, so the code that would run them is not there). To keep channels declared while the endpoint stays off, name the feature: features: { socket: true } — then the declaration is legal and the channel-without-endpoint state logs a startup warning instead.

That one line does a second thing: it turns on the socket feature. Sockets are optional, and an app that never asked for them does not download them — with the feature off, the compiler folds every socket body in @point0/core to a throw in the CLIENT build and @point0/core/socket never reaches the browser (~71.6 KB raw / ~16 KB gzip — the delta between the two @point0/core rows of the size table). You can name the feature yourself when the two differ — features: { socket: true } ships the code without serving an endpoint, a client's own features: { socket: false } strips just that bundle — see features. Everything the socket exports lives behind one door, the @point0/core/socket subpath; the main @point0/core entry deliberately does not re-export it.

Connecting

useConnection / connect send a regular request to the channel endpoint — not a WebSocket frame. So .connector runs like a loader on any endpoint: full request, headers, cookies, set, middleware, plugins. Any server check works, and throwing rejects the connect — the client sees the typed error in connection.error.

The channel endpoint is dual-method like a query: a short input rides a GET (?input=), a long one a POST — chosen by URL length. What differs from a query is the tail: once the pipeline passes, the connection binds to the client's WebSocket. By default every connect — the first and every re- — is the ticket path: the response carries a one-time ticket the socket claims (opening first if needed). The upgradable: true channel option buys back the cold-start round trip: with no socket open yet and a short input, the client upgrades the connect GET itself into the socket — the handshake IS the connect, no ticket. Either way, middleware and plugins see only this handshake — messages after it carry no headers or cookies: everything a later reply needs must come out of the connector, which establishes it as the identity.

The two connect paths

Both paths run the identical server pipeline — same input, same middleware and ctx, same connector — and both deliver cookies: the request's cookies follow the browser's ordinary rules on each, and set.cookies from the pipeline reaches the client on the upgrade too (the engine carries the effect headers into the 101, and browsers apply a handshake's Set-Cookie). What differs is the transport shell around that pipeline:

Ticket path (default)Upgrade path (upgradable: true, cold start only)
Method and bodyGET or POST by input length; binary inputs ride the POSTGET only — the input rides ?input= under the URL-length cap; too long or binary falls back to the ticket path
Custom client headersa plain fetch — .fetchOptions headers and credentials applybrowser JS cannot attach custom headers to a handshake (new WebSocket(url) exposes none) — only browser-set ones (Cookie, Origin) arrive
CORSordinary CORS rulesa WebSocket handshake skips CORS entirely — control Origin with a middleware
Connector errorsthe typed error arrives in connection.errora browser hides a failed handshake's response — the client falls back to a ticket attempt, which surfaces the same typed error (one extra round trip on the error path)
Reconnectsalways this pathnever — the upgrade is a cold-start optimization only

That asymmetry is why the upgrade is opt-in. A connect that authenticates through custom headers (.fetchOptions) would silently pass the connector WITHOUT them on the upgrade — under header-or-fallback auth that means a different identity, not an error. Default-off keeps every connect one shape the connector can rely on; turn upgradable on for same-origin apps on cookie auth, where the handshake carries everything the connector reads and the saved round trip is free.

The connection identity

The connector returns the identity object — anything the connection needs to select and authorize later, established at connect time:

// identity from auth
.connector(async ({ request }) => ({ userId: request.state.userId }))

// identity from the input plus a lookup
.connector(async ({ input, ctx }) => ({
  workspaceId: input.workspaceId,
  role: await roleIn(ctx.me.user.id, input.workspaceId),
}))

Identity is the connection's server-side credential. The socket carries no cookies or headers, so whoever the client is was decided at connect time and lives here. Handlers read it, kick / the $identity selections match over it, and it is rebuilt on a reconnect or a server refresh — or patched in place with amendIdentity. It is deliberately not called ctx: that word stays a request's context (the ctx argument, from .use/.ctx), so the connector's return is the identity.

The connector returns no room and no data — only the identity. Rooms come from spaces; data a client needs lives in an ordinary query right next to the connection — a connect is not a query, it's a pipe.

.serverReply(async ({ input, identity, room, connectionId }) => {
  identity.userId // established at connect — never leaves the server
  room.chatId // the room this message addresses (space handlers only)
  connectionId // this connection — target it back with { connectionId }
})

On the server a connection is always the bare connectionId string — in callbacks, in events, in targets, in listings. It is ephemeral (a reconnect mints a new one): address rooms, not connection ids, for anything long-lived.

Spaces

A space grows from a channel and describes a family of rooms of one shape. The room shape is declared at the opener, the way a component declares its props — then take an .input, decide rooms in .joiner, close with .space():

export const chatSpace = appChannel.lets
  .space<{ chatId: string }>() // the room shape — every room of this space is one of these
  .input(z.object({ chatId: z.string() }))
  .joiner(async ({ input, identity }) => {
    const ok = await isMember(identity.userId, input.chatId)
    return ok ? { chatId: input.chatId } : undefined
  })
  .space()

The generic rides either spelling of the opener — appChannel.lets.space<{ chatId: string }>() or appChannel.lets<{ chatId: string }>('space', 'chat'). Everything downstream reads it: the .joiner and .enroller returns are checked against it, handlers get it as room, and every target (room, $room, except, kick, enroll, the memberships.* listings) is typed by it. Omit it and the room is the empty object — a space with exactly one room, {} (the global space); a joiner that then returns anything keyed is a type error, which is the nudge to declare the shape.

Checked means exactly, extra keys included. A room the joiner returns with a key the shape never declared is a type error, not a shrug — the snapshot IS the address, so { chatId: 'c1', extra: 1 } is a different pub/sub topic than { chatId: 'c1' } and would never hear a send aimed at it. Pass a wide row through and TypeScript stops you; map it down to the room shape instead:

.joiner(async ({ input }) => {
  const chat = await db.chat.find(input.chatId) // { id, chatId, title, … }
  return { chatId: chat.chatId } // not `chat` — the room is its keys, nothing more
})

The input is not the room. It is what the client passes to join — the request. The joiner turns it into rooms, and may well return something else entirely ({ a, b } in, one { pair: 'a+b' } out), several rooms, or none.

What a membership IS. A membership is information about participation — never an address. On the server it is exactly (space, channel, connectionId, rooms); the wire carries no membership entity at all (frames carry the space, the rooms and the connection id). On the client it is that same participation plus its input, which is the client-side hold-dedup key — like a query key, not an address. Addressing is always by ROOM: the room is what a push targets, what a handler binds, and what a socket-query key carries. Passing a membership where a room is expected is only the convenience "take its single room".

.joiner(({ input, identity, connectionId }) => …) runs over the socket on every join and gets the join input plus the connection's ready identity (authentication already happened on the channel — there's no request here) and its connectionId. To read this connection's current rooms, use the synchronous local floor — thisSpace.memberships.server.local.rooms({ connectionId }). It returns which rooms the client enters:

  • one room object → joins that room;
  • an array of room objects → joins several at once;
  • an empty array or nothing (undefined) → joins none — a clean deny;
  • a throw → a join error, delivered to the client on the membership as status: 'error' (exactly as a connector throw is a connect error).

An array on the input is normal — a live-entities space joins forty rooms in one frame:

export const entitySpace = appChannel.lets
  .space<{ model: string; id: string }>()
  .input(
    z.object({
      items: z.array(z.object({ model: z.string(), id: z.string() })),
    }),
  )
  .joiner(({ input, identity }) =>
    input.items.filter((item) => canSee(identity, item)),
  )
  .space()

No .joiner at all → clients cannot join this space. A join is refused with a typed POINT0_SOCKET_JOIN_NOT_ALLOWED error — thrown on the client before a frame leaves, and answered the same way by the server for anyone who frames one by hand. Such a space is the server's to fill: .enroller at connection setup, space.enroll() mid-life. That is deliberate — the rooms of an enroller-only space are chosen from the identity, and a client-authored room must never be able to slip in beside them.

No .joiner data return either — a join yields only room objects; data lives in queries.

Joining from the client

useMembership(input, options?) holds a membership while mounted; join is its imperative twin. The membership is query-shaped — it reports where the join stands and which rooms it actually got:

const membership = chatSpace.useMembership({ chatId })
membership.status // 'joining' | 'joined' | 'error' | 'closed'
membership.rooms // the rooms the server admitted this client into — [] = joined nothing
membership.error // typed, when .joiner threw
membership.isLoading // status === 'joining'
membership.input // { chatId }
membership.membershipIndex // successful joins so far — > 1 means it re-entered
membership.connection // the channel connection this membership rides on
membership.leave() // release this hold

rooms is the server's answer, separate from the requested input: a partial join (asked for forty, got thirty) is visible, and a clean deny is status: 'joined' with rooms: [] — distinct from status: 'error' (a .joiner throw).

Holds dedupe like connections, one level down: two components calling useMembership({ chatId: '5' }) share one membership with two holders, the second join is never sent; both released → linger → leave. Outside hooks, getMembership(membershipInput, channelInput?) looks a live membership up (no hold, no join — the second arg only when the channel has several live connections); getMembershipOrUndefined(...) probes.

;<chatSpace.Membership input={{ chatId }}>
  <Chat /> {/* gated on the join, like a mountable; gate={false} opts out */}
</chatSpace.Membership>

chatSpace.getMembershipOrUndefined({ chatId })?.rooms

A join needs a connection. There is no auto-connect from a join — a membership requires someone to hold the channel connection (canonically an <appChannel.Connection> at the app root); the server-side way in is .enroller. Nothing holds it → the join throws "No live connection" synchronously, the same shape as a send with no connection. A space that declares no .joiner refuses the same way, and sooner: the client bundle knows the joiner is absent (the compiler cuts the callback, not the declaration), so join throws POINT0_SOCKET_JOIN_NOT_ALLOWED before it even looks for a connection, and useMembership never registers a membership. Such a space still HAS memberships on the client — the enrolled ones — and no input opened them, so getMembership has nothing to look up: read them with memberships.client.list().

Enrolling from the server — .enroller

.joiner is the client asking in; .enroller is the server putting a fresh connection into rooms on its own. It runs server-side at connection setup (both connect paths — the cold-start upgrade and the ticket claim), before the connect confirms, so the rooms exist before the client renders anything. The canonical case is the personal-push room:

export const userSpace = appChannel.lets
  .space<{ userId: string }>()
  .enroller(({ identity }) => ({ userId: identity.userId })) // every connection lands in its own room
  .space() // no .joiner — nobody can join this space from the browser

export const notificationHandler = userSpace.lets
  .clientHandler()
  .serverSend(notificationSchema)
  .clientHandler()

// anywhere on the server — a hot pub/sub push, zero client code
void notificationHandler.sendToClient(notification, { room: { userId } })

.enroller(({ identity, connectionId }) => …) returns one room, an array, or nothing — same normalization as .joiner, and checked against the same opener-declared room shape (read earlier enrollments of the same connect with thisSpace.memberships.server.local.rooms({ connectionId })). The client learns its enrollments from the connect confirmation and holds them as regular memberships, just hold-less: they are joined right away, receive pushes and fire listeners like any membership, send no join frames, and live with the connection. A refresh re-runs the enrollers with the rebuilt identity and the client reconciles. Hold-less also means no facade came back from a join call, so on the client an enrollment is read through memberships.client.list() — the floor that includes them.

Leaving an enrollment. leave() on an enrolled membership works like any other: the client names that membership's rooms in a leave frame, the server — which keys nothing by how a room was entered — drops them, and the facade dies immediately (no hold behind it, so no linger). A room another live membership of the same connection still covers is left out of the frame, so leaving an enrollment never silences a join of the same room. It is not a permanent exit, though: the enroller runs at every connection setup, so a reconnect or a refresh puts the connection right back in. Permanence is data the enroller reads:

export const userSpace = appChannel.lets
  .space<{ userId: string }>()
  .enroller(async ({ identity }) => {
    const user = await db.user.findUnique({ where: { id: identity.userId } })
    // muted → enroll into nothing: no personal room, no pushes, on every connection this identity opens
    return user.notificationsMuted ? undefined : { userId: identity.userId }
  })
  .space()
// the client: leave() stops the pushes for THIS connection right now, the mutation makes it stick
userSpace.memberships.client.list().forEach((membership) => membership.leave())
await muteNotificationsMutation.fetchMutation()

An enrolled membership has no lifecycle of its own — it is announced, not joined, so onEnter never fires for it and its connection dying stays silent. The one exception is this leave(): it fires onLeave, exactly like the voluntary leave of a joined membership.

A space takes at most one .enroller; it coexists with .joiner in either order (the client can then join more rooms too — both callbacks answer to the space's one declared room shape). Without a .joiner the space is server-enrolled only, which is the shape you want for personal rooms. A throwing enroller fails the whole connection setup — the client sees a failed connect, exactly as a connector throw; a connection missing its enrolled rooms would drop pushes silently. Each enrollment emits the pointSpaceJoinServer* family (with an empty input) — an enrollment is a server-initiated join. Like .joiner, the callback is server code — cut from the client bundle with its imports. An .enroller on a resumable: false space of a resumable channel fails at the closer: a resume would silently drop the enrollments (they only re-run on a full connect).

Enrolling mid-life — space.enroll(target, room). .enroller runs at connection setup; enroll is the same act aimed at connections that are already live — the mirror of space.kick (kick = a forced leave, enroll = a forced join). The first argument selects who with the usual $-dictionary; the second names the rooms:

// a thread was created — put every live connection of its author into its room
await notificationSpace.enroll({ $identity: { userId } }, { threadId })

// one or many — the same key, like every other room surface
await chatSpace.enroll({ connectionId }, [{ chatId: '5' }, { chatId: '6' }])

connectionId / $identity / a bare target select among all connections of the channel (no membership required — you are enrolling them); room / $room parts select by existing memberships ("those already in these rooms"). The enrolled rooms behave exactly like .enroller rooms — hold-less, joined, no client join behind them — and a space kick (or the client's own leave()) removes them like any others. A connection the new rooms would push past maxRooms is skipped with a logged warning: an admin fan-out has no one requester to answer, so the rest of the selection still gets its rooms. An imperative enrollment is ephemeral: it lives until the connection does, and a reconnect or refresh rebuilds enrollments from .enroller alone. A durable enrollment is data — write the fact, have .enroller read it, and use enroll to deliver it to the connections that are already online.

Join guards and the local floor

The closing .space({...}) takes the join guards in its server group; .spaceOptions({...}) declares them up the chain (root/base/plugin, and the channel — callbacks stack chain → closer and run in order):

export const chatSpace = appChannel.lets
  .space<{ chatId: string }>()
  .input(z.object({ chatId: z.string() }))
  .joiner(async ({ input, identity }) =>
    (await isMember(identity.userId, input.chatId))
      ? { chatId: input.chatId }
      : undefined,
  )
  .space({
    server: {
      // the guard BEFORE the join — throw and the client's join fails, the joiner never runs
      onBeforeJoiner: ({ identity, connectionId }) => {
        if (
          chatSpace.memberships.server.local.rooms({ connectionId }).length >=
          50
        ) {
          throw new AppError('Too many rooms', { code: 'ROOM_LIMIT' })
        }
      },
      // after the join settles — audit, metrics; `output` (the admitted rooms) XOR `error`
      onAfterJoiner: ({ identity, output, error }) => {
        audit.log('space-join', {
          me: identity.userId,
          rooms: output?.length ?? 0,
          ok: !error,
        })
      },
    },
  })

Both receive { input, identity, connectionId, point }; onAfterJoiner adds { output, error } and its own throw only logs. An onBeforeJoiner throw is a join error — the client sees it on the membership as status: 'error', same as a .joiner throw. The blunt per-connection bound is the maxRooms option (the table) — respected on every path that puts a connection into rooms; the guard plus the local floor is the POLICY mechanism, and it composes (per-role caps, per-space caps, rate limits).

The local floor is the synchronous sub-floor of the server floor of the enumerations — space.memberships.server.local.count/list/rooms and channel.connections.server.local.count/list. It reads this process's slice of the room index straight from memory: no bus, no gather window, no promise (plain values). The callback inputs are pure data again (input, identity, connectionId, messageId); to read a connection's current rooms in a guard, call chatSpace.memberships.server.local.rooms({ connectionId }).

In the join path (.joiner, .enroller, the join guards) the local slice is by construction the full truth about the connection — a join always executes on the socket's own process. Anywhere else it is local only; for truth across every process use the async count / list. (There is no flat rooms without a target — a target is always what you read against.)

Server handlers: client → server

Naming — input / message / data. One rule across every socket callback: input is the payload of a CALL — someone called a point and gets an answer (the channel connect, a space join, a serverHandler send — query vocabulary); message is the payload of a PUSH — the server broadcasts, nobody awaits (sendToClient → .clientReply, the onMessageFromServer listeners, iterateMessagesFromServer); data is the other side's ANSWER to you (await sendToServer() resolves with it, onReplyFromServer.data, a collected push reply). So .serverReply reads input and .clientReply reads message — the word tells you whether you are answering a call or reacting to a broadcast.

Open with .serverHandler(), close with .serverHandler(options?). Takes .clientSend (the schema of what clients send) and .serverReply (the server's answer). The message is optional (like a mutation's input); the reply is not. .serverReply takes no schema — the server trusts itself, only client answers need checking. A handler grows from a channel or a space, and that is what shapes its callbacks and its addressing:

// from a space — the callbacks get the typed room
export const messageSendHandler = chatSpace.lets
  .serverHandler()
  .clientSend(z.object({ text: z.string().min(1) }))
  .serverReply(async ({ input, identity, room }) => {
    const message = await create(input.text, room.chatId, identity.userId)
    return message
  })
  .serverHandler()

// from the channel — no room slot at all
export const markAllReadHandler = appChannel.lets
  .serverHandler()
  .serverReply(async ({ identity }) => {
    await prisma.chatRead.updateMany({ where: { userId: identity.userId } })
    return { ok: true }
  })
  .serverHandler()
chatSpace.lets.serverHandler().clientSend(schema).serverHandler()
// type error: Point has no reply. Please add .serverReply()
//             before calling .serverHandler()

That closing guard is the only special error (same pattern as a loaderless mutation). Nothing else needs one: .loader / .clientLoader / plain .input simply don't exist on handler points. On handlers the method name is the author — who produced the message or reply — so a chain reads as a dialogue: .clientSend (the client said) → .serverReply (the server answered). The side that RUNS the code is the opposite one — .clientSend's schema is parsed by the server — which is also how they strip (see the machinery).

The channel/space input does not merge into the handler's input — connecting, joining, and messaging are different things. On the server the input isn't kept: what a reply needs comes from the identity (established at connect) and the room (resolved at join). On the client the input stays as connection.input / membership.input.

Handlers take no .ctx / .use — a type error, permanently. The identity was built by the connector at connect time, and messages carry no request. Something needed per message is computed in the reply itself.

Sending from the client

.sendToServer(input?, options?) resolves with the .serverReply's return — one reply, always. Don't await it and it's fire-and-forget. Bind the handler to a target by calling it — a channel handler binds a connection, a space handler binds a room:

const membership = chatSpace.useMembership({ chatId })

const message = await messageSendHandler({ chatId }).sendToServer({ text }) // the ROOM
void messageSendHandler({ chatId }).sendToServer({ text }) // fire and forget
void markAllReadHandler(connection).sendToServer() // a channel handler binds a connection

A space handler is always addressed by room — that is what the object in the binder is. Passing a membership instead is the shorthand "use its single room": exactly one room resolves, zero or several are an error telling you to bind the room. A channel handler's target may equally be the connection object or the channel input it was opened with. Either way the binder SEARCHES the live registry — it never opens or joins anything.

void messageSendHandler(membership).sendToServer({ text }) // = its single room
void messageSendHandler({ chatId }, { workspaceId }).sendToServer({ text }) // + channel input

The optional second argument is the channel input, needed only when the same room lives under several live connections of the channel.

The target resolves lazily on every call/render: a room-bound hook waits for the join that admits the room. An explicit target wins over the ambient <chatSpace.Membership> / <appChannel.Connection>.

The bare form — no binding — resolves on its own: hooks read the enclosing ambient wrapper, and every method falls back to the single live membership / connection, then to its single room. With several live memberships (or several rooms in one) the bare form is a runtime error — bind explicitly.

A send waits for its level. A space-handler send waits for the membership to reach joined (which implies the connection is claimed) and flushes; a channel-handler send waits for the connection to open. Nothing started and no one started it → a synchronous throw. Binding a room the client does not currently hold is that same throw: handler(room) addresses a LIVE room, so join (or be enrolled) first.

Multi-room memberships bind the room. One join may land in several rooms — then the membership names no single address and every bound surface takes the room:

const membership = entitySpace.useMembership({ ids }) // rooms: [{ model, id }, …]

void entityUpdateHandler({ model, id }).sendToServer(input) // this room
const { data } = entityInfoHandler({ model, id }).useSocketQuery() // one cache per room

There is no room send option — binding is the one way to name a room, across sendToServer, the listeners, the iterator and the whole socket-query family.

A reply throw rejects the promise with the typed error — see error handling. onReplyFromServer in the closing options' client group fires on the client for every reply of the handler, including fire-and-forget sends; its payload is data, the server's reply, next to the input the send carried. Up the chain via .serverHandlerOptions({ client: { onReplyFromServer } }) it fires for every handler beneath, there data is unknown and point tells which replied.

The handler as a mutation, query, or infinite query

A serverHandler declares what it IS for the client — a flavor, after .serverReply and before the closer: .query(options?), .infiniteQuery(options), or .mutation(options?). Nothing declared means mutation (the default). One flavor per handler; a flavor before .serverReply is a type error — the reply is what the query or mutation returns. The flavor opens the matching connection family — the standard query / mutation surface where the request travels the socket instead of HTTP:

  • .query() → the full query family: useSocketQuery / useSuspenseSocketQuery / fetchSocketQuery / prefetchSocketQuery / ensureSocketQueryData / getSocketQueryOptions / getSocketQueryKey / getSocketQueryData / setSocketQueryData / getSocketQueryState / getSocketQueryCache / getSocketQueriesCache / refetchSocketQuery / invalidateSocketQuery / cancelSocketQuery / removeSocketQuery / resetSocketQuery
  • .infiniteQuery({ pageParamFromInput, getNextPageParam, initialPageParam }) → the same family with the Infinite infix (useSocketInfiniteQuery, invalidateSocketInfiniteQuery, …) — the page cursor folds into the message input, each page is one send
  • mutation (the default) → useSocketMutation(options?) / fetchSocketMutation(input?, options?) (the non-hook run through the mutation cache machinery — the plain sendToServer bypasses it) / getSocketMutationKey / getSocketMutationOptions / getSocketMutationCache / getSocketMutationsCache

Each method mirrors its regular query / mutation counterpart: the exact-input forms act on one cache entry, and the cache-acting methods (refetch / invalidate / cancel / remove / reset / getSocketQueriesCache) also take a predicate over the parsed input or true for every entry of this handler on the resolved connection (with no resolvable connection, across all connections).

export const chatInfoHandler = chatSpace.lets
  .serverHandler()
  .clientSend(z.object({ q: z.string() }))
  .serverReply(async ({ input, room }) => ({
    info: await lookup(input.q, room.chatId),
  }))
  .query()
  .serverHandler()

The target resolves like every bare handler method — the ambient wrapper, else the single live one — and binding by call fixes it. The cache key carries the address of the level: a channel handler keys by the channel and its connection input; a space handler adds the space and the room — so two chats never share a cache cell, and a multi-room membership gets one cache entry per room:

const a = chatInfoHandler({ chatId: '5' }).useSocketQuery({ q }) // room 5's entry
const b = chatInfoHandler({ chatId: '7' }).useSocketQuery({ q }) // room 7's entry

The membership input is NOT part of the key: it is how the client dedups holds, not an address (see what a membership is). Two memberships whose joins landed in the same room therefore read one cache entry — which is what you want, since the server answers the room.

The cache-acting methods scope through the same key: an exact input acts on one entry, a predicate or true sweeps the handler's entries — narrowed to the bound room when the binding named one, otherwise across every room of the space on the resolved connection:

await chatInfoHandler({ chatId }).invalidateSocketQuery({ q }) // one input, one room
await chatInfoHandler({ chatId }).invalidateSocketQuery(true) // every input in that room
await chatInfoHandler(membership).invalidateSocketQuery(true) // every room on its connection
await chatInfoHandler.invalidateSocketQuery(true) // no live membership → across all

setSocketQueryData(input?, updater) is the push-driven mirror of a query's setQueryData: when a clientHandler push already carries the READY data, write it straight into the cache and no refetch request goes out. It matches the exact key (the level's identity plus this input — no partial scoping) and resolves the connection/membership strictly (the ambient wrapper, the single live one, or the bound target), so a bare call under no ambient level with several live ones throws. The updater's old is undefined until the query has resolved once; the return is the new data. It is client-side only. A push carrying the fresh member list writes it in place — no member refetches:

presenceChangedHandler(membership).useOnMessageFromServer(({ message }) => {
  whoIsHereHandler(membership).setSocketQueryData(undefined, () => ({
    nicknames: message.nicknames,
  }))
})

A socket query never runs before its level is ready: open for a channel handler; for a space one, a joined membership that currently HOLDS the addressed room — which also means SSR never fetches or dehydrates one (every socket query is ssr: false by construction). The imperative fetchSocket* / prefetchSocket* / ensureSocket* AWAIT the connect like the send queue — fired while the level is still connecting or joining, they wait for it to land (up to the handler's timeout) and fail only when the connect fails. They throw on the server. The suspense hooks (useSuspenseSocketQuery / useSuspenseSocketInfiniteQuery) suspend on the connect first, then on the fetch; during SSR the HTML ships the Suspense fallback and the client resolves after hydration.

Answering early — the imperative reply

The return doesn't have to be the answer. Name the reply type with the explicit generic — .serverReply<T>(...) — and the callback's args gain the imperative reply: call it and the envelope leaves for the client immediately, while your code keeps running. (The generic is what makes this typeable at all — a call argument can't drive inference the way a return does, so the type must be named.)

export const transcodeHandler = chatSpace.lets
  .serverHandler()
  .clientSend(z.object({ videoId: z.string() }))
  .serverReply<{ accepted: boolean }>(async ({ input, reply, identity }) => {
    reply({ accepted: true }) // the client's sendToServer resolves NOW…
    const url = await transcode(input.videoId) // …while the server keeps working
    void transcodeDoneHandler.sendToClient(
      { url },
      { room: { userId: identity.userId } },
    )
  })
  .serverHandler()

reply is called once — the first call wins, later calls only warn, a return after it is ignored. reply(new Error(...)) is the imperative refusal (the client's send rejects with the typed error, exactly as a throw would); .serverReply<undefined>() makes reply(undefined) the early ack (the client types the resolve as the empty data, like any ack). return works the same with or without the generic; a throw after reply() was sent only logs — the client already has its answer.

A result that outlives the request (the transcode above) is data, not a pending promise: write it, then push it into a room the user holds (an .enroller personal room is the usual shape) or invalidate a query — that survives a reconnect, a redeploy, and a closed tab. See Delivery.

A reply never streams

.serverReply answers one send — a generator callback is refused at the type level and at runtime. When the answer IS a stream (the LLM-chat shape: one action, the result arrives token by token), split it along the architecture: a mutation starts the job, the server pushes the tokens through a clientHandler addressed to the calling connection, and the client consumes them — reactively with useOnMessageFromServer, or as an async iterable with iterateMessagesFromServer:

export const chatBotTokenHandler = chatSpace.lets
  .clientHandler()
  .serverSend(z.object({ token: z.string() }))
  .clientHandler()

export const chatBotAskHandler = chatSpace.lets
  .serverHandler()
  .clientSend(z.object({ prompt: z.string() }))
  .serverReply(async ({ input, identity, connectionId }) => {
    void (async () => {
      for await (const token of llm.stream(input.prompt, {
        user: identity.userId,
      })) {
        await chatBotTokenHandler.sendToClient({ token }, { connectionId })
      }
    })()
    return { started: true }
  })
  .serverHandler()

The pushes ride the already-open socket, survive the channel's reconnects, and scale across processes through the backplane like every push — and cancelling the job is just another mutation. See Delivery for the stream-with-history shape that survives a gap.

Guards and audit

Messages travel the socket, so request-time middleware and plugins never see them — they ran once, at connect. What runs per message are the handler's server-side callbacks:

.serverHandler({
  server: {
    // the guard BEFORE the reply — throw and the client's send rejects, the reply never runs
    onBeforeServerReply: async ({ identity, connectionId }) => {
      if (await rateLimiter.exceeded(connectionId)) {
        throw new AppError('Slow down', { code: 'RATE_LIMITED' })
      }
    },
    // after the reply settles — audit, metrics; `output` XOR `error`, its own throw only logs
    onAfterServerReply: ({ input, identity, output, error }) => {
      audit.log('message-send', { input, me: identity.userId, ok: !error })
    },
  },
})

Both receive { input, identity, connectionId, messageId, point } — plus room for a space handler. onAfterServerReply adds { output, error }; an imperative reply() fires it the moment it's called (with the replied data). Both are server code — cut from the client bundle with their imports.

Client handlers: server → client

Open with .clientHandler(), close with .clientHandler(options?). Everything inside is optional — the minimal one is a pure trigger:

export const ideasChangedHandler = ideasSpace.lets
  .clientHandler()
  .clientHandler()

Add .serverSend for a typed payload — the server produced the message, the client reads it (the server never parses it, only types the send). A module-level listener can live in the closing options — then connecting/joining is all a client needs, no component subscription:

export const messageReceivedHandler = chatSpace.lets
  .clientHandler()
  .serverSend(messageSchema)
  .clientHandler({
    client: { onMessageFromServer: ({ message }) => notify(message) },
  })

Component listeners (useOnMessageFromServer / onMessageFromServer) add on top — every registered listener fires exactly once per message, each also receiving point. Add .clientReply when the client should answer back: a module-level reaction that runs on every message; its return travels back to the server and nowhere else. The listeners fire immediately with the message and never see the reply — react to the reply inside .clientReply itself. Pass a schema as the second argument and the server validates every reply:

export const pingHandler = appChannel.lets
  .clientHandler()
  .serverSend(z.object({ ask: z.string() }))
  .clientReply(
    ({ message, connection }) => ({ answer: `pong: ${message.ask}` }),
    z.object({ answer: z.string() }), // checked on the server, types the reply
  )
  .clientHandler()

The strip side is in the name: .serverReply is cut from the client bundle, .clientReply from the server bundle. Inside .clientReply(cb, schema?) the callback is client code but the schema is server code, so the compiler splits the call — in the server bundle the callback becomes () => {} and the schema stays; in the client bundle the schema argument is dropped. A space clientHandler's .clientReply also gets room.

Sending from the server — the addressing watershed

sendToClient(message, target?, replies?) — three positional arguments, callable anywhere in a process running the engine server — mutation loaders, other handlers, crons (a separate worker process without engine.serve() has no socket adapter to send through). The target is a $-dictionary whose parts combine with AND. One rule reads it: a bare key is an exact address (the hot path), a $-key is a Mongo-style selection (a scan, run by sift). No target — or {} — means everyone in the handler's scope. What the target can name follows from where the handler grew — this is the watershed:

A channel clientHandler addresses connections:

void announceHandler.sendToClient({ text }) // everyone in the channel
void announceHandler.sendToClient({ text }, { connectionId }) // one connection — the cheapest address
void announceHandler.sendToClient({ text }, { $identity: { role: 'admin' } }) // a selection (scan, admin)

A space clientHandler addresses rooms — typed by the space's room shape — and may narrow by the same connection keys:

void messageReceivedHandler.sendToClient(message, { room: { chatId: '5' } })
void messageReceivedHandler.sendToClient(message, {
  room: [{ chatId: '5' }, { chatId: '7' }],
})
void messageReceivedHandler.sendToClient(message) // bare — EVERYONE in the space
void messageReceivedHandler.sendToClient(message, {
  room,
  except: senderConnectionId,
})
  • room — an exact room snapshot (or an array of them): the pub/sub topic address, the hot path. The bare space send is space-wide — one publish on the space-wide topic reaches every member of the space; also a hot pub/sub path, not a scan.
  • $room — a sift selection over the space's rooms (subset semantics for a flat object: $room: { workspaceId: '7' } matches every room of workspace 7, whatever its other fields). An explicit scan — every process filters its local room index — costing what $identity costs; the $ is the price tag.
  • connectionId — exact connection id(s). AND-combined with room: the connection must be in the room.
  • $identity — a sift selection over the connection identities — a scan, for the rare admin fan-out, not the hot path.
  • except — connection id(s), or (on a space handler) room snapshot(s) of the same space, that the push must not reach.

The $-rule is uniform wherever a target is taken — pushes, kick, amendIdentity, the enumerations: a bare key is an exact address (the hot path), a $-key is an explicit sift scan. And "everyone except this user, everywhere" is not an except — it's $identity: { userId: { $ne: userId } }.

A matcher is typed by the declared identity (or room) keys, and sift operators belong in the values of those keys — { userId: { $in: [...] } } — fully typed there. Mind that a matcher naming a key nothing declares selects nobody: TypeScript catches the mistyped literal, and a matcher built in a variable is on you — the usual structural-typing rules apply.

Collecting replies

The third argument, replies, collects each client's .clientReply answer — it exists only when the handler declares .clientReply (no clientReply, no argument), and without it nothing is collected. Three forms, and the return type follows the one you pass:

// true — the bare collection: an async iterable of replies
for await (const reply of pingHandler.sendToClient({ ask }, {}, true)) {
  reply.data // { answer } — validated by .clientReply's schema
  reply.connectionId
}

// { waitForAll: true } — the full array when the window closes
const replies = await pingHandler.sendToClient(
  { ask },
  { $identity: { role: 'admin' } },
  { waitForAll: true },
)

// { onReply } — a callback per reply; the promise resolves when the window closes
await pingHandler.sendToClient(
  { ask },
  {},
  {
    timeout: 3000,
    onReply: ({ data, connectionId }) => tally(data),
  },
)

Each reply is { data, connectionId }, validated by the .clientReply schema (an invalid one is dropped and logged). One delivered push = ONE reply per connection (per targeted room, for a multi-room push) — how many components a client mounted over the room never changes the count. timeout bounds the collection window (default 5000 ms). A client whose .clientReply THROWS still answers — as an error that counts toward the window (so waitForAll never hangs) but delivers nothing (it's logged server-side).

Listening in components

const TypingIndicator = ({ chatId }: { chatId: string }) => {
  const membership = chatSpace.useMembership({ chatId })
  const [who, setWho] = useState<string | null>(null)
  typingHandler(membership).useOnMessageFromServer(({ message }) =>
    setWho(message.userName),
  )
  return who && <span>{who} is typing…</span>
}

The callback receives { message, connection, point } — a space handler's adds room (the room the push addressed). It fires immediately when the message arrives, decoupled from the .clientReply auto-responder: a slow or throwing reply never delays or suppresses the listeners (react to the reply inside .clientReply itself). Same resolution as .sendToServer: under the ambient wrapper (or a single live target) the bare typingHandler.useOnMessageFromServer(cb) works.

A ROOM-bound listener hears that room only. Binding a room narrows the listener the way it narrows a send — the pushes addressed elsewhere never reach it, and a space-WIDE push (one with no room) is not addressed to a room, so it does not either. A membership-bound (or bare) listener hears every room the membership covers, space-wide pushes included, and room tells which one:

typingHandler({ chatId }).useOnMessageFromServer(cb) // this room's pushes
typingHandler(membership).useOnMessageFromServer(({ message, room }) => …) // all of them

A second argument takes the options: enabled (false detaches the listener, like a disabled query) and lastMessageFromServerAsData — keep the latest message in state, the result gains data and re-renders on every push:

const { data } = tickHandler.useOnMessageFromServer(() => {}, {
  lastMessageFromServerAsData: true,
})

By default the hook returns nothing and nothing re-renders — the typical consumer reacts through the listener, and lastMessageFromServerAsData: true turns the hook into the latest-message state when rendering is the point.

Iterating the pushes

Every clientHandler also carries an imperative consumer — iterateMessagesFromServer(options?), the pushes as an async iterable. No request leaves the client and there is no transport of its own: iterating attaches a listener to the resolved target (the ambient connection, the single live one, or an explicitly bound handler(connection) / handler(room)), and each sendToClient push is one yield — filtered to the bound room, exactly like the listeners.

for await (const { tick } of tickHandler.iterateMessagesFromServer()) {
  append(tick)
}

The target owns its own life: a drop parks the loop while the channel's own reconnect policy redials — there is no second policy at the message layer; the iteration ends when the target closes for good and throws the target's typed error when it fails. Break out of the loop (or abort options.signal) to detach the listener. Connection state itself is the channel's surface — read it with useConnection / useMembership, not here.

An HTTP subscription is a different thing on purpose — a REQUEST the server answers with a stream. A clientHandler's pushes have no request, so there is no .subscription() on socket points; the iterator and the listeners are the whole consuming surface.

Managing connections

A channel exposes kick, refresh, amendIdentity, and the connections.* enumerations; a space exposes kick, enroll and the memberships.* enumerations (a space has no identity to refresh). The commands are server-only and take the same $-dictionary target the pushes use — parts combine with AND, a bare call means everything in scope. The enumerations name a floor first (server / client) and only the server floor takes targets:

// channel target: { connectionId?, $identity? }
// space target:   { room?, $room?, connectionId?, $identity? }
  • connectionId — exact connection id(s), the O(1) address.
  • $identity — a Mongo-style query over the connection identity, run by sift. The TYPED surface is the common subset — $eq $ne $gt $gte $lt $lte $in $nin $exists, plus $or / $and; sift accepts more at runtime, so anything outside the subset takes a cast. $where is rejected. Serialized with the channel transformer, so Dates compare live. A nested object literal ({ me: { id } }) is exact subdocument equality — a field is a dot path ({ 'me.id': userId }; the typed keys are the top-level ones, so a dot path takes a cast, like the operators outside the subset).
  • room (space only) — an exact room snapshot (or an array of them): full-object equality, the index address.
  • $room (space only) — the same sift machinery over the space's rooms. A flat $room: { chatId: '5' } reads as subset semantics: it matches every room whose chatId is '5', whatever its other fields — deliberately, this is what a selection means. A snapshot in room is exact equality of the whole object. An exact address and a selection are different operations, which is why they are different keys.
// close a user's connections entirely (a role revoked, an admin logout) — a `closed` frame
await appChannel.kick({ $identity: { userId: '42' } })
await appChannel.kick({ connectionId, reason: 'signed-out' })

// re-run the connect for matching connections — the identity is rebuilt, the socket stays up
await appChannel.refresh({ $identity: { userId: '42' } })

channel.kick removes matching connections and marks each client closed. What happens next follows the hold's nature: a connection held by useConnection / <Connection> declares "stay connected while mounted", so it re-establishes on its own through the reconnect policy — the connector re-runs and judges afresh; an imperative connect() stays closed until its owner reconnects (or reconnectAll()). So a kick is an interruption, never a ban — a real ban belongs in the connector, which re-applies on every connect (throw with preventRetry and the client stops knocking entirely). The optional reason rides the close frame — a raw WebSocket consumer can read it off the wire; Point0's own client runtime does not surface it (tell the user WHY with a push right before the kick). refresh is the "this identity changed" signal: matching clients silently re-connect (the connector re-runs, the identity is rebuilt, the enrollers re-run) and re-join their spaces, without dropping the socket.

Patching the identity — amendIdentity

channel.amendIdentity(target, patch) shallow-merges a patch into the stored identity of matching connections, in place — no reconnect, no connector run:

await appChannel.amendIdentity({ $identity: { userId: '42' } }, { plan: 'pro' })
await appChannel.amendIdentity({ connectionId }, { displayName })

It amends data, not rights: rooms already granted stay granted. Narrowed rights are a space.kick next to it; a full re-evaluation (the connector and the enrollers re-run) is refresh. The patch is a plain object — an updater function cannot travel the backplane bus. The client learns nothing — the identity is not its business. It needs a connector-declared identity to patch: a connectorless channel's identity is the strict {}, so amendIdentity on it is a compile error on the call and a runtime throw underneath — never a silent identity growing undeclared keys.

Kicking from rooms — space.kick

Space admin targets rooms, not connections:

// user 42 was banned in chat 5 — drop THEM from THAT room (their connection lives on)
await chatSpace.kick({ room: { chatId: '5' }, $identity: { userId: '42' } })

// chat 5 was deleted — drop EVERYONE from its room (this is "close the room")
await chatSpace.kick({ room: { chatId: '5' } })

// a selection — every room of workspace 7, whatever its other fields (subset semantics)
await chatSpace.kick({ $room: { workspaceId: '7' } })

space.kick is NOT a connection kill — it's a forced leave of the matching rooms. The client's membership loses those rooms (its other rooms and its connection live on) — and then the hold's nature decides again: a membership held by useMembership / <Membership> replays its join through the reconnect policy, so the joiner (and its guards) rules on it afresh — deny there (with preventRetry for a hard deny) and the rooms stay gone; an imperative join() stays shrunk until its owner re-joins. A room has no state of its own — it exists while someone is in it, so "close the room" is just kicking everyone out of it (and revoking the right to come back in the joiner). On a resumable channel the kick reaches parked connections and their passports too — a kicked room does not come back through a resume. Like the channel kick's, the optional reason rides the left frame for raw consumers only — the typed client runtime does not surface it.

Kick's mirror is space.enroll — a forced join: the same $-dictionary selects who, the second argument names the rooms. The two compose into a server-side "move": kick from the old room, enroll into the new.

Enumerating — connections and memberships

channel.connections and space.memberships are the enumeration namespaces — the channel holds connections, the space holds memberships, the name follows the entity. Each has three floors, because "which connections" is three different questions:

FloorReadsShape
serverthe whole cluster, over the busasync — a snapshot over a window
server.localthis process onlysynchronous, plain values
clientthis browser tab onlysynchronous, the live facades

Naming the floor is mandatory — there is no bare connections.count(). And the floors are side-locked: .server.* on the client and .client.* on the server are runtime errors, never a silently empty answer.

The server floor — three reads over the live state everywhere in the cluster. Targets are the same $-dictionary:

await appChannel.connections.server.count({ $identity: { plan: 'free' } }) // → number
await chatSpace.memberships.server.count({ room: { chatId: '5' } }) // the presence counter

const list = await chatSpace.memberships.server.list({ room }, { timeout: 2000 })
// [{ connectionId, identity, rooms }, ...]

await chatSpace.memberships.server.forEach({ room }, {
  onMembership: async ({ connectionId, identity, rooms }) => { ... }, // per-member work
}) // → Promise<number> — how many were processed

for await (const m of chatSpace.memberships.server.forEach({ room })) { ... } // bare → AsyncIterable

Every read on it is a snapshot over a window: local connections answer immediately, other processes answer over the backplane bus within timeout (ms, default 1000) — whoever misses the window is not in the result. That's the semantics of all six methods, once and for all.

  • count(target?, { timeout? }) — the cheap one: only numbers travel the bus, never the items.
  • list(target?, { timeout? }) — the merged array at window close. A channel item is { connectionId, identity, spaces } (spaces = space name → parsed rooms); a space item is { connectionId, identity, rooms }.
  • forEach(target?, options?) — the streaming form: with the callback (onConnection / onMembership — named by the entity, like onReply) it processes items as they arrive and resolves with the processed count; bare, it is an async iterable. "Wait for all of them as an array" is list — forEach has no waitForAll on purpose.

Counts and lists are for showing numbers, not for deciding who gets in. A snapshot tells you how things stood a moment ago, and two joins can land while you act on it. A limit that must hold lives in your own database — the seat recipe in Self-referential callbacks shows both versions.

The server.local floor is the sub-floor under it: this process's matching slice only, straight off the room index. No bus, no gather window, no promise — plain values:

channel.connections.server.local.count(target?) // → number
channel.connections.server.local.list(target?) // → Array<{ connectionId, identity, spaces }>
space.memberships.server.local.count(target?) // → number
space.memberships.server.local.list(target?) // → Array<{ connectionId, identity, rooms }>
space.memberships.server.local.rooms(target?) // → Room[] — the flat, deduped room set the matches hold

Same $-dictionary targets and item shapes as the cluster floor. Use it in the join path — .joiner, .enroller, the join guards, .serverReply — where the socket lives on this very process, so the local slice is by construction the full truth about the connection: a join guard caps rooms with space.memberships.server.local.rooms({ connectionId }). Anywhere else it is local only; for truth across every process use the async count / list.

The client floor is the browser's own picture: the live facades this tab holds — the very objects connect / useConnection / join / useMembership hand out, with their status, rooms, input, disconnect / leave. Synchronous, hold-less (reading connects and joins nothing), and it takes no target — a bare call is everything of that channel/space on this client:

appChannel.connections.client.count() // → number
appChannel.connections.client.list() // → ClientChannelConnection[]
chatSpace.memberships.client.count() // → number
chatSpace.memberships.client.list() // → ClientSpaceMembership[]

memberships.client.list() includes enrolled memberships — the ones .enroller or space.enroll created server-side with no join behind them. Nothing else reads those one at a time: an enrollment has no join input, so getMembership(input) has nothing to look up. On a space with no .joiner this is THE read — and since these are the real facades, it is also where an enrollment is left: membership.leave() drops its rooms until the next connection setup re-enrolls.

The lookups are the per-item twins — getConnection(input) / getMembership(input) find the one that joined with that input, the client floor gives you all of them. Across every point of a scope at once there is getSocket() / useSocket(), which also lists kicked-but-still-held connections (dormant until a remount); the client floor is live ones only. A local sub-floor here would mean nothing: a browser tab IS one process.

The commands stay directly on the point — channel.kick(...), channel.refresh(...), channel.amendIdentity(...), space.enroll(...), never under a floor. They are actions, not reads, and they are server-only, period.

Every read here is scoped to ONE point and answers a target. For the whole process at once — every scope, channel and space it holds — the engine has its own synchronous window, engine.socket.local.get() / engine.socket.status(): per-process metrics, health checks, devtools.

Self-referential callbacks

A callback may call its own point's server surface — a joiner that counts its own space's memberships, say. The room comes from the opener, so the space's type owes the callback nothing and the self-reference is just a value reference, never TypeScript's "referenced in its own initializer":

export const seatSpace = appChannel.lets
  .space<{ eventId: string }>()
  .input(z.object({ eventId: z.string() }))
  .joiner(async ({ input }) => {
    const seated = await seatSpace.memberships.server.count({
      room: { eventId: input.eventId },
    })
    if (seated >= 100) {
      return undefined // about full — a clean deny
    }
    return { eventId: input.eventId }
  })
  .space()

Keep the self-reference out of the returned expression itself. Above it decides in a guard and the returns are plain room literals — that compiles. Fold it into the return instead (return seated < 100 ? { eventId } : undefined) and you are back to the ordinary circularity every const has: computing the callback's return type needs seatSpace, whose type is still being computed (TS7024). That one is not specific to points — it is the same reason a plain const x = f(() => x.y) cannot type itself.

Note what this joiner promises: it caps the room's fanout at about a hundred — one or two over is fine. The count is a snapshot over the bus, not a transaction: two processes can both read 99 at the same moment and both admit. For keeping a room reasonably sized that is exactly enough; for a hard limit it is the wrong tool.

A hard limit — seat 101 must not exist — is a reservation in your own database, made inside the joiner, where a transaction or a unique index can refuse the extra insert. The joiner denies when the reservation fails, and a server event frees the seat on every kind of leave:

export const ticketSpace = appChannel.lets
  .space<{ eventId: string }>()
  .input(z.object({ eventId: z.string() }))
  .joiner(async ({ input, connectionId }) => {
    try {
      // one serializable transaction — the count and the insert commit
      // as one, so two racers cannot both see 99
      await prisma.$transaction(
        async (tx) => {
          const taken = await tx.seat.count({
            where: { eventId: input.eventId },
          })
          if (taken >= 100) throw new Error('full')
          await tx.seat.create({
            data: { eventId: input.eventId, connectionId },
          })
        },
        { isolationLevel: 'Serializable' },
      )
    } catch {
      return undefined // full, or lost the race — a clean deny either way
    }
    return { eventId: input.eventId }
  })
  // every leave lands here — `leave()`, a kick, a dead socket, a closed connection
  .serverOn(['pointSpaceLeaveServer'], async (event) => {
    const rooms = z
      .array(z.object({ eventId: z.string() }))
      .parse(event.data.rooms)
    await prisma.seat.deleteMany({
      where: {
        connectionId: event.data.connectionId,
        eventId: { in: rooms.map((room) => room.eventId) },
      },
    })
  })
  .space()

The count now lives where it can refuse: the transaction serializes the racers, so the second one sees 100 and denies (a unique index on a seat number is the same door in a single insert). pointSpaceLeaveServer fires for every way out — the client's leave(), a space.kick, a dead socket — so a reservation never outlives its membership.

The identity has no opener generic (a channel has one producer, its .connector, and no self-reference problem), so .connector<TIdentity>() keeps its optional explicit type argument for the same reason: reach for it when the connector touches its own channel's connections.*.

Where it's stored — the engine server.backplane

The connection identity is stored per connection (as { scope, channel, identity }), alongside the one-time connect tickets — and not the memberships (the client replays its joins after a socket death, so the store stays small; the one exception is a resumable channel, whose record also carries the per-space rooms as the resume passport). Default is server memory. To run several processes behind a load balancer (the connect and the socket may hit different ones), plug a socket backplane into the engine config. The options form a ladder — take the first rung that fits: for plain Redis it is one line, a URL string ('redis://…' / 'rediss://…' / 'valkey://…') served by Bun's built-in Redis client — one connection for the KV and the publishes, a duplicate in subscriber mode for the bus:

server: {
  socket: true,
  backplane: process.env.REDIS_URL, // 'redis://localhost:6379'
}

Next rung: the ready-made adapters below, for Postgres and for a Redis client you already run. Last rung: the contract — any KV + pub/sub you bring, as a plain object of functions.

Ready-made adapters

For the clients you may already run there are ready-made adapters — subpath exports of @point0/engine, each a factory that takes YOUR client and returns a Backplane. They carry no dependencies (each is typed structurally against the few methods it calls): install the client's package yourself, construct the client, hand it over. Construct the client inside the lazy factory form — the engine config module is also loaded by builds and codegen, where a database connection has no business starting; the factory runs only on server start:

// Postgres — the stack that already has a database and no Redis. bun add postgres
import { postgresBackplane } from '@point0/engine/backplane/postgres'

server: {
  socket: true,
  backplane: async () => postgresBackplane((await import('@/lib/sql')).sql),
}
AdapterForInstall
@point0/engine/backplane/postgresPostgres via postgres.js — real push, no Redispostgres
@point0/engine/backplane/ioredisan ioredis client you already hold (BullMQ, Sentinel, custom TLS)ioredis
@point0/engine/backplane/node-redisa node-redis (redis package, v4/v5) client you already holdredis
@point0/engine/backplane/bun-redisBun's built-in client when it needs options a URL cannot carry — what the URL shortcut itself is built on—

postgresBackplane(sql) turns the database you already have into the backplane: the bus rides LISTEN/NOTIFY (millisecond push — postgres.js keeps one dedicated listen connection, reconnects it with backoff and re-listens on its own), the KV an UNLOGGED table with TTLs computed on the DATABASE clock. Sharing the app's sql instance is fine — the KV rides the ordinary pool. Two Postgres limits are absorbed for you: channel names (identifiers, capped at 63 bytes and silently truncated — long room topics would fold onto each other) are hashed to p0_<sha256-prefix> on both sides, and a message over the ~8 KB NOTIFY cap is spilled through a payload table and fetched on delivery, in order. Both tables are created on first use; pass createTables: false and run the SQL yourself when the app's DB role cannot create tables:

CREATE UNLOGGED TABLE IF NOT EXISTS point0_backplane_kv (
  key text PRIMARY KEY,
  value text NOT NULL,
  expires_at timestamptz
);
CREATE UNLOGGED TABLE IF NOT EXISTS point0_backplane_payload (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  message text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

The one requirement: a direct connection. LISTEN does not work through a transaction-mode pooler (PgBouncer, Supabase's Supavisor, Neon's pooler) — point the backplane at the direct URL; on hosts that connect you directly (Railway, plain VMs) there is nothing to do.

The Redis adapters map the client 1:1 (GET / SET PX / DEL / GETDEL / PUBLISH / SUBSCRIBE) and need Redis >= 6.2 (GETDEL). A Redis connection in subscriber mode cannot run regular commands, so each adapter lazily duplicate()s the client for the bus on first subscribe (or takes yours via the subscriber option). Reconnect durability is covered per client: ioredis re-subscribes on its own (autoResubscribe, on by default — keep it on), node-redis and Bun's client are wrapped in a registry that replays every subscription after a reconnect.

import { ioredisBackplane } from '@point0/engine/backplane/ioredis'

server: {
  socket: true,
  backplane: async () => ioredisBackplane((await import('@/lib/redis')).redis),
}

Every adapter follows one ownership rule: on engine dispose it closes what it created itself (the duplicated subscriber, its listens, its timers) and never the client you passed in. When the client exists solely for the backplane — constructed inline in the factory — pass closeClient: true and the adapter closes it too:

import { bunRedisBackplane } from '@point0/engine/backplane/bun-redis'

server: {
  socket: true,
  backplane: () => bunRedisBackplane(new Bun.RedisClient(url, { tls: { … } }), { closeClient: true }),
}

The contract

The interface is Redis-shaped on purpose: a string KV with a per-key TTL plus channel pub/sub. A node-redis client maps onto it 1:1 — GET / SET PX / DEL, and PUBLISH / SUBSCRIBE on the channel it is handed. point0 owns the channel names and passes each one as an argument (they all live under point0:socket:*) — your implementation just routes what it is given and knows nothing about point0:*, exactly like the KV side where keys already arrive ready-made. The five functions carry everything the sockets need across processes: the stored records, room and channel pushes, collected replies, the kick / refresh / amendIdentity commands, and the enumerations.

// engine config — the backplane is a Redis-shaped KV + channel pub/sub
server: {
  backplane: {
    get: (key) => redis.get(key),
    set: async (key, value, ttlMs) => {
      if (ttlMs === undefined) await redis.set(key, value)
      else await redis.set(key, value, { PX: ttlMs })
    },
    delete: async (key) => {
      await redis.del(key)
    },
    // the channel name arrives ready-made — just route it; point0 owns `point0:*`
    publish: (channel, message) => sub.publisher.publish(channel, message),
    subscribe: (channel, onMessage) => {
      void sub.subscriber.subscribe(channel, onMessage)
    },
    // optional sixth — read-and-delete in one atomic step (Redis GETDEL)
    getDelete: (key) => redis.getDel(key),
  },
}

The bus is sharded by topic — traffic goes where it is needed instead of everywhere. A push addressed to exact rooms is published on each room's own channel, and only the processes currently holding a member of that room are subscribed to it: the first local member subscribes, the last one out unsubscribes after a short linger. Space-wide and channel-wide pushes ride a per-space / per-channel topic the same way. The rare admin commands and the $identity / $room selections ride one shared channel every process subscribes, and answers — collected replies, count/list responses — go straight to the asking process's own inbox channel. Every message still carries its full target and each process filters locally, so an implementation that ignores unsubscribes (or broadcasts everything to everyone) stays correct — it just pays more traffic. The payoff: a node's incoming stream scales with its own users, not with the size of the cluster.

Redis Cluster. In a clustered Redis, ordinary pub/sub broadcasts every message to every node of the cluster regardless of who subscribed where — which un-shards the bus at the Redis tier. Point the backplane at a single (non-cluster) pub/sub Redis, or build a custom Backplane on sharded pub/sub (SSUBSCRIBE / SPUBLISH, Redis 7+); the built-in redis://… shortcut speaks ordinary pub/sub.

subscribe is not one-and-done. The engine subscribes and unsubscribes channels throughout the server's lifetime — one per room its connections are in, plus the shared and inbox channels — and it can call the unsubscribe a subscribe returned once it is done with a channel. Returning nothing instead of an unsubscribe is legal too — that implementation just keeps receiving traffic for channels the engine wanted to leave, which costs bandwidth, not correctness. One duty is yours: a settled subscribe is durable until point0 unsubscribes it, so if your transport drops and reconnects, your implementation must restore its own subscriptions — point0 cannot see the drop and will not re-subscribe. The redis://… shortcut and the ready-made adapters do this for you.

There is an optional sixth, getDelete(key) — read a key and delete it in one atomic step (Redis GETDEL). Implement it and a connect ticket is claimed atomically across processes: two processes racing the same ticket, exactly one wins, because the read IS the delete. Leave it out and point0 falls back to get + delete, which is a window two processes can both step into (one process is always safe — an in-process guard holds a ticket while its claim is mid-flight). The in-memory default, the redis://… URL shortcut and every ready-made adapter implement it.

And an optional seventh, dispose() — release what the backplane itself OWNS (a duplicated subscriber connection, a listen connection, timers). The engine calls it when it is disposed. Close only what your implementation created, never a client the app passed in — the app owns that lifecycle. A dev server disposes and re-resolves the backplane across hot restarts: the factory form returns a fresh adapter per call, so per-generation dispose is clean, while an adapter OBJECT passed directly is re-resolved as the SAME object — give it a dispose it can survive, or none.

Keys and values are always strings — the point's transformer already did the serializing. Every record carries a TTL: connect tickets live 30 s, connection records live connectionTtl (a channel option, default 90 s) and the client ping slides that window forward — so a process dying between the connect and the claim leaves nothing behind. delete is the fast path connections and claimed tickets leave by; the TTL is the backstop. Anything with a KV and a broadcast fits these functions — the ready-made postgres adapter is exactly that shape (an unlogged KV table + LISTEN/NOTIFY, with a DELETE … RETURNING for the sixth), no Redis needed.

Connections and memberships on the client

A connection is query-shaped — a connect is a request with an answer:

const connection = appChannel.useConnection()
connection.status // 'connecting' | 'open' | 'error' | 'closed'
connection.error // typed, when the connector threw
connection.isLoading // status === 'connecting'
connection.input // what this connection connected with
connection.id // the connection id (undefined until the connect response arrives)
connection.connectionIndex // successful connects so far — > 1 means it reconnected
connection.disconnect() // release this hold

There is no connection.data and no connection.room — the connector returns identity (server-side only), rooms come from memberships. Hooks are not the only way in — the same surface exists imperatively (connect / join), and holds are counted wherever they come from: every useConnection / <Connection> / connect() with an equal input shares one connection; the real connection closes when the last hold is gone, after linger. Memberships hold the same way. On a no-input channel or space, undefined and {} are the same input — pass either (or nothing), everything normalizes to {} and shares one hold.

<appChannel.Connection>
  <Router />{' '}
  {/* the app runs inside; every space membership rides this connection */}
</appChannel.Connection>

<Connection> renders through the same mountable interpreter as Layout / Provider: the chain's mount actions run, so the inherited .with(...) wrappers from root/base wrap the children, and the nearest .loading() up the channel's chain renders while connecting (during SSR too — nothing connects there), a failed connect renders the nearest .error(), and once open the children render. <space.Membership> does the same for a join — but a space opened from its channel inherits only the meta subset, so root/base .with(...) injections reach <Connection>, not <Membership> (.wrapper() elements apply to both). The gate prop (boolean | { loading?: boolean; error?: boolean }) controls the gating: by default only errors gate ({ loading: false, error: true }), so the children render while connecting (progressive enhancement) but a failed connect renders the nearest .error(). Pass gate={false} to opt out entirely — the children render immediately through everything and the handlers inside wait on their own; gate / gate={true} waits on the connect too. The object form overrides only the named aspects — an unnamed key keeps its default, so gate={{ loading: true }} waits on the connect AND keeps surfacing errors; hiding errors takes an explicit error: false.

Both take an options prop — the same options as useConnection / useMembership (reconnect, the lifecycle callbacks, enabled, …) — applied to the hold this mount opens. And both take LoadingComponent / ErrorComponent — on-the-spot overrides of the gate's loading/error render for THAT mount: passed here they win over the nearest .loading() / .error() up the chain.

<appChannel.Connection
  options={{ reconnect: { retries: 3 } }}
  LoadingComponent={Spinner}
  ErrorComponent={ConnectFailed}
>
  <Router />
</appChannel.Connection>

The WebSocket itself is lazy; the socket floor of the client surface lives in @point0/core/socket:

  • getSocket() — a read-only snapshot of the whole vertical: { status, connections, memberships } (the transport 'idle' | 'connecting' | 'open' | 'closed' plus every live connection and membership facade). The seed of any monitoring/devtools view.
  • useSocket({ hold? }) — the same shape as a live React value, re-rendering on every socket, connection, or membership move. hold: true also keeps the socket open while mounted; default false — a bare useSocket() only reads.
  • <Socket> — the keeper component: holds the socket open while mounted (instant first message), hold defaults to true here (holding is its whole job; hold: false releases). A keeper, not a provider: it provides no context and gates nothing, so its position in the tree carries no meaning.

One vocabulary across the vertical: hooks and components HOLD sockets, connections, and memberships — the last hold gone releases the thing after its linger.

During SSR nothing connects — useConnection / useMembership report the in-flight status on the server and do the real work after mount.

On pages and components — .with(channel) and .with(space)

A page, layout, or component can hold a connection or a membership declaratively, the way it holds a query:

export const ChatPage = root.lets
  .page('chatPage', '/chats/:chatId')
  .with(appChannel) // the connection lands in `connections`
  .with(chatSpace, ({ params }) => ({ chatId: params.chatId })) // the membership lands in `memberships`
  .page(({ connections, memberships }) => {
    const [connection] = connections
    const [membership] = memberships // typed: rooms, status, …
    return <Chat rooms={membership.rooms} />
  })

.with(channel, input?, opts?, gate?) holds a connection for the mountable and lands it in connections next to queries; .with(space, input, opts?, gate?) holds a membership and lands it in memberships (it needs the space's channel connection held in the same chain — a .with(channel) earlier, or an ambient one). By default only errors gate ({ loading: false, error: true }) — a socket is progressive enhancement, so the page shows while it connects, but a failed connect renders the HOSTING point's own .error(). Pass a trailing gate / gate: true — the same position a query injection's resolve gates with — to wait on connecting/joining too: the host's own .loading() / .error() render (during SSR too). The object form overrides only the named aspects (an unnamed key keeps its default); gate: false renders through everything. Whatever the gate, the injected facade type stays indeterminate — a connection carries no data and its status can flip on a reconnect at any moment, so gate gates the render, never the type. Both flow into with-fns and .mapper() like queries.

.with(...) is available on channel and space chains themselves too, not only on mountables — a channel or a space takes chain wrappers the way every other point does, and they compose in the usual order.

Outside hooks, getConnection(input?) / getMembership(membershipInput, channelInput?) look the live one up — no hold, no connect. Both throw when nothing matches; the *OrUndefined twins probe. On the server they throw (strict) or return undefined (probing) — nothing is ever connected there.

Client helpers

Two module-level helpers from @point0/core/socket reset every connection at once — the client side of a login/logout:

import { reconnectAll, disconnectAll } from '@point0/core/socket'

onLogin(() => reconnectAll()) // re-run every connect (identities rebuilt), re-join spaces, revive kicked-but-held ones
onLogout(() => disconnectAll()) // close everything now — the connectors re-judge whatever comes back

reconnectAll() is the client counterpart of the server's refresh; it also clears every preventRetry "sit out" mark — an explicit re-evaluation. disconnectAll() closes everything immediately; connections held by hooks and components then re-establish on their own (their nature is "stay connected while mounted"), which is exactly right on a logout — the connectors re-run against the cleared session and answer with the typed deny (ideally preventRetry), so the UI lands in the honest error state. Imperative connect() holders stay closed until their owner acts.

Reconnect

The socket reconnects on its own — the first retry immediately, then with backoff (reconnect options above). A drop nobody reported counts as one too: the client measures the socket against its own ping, and two pings answered by nothing at all — no pong, no push, no reply — plus more than two intervals of silence is a half-open connection (a NAT timeout, a machine that slept), so the client closes it locally and reconnects instead of writing pushes into a dead pipe. That is the client's half of the same contract the engine's 120 s idleTimeout keeps on the server side; ping: 0 switches off the client's half — the pings and the deadline they arm (the server's idleTimeout stands, so a silent client is still dropped there).

Every held connection re-runs its connect (the connector re-applies the check, the identity is rebuilt), and every held membership re-joins on the fresh connection — the client remembers its inputs. A connect the connector denies (a typed error) goes error and stops retrying — and a deny thrown with preventRetry additionally sits the connection out of future reconnect cycles until reconnectAll() or a remount; the same flag on a joiner deny stops the join from replaying. A client .sendToServer during the gap waits in a queue up to the handler's timeout (default 5000 ms); handlers that shouldn't wait (typing pings) opt out with queue: false. The queue never outlives an answer: a connect or join DENIED by the server fails the sends waiting on it immediately with that typed error — only a transport failure (which the reconnect policy keeps retrying) leaves them queued until their window runs out.

Pushes the server sent during the gap are gone on this path — there is no server-side buffer here (the contract is Delivery below; a resumable channel adds an opt-in one). Catching up is a refetch, and the lifecycle callbacks are where it lives. Both levels have a full set, point-level or per call: a connection takes onConnect / onDisconnect / onError, a membership takes onEnter / onLeave (an enrolled membership is outside this: no onEnter fires for it, and only an explicit leave() fires its onLeave). onConnect and onEnter fire on every successful connect/join — the first and the replays alike — and each callback receives the facade plus three facts about the entry: the counter (connectionIndex / membershipIndex — how many successful connects/joins came BEFORE this one), resumed (this entry rode the resume path — the connector/joiners were skipped; always false on a non-resumable channel), and gapless — provably nothing was missed: true on the first entry and on a resume whose buffers covered the whole gap, false on every other re-entry. Each callback's gapless speaks for exactly the data that reaches IT: onEnter's verdict covers that membership's rooms (and its space-wide pushes), onConnect's the channel-wide and connection-addressed pushes — so a gap in one busy chat never forces the quiet ones (or the global data) to refetch. Each fact answers its own question: "did I miss anything HERE?" → gapless; "did the connector re-run?" → resumed; "is this the first time?" → the index. The catch-up is therefore ONE condition, on both levels:

chatSpace.useMembership(
  { chatId },
  {
    onEnter: ({ gapless }) => {
      if (!gapless) void chatMessagesQuery.invalidateQuery({ chatId })
    },
  },
)
// channel-wide catch-up rides the connection instead:
appChannel.useConnection(undefined, {
  onConnect: ({ gapless }) => {
    if (!gapless) void notificationsQuery.invalidateQuery()
  },
})

On a plain channel gapless is simply index === 0 — the condition reads "is this a re-entry?" there — and on a resumable channel it stops refetching exactly when the buffer proves the refetch redundant.

Resumable connections

resumable: true on the channel makes a reconnect CHEAP: instead of re-running the connect request (connector), the joins (joiners) and the enrollments (enrollers), the client presents a per-connection resume key and the server restores the connection — identity, rooms, subscriptions — from what it already knows. A redeploy then costs the server a couple of KV reads per client instead of a thundering herd of full connects:

export const appChannel = root.lets
  .channel()
  .input(z.object({ deviceId: z.string() }))
  .connector(async ({ ctx }) => ({ userId: ctx.me.user.id }))
  .channel({
    resumable: true,
    server: { connectionTtl: 300_000 }, // the restore window IS the record's life
  })

Nothing changes in the client API — everything is automatic. The key arrives once with the connect confirmation and lives in the tab's memory (never in localStorage — a page reload is an honest full connect); the connection record every ping already renews becomes the passport: it carries the per-space rooms and the SHA-256 of the key (never the key itself — a leaked backplane mints no working credentials). The restore window is connectionTtl — there is no second knob: a live connection renews the record anyway, and a dead one leaves exactly the record behind.

What a resume restores and what it never bypasses:

  • connectionId survives the drop — addressed pushes keep their addressee across a blip (a full reconnect mints a new cid).
  • Enrolled memberships restore with the passport like joined rooms — the enroller does not re-run.
  • A server refresh bypasses resume — it exists to re-run the connectors, and it voids the key; the fresh connect mints a new one. A kick and a voluntary close delete the record, so the later resume is refused and the full connect puts the connector back in charge — revocation is never resumable. A refused resume falls back to the ordinary full connect of that one connection, automatically.
  • A space can opt OUT with resumable: false (a top-level space option): its rooms stay out of the passport and out of the restore — the client re-joins them itself from its own state (the joiner re-judges). For spaces whose rooms change many times a second (live-query-style rooms derived from client state), the passport write-through would hammer the backplane for rooms the client re-derives anyway.

The buffer. Restoring the connection does not by itself restore the frames pushed into the gap. A clientHandler opts its pushes in with its own top-level resumable (true = the default ceiling of 128, a number = up to that many of its frames buffered per stream); the object form adds the replay POLICY next to the ceiling:

export const messageReceivedHandler = chatSpace.lets
  .clientHandler()
  .serverSend(messageSchema)
  .clientHandler({ resumable: 128 })

// deltas are only valuable as a COMPLETE sequence — withhold the partial tail of a gappy recovery
export const docPatchHandler = docSpace.lets
  .clientHandler()
  .serverSend(patchSchema)
  .clientHandler({ resumable: { buffer: 256, replay: 'gapless' } })

replay: 'gapless' (default 'always') makes the server SKIP this handler's frames when replaying a stream whose recovery is not provably gapless: the client sees the honest gapless: false in onEnter/onConnect and refetches, without a partial tail arriving first. The withheld frames are not thrown away — they stay in the stream's log, and a later resume that IS provably clean from the client's cursor delivers them in full. Channel-wide, the policy is one chain line: .clientHandlerOptions({ resumable: { replay: 'gapless' } }).

For a per-listener decision instead of a per-handler declaration, every delivered message carries replayed in its props — false on a live push, { gapless } on a replayed one (its OWN stream's verdict):

messageReceivedHandler(chatMembership).onMessageFromServer(
  ({ message, replayed }) => {
    if (replayed && !replayed.gapless) return // the refetch below covers it
    appendMessage(message) // dedup by id — a replay may overlap the refetch
  },
)

The buffers live on topic streams — one per room, one per space, one channel-wide, plus a personal stream for connection-addressed pushes: one copy of every frame however many members subscribe, and every frame numbered per stream. On a resume the client presents its per-stream cursors and the server replays everything after them — all the streams merged back into ONE tail in the original delivery order, through the ordinary dispatch. gapless is then a proof, not a guess, and a PER-STREAM one: a stream's verdict is true only when its log covered that stream's whole gap and no non-opted handler pushed past the cursor — which is what makes the per-level verdicts above precise instead of one smeared bit. The buffers live in process memory: a same-process blip replays (gapless: true), a redeploy resumes without them (gapless: false — the one-condition catch-up above refetches), which is exactly why the truth stays in queries. The ceilings are the channel's server.resume group — streamMaxFrames (1024) and streamMaxBytes (4 MiB) per stream, a space overriding them for its own streams — and an overflow evicts oldest-first while honestly flipping the affected stream's gapless.

When the socket of a buffering connection dies, the server parks it for the server.resume.parkWindow (default 30 s): publicly the connection is dead at once — the leave and close events fire, presence counts drop, enumerations skip it — but its streams stay addressed, so every push that concerns it — its rooms, its cid, a matching $identity/$room selection — keeps landing in them, and a client that returns within the window misses nothing. The park itself buffers nothing (the streams are the buffer — a parked member costs a room push zero extra work). Past the window the connection leaves the streams; the record lives on to its own TTL, so a later resume still works, just without the replay. A kick reaches into the park too: channel.kick sweeps matching parked connections and deletes their records, and a space.kick removes the kicked rooms from parked connections and their passports — the returning client receives the forced leave right after the resume.

One price, deliberate: the identity a resume restores is as old as the record — the freeze is bounded by connectionTtl, and the admin answer is kick (instant) or refresh (soft), both of which force the full path. Delivery costs nothing extra: room, space-wide and channel-wide pushes ride the same one-publish fan-out as a plain channel's, buffered once per stream — however many members, whatever their parked state. The config cannot lie about itself: a buffering handler on a non-resumable channel, or on a handler of a resumable: false space, fails at the closer — as do resumable: false on a space of a non-resumable channel (nothing to opt out of) and .enroller on an opted-out space (a resume would silently drop the enrollments, which only re-run on a full connect).

Security, in three facts: the KV stores only the key's hash (compared in constant time), a wrong key and an unknown cid are refused identically (no oracle), and a resume against a LIVE connection is the takeover — the main scenario, not an edge: the client notices a dead network first (its ping deadline beats the server's idleTimeout), dials anew, and the server moves the connection to the new socket, closing the zombie one. Exporting the key to another tab is the documented "don't": two tabs with one cid steal the connection from each other, last wins.

Delivery

A push is best-effort, at-most-once: point0 writes one frame into a live socket and moves on. There are no acknowledgements and no retries. If the socket is gone at that instant — a reconnect gap, a redeploy, a closed tab — the frame is dropped, and no later reconnect replays it. The one bounded exception is opt-in: a resumable channel's opted-in handlers keep short in-memory stream logs for a resume to drain — and even there the per-level gapless flags tell the client honestly whether the logs covered its gap, so the refetch below stays the ground truth.

What does not happen is a live connection quietly missing pushes. A client too slow to read what the server writes loses its socket, not its frames — see outbound backpressure — so a gap always surfaces as a disconnect, which is what the reconnect catch-up below is for.

That is deliberate, because the truth lives in queries, not in pushes. A push is a signal — "something moved, look again" — and never carries the only copy of a piece of data. The durable copy is in the database, read through a query; the push just tells the client the query is stale. So catching up after any gap is a catch-up refetch, not a frame replay — which is exactly why a dropped push costs nothing.

Stream with history. When you want a client to render a stream and survive reconnects without losing entries, keep the stream in the database with a cursor and let pushes advance the tail. The client refetches from its last cursor on mount and on reconnect; each push carries the new tail (or just a "refetch" nudge). This is a few lines of your own code, not framework configuration:

// events persist with a monotonic cursor; the query reads "everything after `cursor`"
export const feedSinceHandler = feedSpace.lets
  .serverHandler()
  .clientSend(z.object({ after: z.string() }))
  .serverReply(async ({ input, room }) => ({
    events: await db.event.findMany({
      where: { room: room.feedId, id: { gt: input.after } },
    }),
  }))
  .query()
  .serverHandler()

// a bare trigger — no payload; the server fires it on each new event
export const feedBumpedHandler = feedSpace.lets.clientHandler().clientHandler()

// a push signals the new tail; the client refetches from the cursor it holds
export const FeedView = ({
  feedId,
  cursor,
}: {
  feedId: string
  cursor: string
}) => {
  const { data } = feedSinceHandler.useSocketQuery({ after: cursor })
  feedBumpedHandler.useOnMessageFromServer(() =>
    feedSinceHandler.invalidateSocketQuery(true),
  )
  return <Feed events={data?.events ?? []} />
}

The socket lost a push mid-gap? The refetch from cursor returns every event the database has, dropped frames included. History is the query's job; the push only shortens the latency.

Redeploys need no special handling for the same reason. On a rolling deploy the sockets drop; each client reconnects with backoff and re-runs its connect request, so the connector re-runs — a full re-authentication — and its memberships replay their joins while enrollers re-enroll on the fresh connection. Work in flight fails cleanly: an in-flight sendToServer fails by its own typed send timeout, and every connection / ticket record left in the socket backplane expires on its TTL. Nothing leaks across the restart, and the client is whole again after one reconnect. A resumable channel makes that reconnect cheap at scale: the fresh process restores each client from its record instead of running the whole connect cascade — same outcome, a fraction of the load.

Observability

Everything you need for metrics is synchronous reads and events — nothing has to be sampled from inside the machinery.

The process floor is engine.socket (the full field reference — engine-config): local.get() answers connections, sockets, rooms, parked resumable connections and the resume-stream buffers of THIS process; status() answers the backplane health. A metrics endpoint is one action point:

import { engine } from '../engine.js'

export const metricsAction = root
  .lets('action', 'metrics', 'GET', '/metrics')
  .action(async () => {
    const local = engine.socket.local.get()
    const service = engine.socket.status()
    return new Response(
      [
        `point0_socket_connections ${local.connections.length}`,
        `point0_socket_sockets ${local.socketsCount}`,
        `point0_socket_rooms ${local.roomsCount}`,
        `point0_socket_parked ${local.parkedCount}`,
        `point0_socket_stream_frames ${local.streams.frames}`,
        `point0_socket_stream_bytes ${local.streams.bytes}`,
        `point0_socket_stream_evicted_total ${local.streams.evictedFramesTotal}`,
        `point0_socket_bus_up ${service.started ? 1 : 0}`,
        `point0_socket_bus_subscriptions ${service.busSubscriptions}`,
      ].join('\n'),
      { headers: { 'Content-Type': 'text/plain; version=0.0.4' } },
    )
  })

Scrape it per process — the snapshot is deliberately the LOCAL floor, so a cluster's dashboards sum the processes (which is what you want: per-process memory and per-process buffers are what pages an operator).

Cluster-wide gauges live on the points, not on the engine: appChannel.connections.server.count() and chatSpace.memberships.server.count({ room }) scatter-gather over the backplane bus — use them for product-level numbers ("users online", "listeners in this room"), not for scrape loops (each call is a bus round trip with a gather window).

Event-driven counters ride the server events: count pointChannelOpenServer / pointChannelCloseServer for connect/disconnect rates, the pointSpaceJoinServer* / pointSpaceLeaveServer families for room churn — every handler gets the identity and the connection id, so per-tenant breakdowns are one field away.

What to alert on: streams.bytes approaching your server.resume.streamMaxBytes budgets and a growing streams.evictedFramesTotal — evictions mean the buffers are smaller than the traffic and resumes are degrading into honest gapless: false refetches (raise the ceilings or trim the buffering handlers); a persistently high parkedCount — clients are dying faster than they resume; bus_up at 0 — the backplane subscription is down and cross-process delivery is degraded.

The events

Every socket operation emits the framework's lifecycle events — subscribe with .on / .serverOn / .clientOn on any point up the chain, the usual machinery. The four-phase families are split by side — the two sides are genuinely different operations with different data, not one event observed twice: the Server family fires around the server execution (the connector, the .joiner, .serverReply) and its data carries the connection identity; the Client family fires around the client operation (the connect request, the join frame, a clientHandler dispatch) and has no identity field at all — the identity never leaves the server. Besides the families, the server-only singles (pointChannelOpenServer / pointChannelCloseServer / pointSpaceLeaveServer) carry the full identity too; the four-phase server events carry the bare connectionId (a connect family's Start has none yet — no connection exists there — and a failed connect's Settled / Error carry it undefined).

EventWhen it fires
pointChannelConnectServer*four phases around the connector run — before the claim
pointChannelConnectClient*four phases around the connect request
pointChannelOpenServer / pointChannelCloseServerserver-only singles — a connection became live / went away
pointSpaceJoinServer*four phases around each .joiner / .enroller run
pointSpaceJoinClient*four phases around the join frame
pointSpaceLeaveServerserver-only single — a membership left its rooms
pointHandlerServer*four phases around a .serverReply run
pointHandlerClient*four phases around a clientHandler dispatch
socketServer* / socketClient*socket-level singles — the upgrade, a socket arriving / leaving, the one WebSocket opening / closing

Four phases means Start, Settled, Success, Error — the standard lifecycle phases; what each event's data carries is enumerated in the per-event reference.

Connect vs open — the claim is the watershed

pointChannelOpenServer / pointChannelCloseServer are per-connection singles, server only: a channel connection actually became live (its ticket was claimed, or its cold-start upgrade landed) or went away. They're distinct from the four-phase pointChannelConnectServer* family, which fires at connector time — before the claim — so a connect success is not yet a live connection. pointChannelOpenServer carries resumed: true when the connection came back through a resume revival (an unpark or a KV restore — no connector ran for that open), false on a real claim. pointChannelCloseServer carries a reason: 'close' (the client left), 'socket' (the socket died), or 'kick' (a server-side channel.kick).

The join family straddles the registration

pointSpaceLeaveServer is the space counterpart of pointChannelCloseServer — a per-membership single, server only: a client left a space's rooms. Its four-phase siblings pointSpaceJoinServer* / pointSpaceJoinClient* fire around the join (the .joiner on the server, the join frame on the client) — the server family also fires around each .enroller run, with an empty input: an enrollment is a server-initiated join. On the server the two ends of the family straddle the registration: Start fires before the .joiner / .enroller runs, while Settled / Success fire after the rooms are registered — so a handler on pointSpaceJoinServerSuccess reading space.memberships.server.list({ room }) already sees the joiner (that is the presence recipe), symmetrically with pointSpaceLeaveServer, which fires after the removal. A join the server refuses after the joiner ran (the socket died mid-join, the rooms would pass maxRooms) closes the family with Settled / Error instead — only a join that landed announces a Success. Success carries the rooms the client entered ([] = a clean deny). The server family's Start, Success, and Settled's success side also carry resumed: a resume revival re-announces the restored rooms through the family (Start included, with an empty input, like an enrollment) flagged true — no joiner or enroller ran for it; a real join, enrollment, or space.enroll carries false, and the error variants carry no resumed at all (a refused resume never reaches the family). pointSpaceLeaveServer carries a reason: 'leave' (the client left), 'socket' (the socket died), 'kick' (a space.kick), or 'close' (the whole connection closed).

The client families carry the counters and markers

The client socket families carry the lifecycle counters and entry markers — the same values the onConnect / onEnter callback props read. Every phase of pointChannelConnectClient* carries connectionIndex and every phase of pointSpaceJoinClient* carries membershipIndex (successful entries before this operation — 0 = the first, > 0 = a re-connect / a replay; a reconnect is the next Start with an index > 0, not a separate event). The successful outcome — Success and the success side of Settled — additionally carries resumed (the entry rode the resume path: no connect request / join frame, no connector / joiner run) and gapless (the server's proof that nothing was missed BY THIS LEVEL — the connect family's bit covers the channel-wide and connection-addressed pushes, each join family's its own membership's rooms: true on the first entry and on a fully-covered resume). A landed resume closes each family with Settled → Success and no Start — the resume is one shared frame at the socket's open, not a per-connection client operation, and a refused resume falls back into the full connect / join whose family runs the complete cycle. The error phases carry the index but no markers — a failed operation has no entry to describe.

The socket singles

The socket* events (socketServerUpgrade / socketServerConnect / socketServerDisconnect / socketClientConnect / socketClientDisconnect) are socket-level singles, not four-phase families, split by side the same way: the server family is the bare websocket endpoint accepting an upgrade through the fetch pipeline (socketServerUpgrade) and a socket landing on / leaving the process; the client family is the one WebSocket opening and closing. socketClientConnect fires on every successful open — the first and the re-opens alike, there is no separate reconnect event (the emit rides a channel point of the scope, so a socket held with zero connections opens silently) — and its data carries socketIndex (0 = the first open, > 0 = a reopen), the same first-vs-repeat convention as the lifecycle callbacks' indexes.

Recipes

Ways to put the pieces together — nothing here is required.

Live query

When a page already loads its data through a query, a room push can write into that query's data — the page re-renders through the useQuery it already has, and the pushed data survives the component unmounting:

export const messageReceivedHandler = chatSpace.lets
  .clientHandler()
  .serverSend(messageSchema)
  .clientHandler({
    client: {
      onMessageFromServer: ({ message }) => {
        chatMessagesQuery.setQueryData({ chatId: message.chatId }, (old) => ({
          messages: [...(old?.messages ?? []), message],
        }))
      },
    },
  })

User space — hot personal pushes

For per-user pushes, a user space whose .enroller puts each connection into its own room is the hot path: the server enrolls at connect time, the client joins nothing, and the push addresses the room by name (a pub/sub topic), no scan. Reach for the $identity matcher only for rare admin fan-outs:

export const userSpace = appChannel.lets
  .space<{ userId: string }>()
  .enroller(({ identity }) => ({ userId: identity.userId })) // your own room, no client code
  .space() // no .joiner: only the server puts anyone in here

export const notificationHandler = userSpace.lets
  .clientHandler()
  .serverSend(notificationSchema)
  .clientHandler({
    client: { onMessageFromServer: ({ message }) => notify(message) },
  })

// anywhere on the server — a direct topic push, no scan
void notificationHandler.sendToClient(notification, { room: { userId } })

Global space vs channel handlers

A roomless message — an app-wide announce, an RPC-style markAllRead, a connection-level query — grows straight from the channel: its handlers address connections (bare / connectionId / $identity), no room ceremony. When you want join-semantics for a broadcast instead (an explicit membership everyone holds), a global space with joiner(() => ({})) gives one real empty room:

export const globalSpace = appChannel.lets
  .space() // no generic — the room shape is the empty object, so the space is ONE room
  .joiner(() => ({})) // and this is what makes it joinable: no joiner, no client joins
  .space()

Channel handlers stay for what a global space would only dress up: the *all* topic is subscribed automatically at connect, so a channel clientHandler works from the root with no membership to hold.

Presence

Who's online is a composition of primitives you already have: the live list from space.memberships.server.list({ room }) (a { connectionId, identity, rooms } per member — memberships.server.count when the number is enough), and the server-only lifecycle events pointSpaceJoinServerSuccess / pointSpaceLeaveServer (each carries the rooms that changed; leave also its reason). Both fire when the change is already DONE — the join is registered before Success, the leave removed before its event — so the list you read inside the handler is the list after the change: the member who just joined is in it, the one who just left is not. Compute the list once, push the data. When a member joins or leaves, the server computes the room's roster a single time and pushes the READY list to the room; every member writes it straight into the whoIsHere query's cache with setSocketQueryData — nobody refires a query, however big the room. The query itself stays the source of truth for first paint and for catching up after a gap:

export const chatSpace = appChannel.lets
  .space<{ chatId: string }>()
  .input(z.object({ chatId: z.string() }))
  .joiner(({ input }) => ({ chatId: input.chatId }))
  // a membership joined its rooms / left them — compute the list ONCE, push it to each room
  .serverOn(
    ['pointSpaceJoinServerSuccess', 'pointSpaceLeaveServer'],
    async (event) => {
      // the event's `rooms` is `unknown[]` up the chain — name the space's room shape
      for (const room of event.data.rooms as Array<{ chatId: string }>) {
        const here = await chatSpace.memberships.server.list({ room })
        const nicknames = here.map((membership) => membership.identity.nickname)
        void presenceChangedHandler.sendToClient({ nicknames }, { room })
      }
    },
  )
  .space()

// server → client — carries the READY member list, computed once per change
export const presenceChangedHandler = chatSpace.lets
  .clientHandler()
  .serverSend(z.object({ nicknames: z.array(z.string()) }))
  .clientHandler()

export const whoIsHereHandler = chatSpace.lets
  .serverHandler()
  .serverReply(async ({ room }) => {
    const here = await chatSpace.memberships.server.list({ room })
    return { nicknames: here.map((membership) => membership.identity.nickname) }
  })
  .query()
  .serverHandler()
export const PresenceList = ({ chatId }: { chatId: string }) => {
  const membership = chatSpace.useMembership({ chatId })
  const { data } = whoIsHereHandler(membership).useSocketQuery()
  presenceChangedHandler(membership).useOnMessageFromServer(({ message }) => {
    whoIsHereHandler(membership).setSocketQueryData(undefined, () => ({
      nicknames: message.nicknames,
    }))
  })
  return <Avatars nicknames={data?.nicknames ?? []} />
}

Dedup when one user opens several tabs (each tab is its own connection), and remember a space.kick fires pointSpaceLeaveServer like any leave.

Origin allowlist for the socket

Browsers do not apply CORS to WebSockets — any page can attempt the handshake. The socket upgrade rides the full fetch pipeline as the websocket request variant, so the gate is an ordinary middleware: answer anything other than the pipeline's marker response and the upgrade is off.

export const root = Point0.lets('root', 'root')
  .middleware(async ({ request, next }) => {
    if (
      request.variant.type === 'websocket' ||
      request.variant.type === 'endpoint'
    ) {
      const origin = request.headers.origin
      if (origin && !ALLOWED_ORIGINS.includes(origin)) {
        return new Response('Origin not allowed', { status: 403 })
      }
    }
    return await next()
  })
  .root()

The endpoint arm covers the cold-start channel-endpoint upgrade (and every other endpoint) with the same list; scope it tighter if you only want to gate the socket.

The usual point machinery

  • Events — every socket operation emits the standard lifecycle events: the four-phase families pointChannelConnectServer*/Client*, pointSpaceJoinServer*/Client*, pointHandlerServer*/Client*, the server-only singles pointChannelOpenServer / pointChannelCloseServer / pointSpaceLeaveServer, and the socket-level socket* singles. When each fires and what it carries: The events; the subscribing machinery itself — events.
  • Transformer — everything the sockets put on the wire goes through the point's transformer: handler inputs, replies, the identity (channel transformer), rooms (space transformer — their serialization is the room identity), every socket frame. Set .transformer(superjson) up the chain and Dates or Maps survive the socket too. A channel can also opt its whole subtree OUT with the preventTransformer: true channel option — plain JSON for raw external consumers, and that channel's schemas must stay JSON-representable (details).
  • Options live on points — .channelOptions(...), .spaceOptions(...), .serverHandlerOptions(...), .clientHandlerOptions(...), declarable on root/base/plugin (and handler options on a channel too). On a point they are GROUPED by side — { server: {...}, client: {...} }, with the rare both-sides option (preventTransformer) top-level; call sites stay flat, since a call already names its side. Resolution lowest-to-highest, like queries, per group; callbacks at every level run in order. server.backplane is the engine's, like the logger. Full option sets: Reference.
  • Plugins — .use fits channels (a connect is an ordinary request); on spaces and handlers it's a type error, as is .ctx.
  • Stripping — same categories as query. On handlers the method name carries the AUTHOR, not the strip side — the reader is the opposite end, and the strip follows the reader. Server-only (cut from the client bundle, with its imports): the channel .connector, the space .joiner and .enroller, .input, .clientSend, .serverReply, .ctx, .middleware, .serverOn, plus .clientReply's schema argument. What gets cut is the arguments, never the call — the client bundle still carries a bare .joiner(), which is how it knows that a space without one refuses joins. Client-only (cut from the server bundle): .serverSend, .clientReply's callback, .clientOn. Isomorphic: .tag, .on, and the closing calls (.channel / .space / .serverHandler / .clientHandler). Options objects split STRUCTURALLY, by group: the client bundle loses the whole server property, the server bundle the whole client one, with their imports; anything top-level (preventTransformer) stays on both. That is why the options argument of these eight methods must be an object literal without a top-level spread — a variable, a call or a spread there is a compile error naming the method. Inside a group anything goes: server: myCaps is fine, the whole property is what gets dropped.

Under the hood

  • Connect hits the channel endpoint — a real dual-method endpoint (GET for a short input, POST for a long one), with middleware, ctx, connector, typed errors. The response carries a one-time ticket the socket claims; on an upgradable channel's cold start with a short input the GET upgrades straight into the socket instead (one round trip, no ticket — see the two connect paths). Middleware and plugins see only this handshake.
  • The socket is one per client, opened lazily; everything multiplexes over it. Its endpoint is GET /_point0/<scope>/websocket (on when the engine's socket server option is), and the upgrade rides the full fetch pipeline as its own request variant — middlewares run before the handshake and can veto it by answering an ordinary response (see the Origin allowlist recipe). Frames are JSON envelopes whose payload fields went through the transformer: claim / claimed / claimErr (plus discard and close), join / joined / joinErr / leave / left, enrolled (a server-side space.enroll grew a connection's enrollment — carrying the full new room set), send / reply / sendErr, msg, ping/pong, resume / resumed / resumeErr (the resumable handshake), and the server-initiated closed (a channel kick) and refresh. The claimed confirmation carries the .enroller enrollments.
  • Rooms are Bun pub/sub topics, namespaced by space — <scope>:<space>:<serialized room>; the channel-wide <scope>:<channel>:*all* is subscribed at connect, and the space-wide <scope>:<space>:*space* with the first membership of a space on the socket (released with the last) — a bare space send publishes there. Within a process Bun fans a push out; across processes it rides the backplane broadcast channel. A room push is one frame per socket carrying the space and the serialized room; a channel push carries the cid or nothing (all).
  • The server stores connection + rooms — nothing else. The join input never outlives the .joiner call: it goes in, rooms come out, it is forgotten (the client keys its hooks by input, like query keys — a purely client-side concern). A repeat join only ever ADDS rooms; removal is leave, a kick, or a refresh. The leave frame NAMES the rooms to drop — the client owns the shared-room refcount across its own memberships, and the server drops what it is told regardless of how the room was entered, which is why an enrollment leaves through that very frame.
  • Handlers load before the connect. A clientHandler registers its dispatch entry when its module evaluates — and connecting a channel first imports every handler of that channel from the generated manifest, so a handler module nothing imports still catches pushes.
  • In dev the client dev server proxies the socket to the engine — the bare /_point0/<scope>/websocket upgrade and the cold-start channel-endpoint upgrade alike — so sockets work from the client port with Bun and Vite, --hot included.
  • Ordering is FIFO per socket, both directions; delivery is at-most-once — no replay after reconnect (the one bounded exception is the opt-in resume buffers, whose replay keeps the original delivery order across streams).
  • Channel connects appear in the OpenAPI spec (real endpoints, both methods), and so does the bare websocket endpoint (documented for every scope with a channel in the point list — filter the channels out to hide it); the joins and the messages on the socket don't.
  • Dead sockets close on their own, from both ends. The server's half is idleTimeout 120 s, which the client ping (30 s) keeps a live socket under: a silently dead client is dropped and its records expire on their TTL. The client's half is the deadline the same ping arms — two pings answered by nothing at all and more than two intervals of silence means a half-open socket, so the client closes it itself and reconnects (see Reconnect).

Not shipped yet (planned): durable message history (the resume stream logs are in-memory and bounded — past them, catching up is the refetch), AsyncAPI, binary payloads, rate limiting.

Reference

What each callback receives

CallbackReceivesReturns
channel .connector{ input, ctx, request, set, points }the connection identity (an object) or nothing
space .joiner{ input, identity, connectionId, points }a room, an array of rooms, [], or nothing — checked against the opener's room shape (no schema: server-trusted)
space .enroller{ identity, connectionId, points } — no input, the server enrolls on its owna room, an array of rooms, or nothing — same check
onBeforeJoiner / onAfterJoiner (space options){ input, identity, connectionId, point, points }; after adds { output, error }— (a before-guard throw fails the join)
serverHandler .serverReply{ input, identity, connectionId, messageId, points } + room (space handler) + reply (with .serverReply<T>())the reply (or nothing after an imperative reply())
clientHandler .clientReply{ message, connection } + room (space handler) — client sidethe reply (schema as 2nd arg, checked on the server)
onMessageFromServer / useOnMessageFromServer callback{ message, connection, point } + room (space handler) — up the chain message is unknown, narrow by point—
onReply (in .sendToClient's replies argument){ data, connectionId }—
onBeforeServerReply / onAfterServerReply (options){ input, identity, connectionId, messageId, point, points } + room (space handler); after adds { output, error }— (a before-guard throw rejects the send)
onReplyFromServer (a serverHandler option){ input, data, connection, point } — client side, fires on every reply—

Server-side callbacks carry the bare connectionId string; read the connection's current rooms with the synchronous space.memberships.server.local.rooms({ connectionId }). On the client the connection facade is { status, error, isLoading, input, id, connectionIndex, disconnect() } (no data, no room); the membership facade is { status, rooms, error, isLoading, input, membershipIndex, connection, leave() }.

Options

One rule across every family: the POINT surface (the chain .xOptions() and the closer) takes all options of the family, GROUPED by the side that reads them — { server: {...}, client: {...} }, with the rare both-sides option top-level next to the groups. A CALL SITE takes exactly its own side, FLAT: the call already names the side, so useConnection({ reconnect }), sendToServer(input, { timeout }) and useMembership(input, { linger }) list the client-read options plus their call-only fields (enabled). Server-read options (caps, server guards) exist on the point only.

The grouping is what makes the compiler's split structural — the client bundle drops the whole server property, the server bundle the whole client one — so the options argument must be an object literal without a top-level spread. A group's value may be any expression (.channel({ server: myCaps })).

.channel({...}) — also via .channelOptions() up the chain. The client rows are also accepted per call on useConnection / connect (flat):

GroupOptionDefaultWhat it does
(call site only)enabledtrueconnect or not — a call-site field, never on the point
clientreconnectona boolean or { enabled, immediately, delay, backoff, maxDelay, retries } — first retry immediate, then 300 ms doubling up to 5 s
clientupgradablefalseallow the cold-start GET+Upgrade connect (one round trip, no ticket); off = every connect takes the ticket path — a plain fetch whose .fetchOptions apply and whose connector error is readable (the two connect paths)
clientlinger1000ms a connection outlives its last hold
clientping30_000keepalive interval; effective on the root — one socket. It also arms the client's liveness deadline: two pings answered by nothing at all and more than two intervals of silence is a half-open socket, which the client closes and reconnects. 0 turns off both halves
clientupgradeTimeout5000ms a cold-start GET+Upgrade waits for its confirmation before falling back to the ticket path (a middlebox may swallow a handshake without failing it)
clientresumeTimeout5000ms a resume offer waits for its answer before that connection falls back to the full connect (a rolling deploy may not speak resume yet)
clientonConnect / onDisconnect / onError—connection lifecycle callbacks, chain in order; onConnect fires on every landed connect — connectionIndex 0 = the first, > 0 = a reconnect; the props also carry resumed/gapless (resumable connections)
servermaxMessageSize1_048_576rejects bigger incoming frames
servermaxConnections32live connections to this channel one SOCKET may hold — point0's client keeps one socket, so one browser tab; other sockets, other tabs and other channels each count on their own, so it bounds a socket, never a user. 1 is a fine value (the socket then holds one connection to the channel at most) and costs no reconnects: a socket takes its connections down with it, one that dies unnoticed goes on the idle timeout, and the fresh socket counts from zero
serverconnectionTtl90_000ms a connection record lives in the backplane without a ping renewal (keep it above ping)
serverresumesee rightthe resume tuning, only with resumable: true (refused otherwise): { parkWindow: 30_000, streamMaxFrames: 1024, streamMaxBytes: 4_194_304 } — the park window and the per-stream buffer ceilings; merged per key across chain → closer
(top level)preventTransformerfalseboth sides read it — true makes this channel's whole wire (spaces and handlers included) plain JSON for raw consumers; resolved at .channel(), never per call; the channel's schemas must stay JSON-representable (details)
(top level)resumablefalseboth sides read it — true makes the connections resumable: a reconnect restores identity/rooms from the record instead of re-running the connector/joiners/enrollers; the restore window is connectionTtl

.space({...}) — also via .spaceOptions() up the chain. The client rows are also accepted per call on useMembership / join:

GroupOptionDefaultWhat it does
clientlinger1000ms a membership outlives its last hold — the membership twin of the channel linger
clientonEnter / onLeave—membership lifecycle callbacks; onEnter fires on every landed join — membershipIndex 0 = the first, > 0 = a replay; the props also carry resumed/gapless
servermaxRooms256rooms of this space one connection may be in, however it got there (join / enroller / enroll); Infinity opts out
serveronBeforeJoiner—guard per incoming join — throw to refuse it. Typed at the closer
serveronAfterJoiner—observer after the join settles — audit/metrics
serverresume—{ streamMaxFrames?, streamMaxBytes? } — this space's own buffer ceilings, overriding the channel's server.resume for its room and space-wide streams; needs the space in the resume (refused on a plain channel or an opt-out space)
(top level)resumable—false opts the space OUT of a resumable channel's restore: rooms out of the passport, the client re-joins them itself; needs a resumable channel and refuses .enroller — both fail at the closer

.serverHandler({...}) — also via .serverHandlerOptions(). The client rows are also accepted per send as sendToServer(input, {...}). There is no room call option — a space handler's send addresses the room it was BOUND to (handler(room).sendToServer(...)):

GroupOptionDefaultWhat it does
clienttimeout5000how long this send waits for its reply — awaiting the connect/join and queueing through a reconnect included; a channel-wide default is .serverHandlerOptions({ client: { timeout } }) on the channel chain
clientqueuetruequeue through a reconnect; false = fail fast
clientonReplyFromServer—callback on every reply — here and up the chain (there data is unknown, narrow by point)
serveronBeforeServerReply—guard per incoming message — throw to refuse it. Typed at the closer
serveronAfterServerReply—observer after the reply settles — audit/metrics

.clientHandler({...}) — also via .clientHandlerOptions():

GroupOptionDefaultWhat it does
clientonMessageFromServer—module-level listener — here and up the chain; listeners add up
servertimeout5000the reply-collection window when replies are requested
(top level)resumable—opt this handler's pushes into the resume buffers — true = the default ceiling of 128, a number = up to that many of ITS frames per stream (the stream totals are the channel's server.resume); the object form { buffer?, replay? } adds the policy: replay: 'gapless' withholds this handler's frames from a gappy recovery's replay

The two timeouts of the handler families are told apart by their group: the serverHandler's is a client option (how long a send waits), the clientHandler's a server one (how long a push collects replies).

Per-send targeting (the target argument — room / connectionId / $identity / except) and collection (the replies argument — true / { timeout, onReply, waitForAll }) are call-site-only.

Method surface

PointMethodReturns
channeluseConnection(input?, options?) / connect(input?, options?)the connection facade
channelgetConnection(input?) / getConnectionOrUndefined(input?) — client sidethe live connection, no hold
channel<Connection input options gate LoadingComponent ErrorComponent> / .letsholds a connection / grows spaces and handlers
channelkick(target?) / refresh(target?) / amendIdentity(target, patch) — serverPromise<void> each
channelconnections.server.count/.list/.forEach(target?, options?) — server, the clusternumber / Array<{ connectionId, identity, spaces }> / stream
channelconnections.server.local.count/.list(target?) — server, synchronous, this processnumber / Array<{ connectionId, identity, spaces }>
channelconnections.client.count/.list() — client, synchronous, this tabnumber / ClientChannelConnection[] — the live facades
spaceuseMembership(input, options?) / join(input, options?, channelInput?)the membership facade
spacegetMembership(membershipInput, channelInput?) / getMembershipOrUndefined(...)the live membership, no hold
space<Membership input options gate LoadingComponent ErrorComponent> / .letsholds a membership / grows handlers
spacekick(target?) — serverPromise<void> — a forced leave of the matching rooms
spacememberships.server.count/.list/.forEach(target?, options?) — server, the clusternumber / Array<{ connectionId, identity, rooms }> / stream
spacememberships.server.local.count/.list/.rooms(target?) — server, synchronous, this processnumber / Array<{ connectionId, identity, rooms }> / Room[]
spacememberships.client.count/.list() — client, synchronous, this tab (enrolled included)number / ClientSpaceMembership[] — the live facades
serverHandlersendToServer(input?, options?) — client sidePromise<reply>
serverHandleruseSocketMutation / useSocketQuery / useSocketInfiniteQuerythe flavor's TanStack surface (mutation is the default)
serverHandlerthe query flavors' full family — useSuspenseSocket*, fetchSocket*, prefetchSocket*, ensureSocket*Data, getSocket*Options/Key/Data/State/Cache(s), setSocket*Data, refetch/invalidate/cancel/remove/resetSocket*mirrors the regular query surface, transport socket (* = Query / InfiniteQuery, paired per flavor)
serverHandlerthe mutation flavor's family — fetchSocketMutation / getSocketMutationKey / getSocketMutationOptions / getSocketMutationCache(s)mirrors the regular mutation surface
clientHandlersendToClient(message, target?, replies?) — server side (the $-dictionary target)follows replies: nothing / async iterable / array / void
clientHandleruseOnMessageFromServer(cb, options?) / onMessageFromServer(cb) — client sidehook: data with lastMessageFromServerAsData / imperative: { remove() }
clientHandleriterateMessagesFromServer(options?) — the pushes as an async iterableAsyncGenerator<message> — parks through a drop, ends when the target closes, throws its typed error
either handlerhandler(connection | channelInput) (channel) / handler(room | membership, channelInput?) (space)the bound surface — the client methods with the target fixed; a space handler addresses a ROOM

All four also expose the standard id (scope:type:name), type ('channel' / 'space' / 'serverHandler' / 'clientHandler'), tags, point, and Infer.

PreviousRSCNextRequest

Enjoying Point0?

Give it a star on GitHub, ask questions, and follow what's new

Star Point0

A star on GitHub helps more developers find Point0
Star on GitHub

Start0

Point0 integrated into the Start0 SaaS boilerplate — the best way to use it
Explore Start0

YouTube

Tutorials, walkthroughs, and updates
YouTube

Discord

Questions and chat in English
Join Discord

Telegram

News channel and chat in Russian
ChannelChat

Twitter

Posts and updates
Twitter