With love for developers of all backgrounds around the world ❤️
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.
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.
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.
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 body | GET or POST by input length; binary inputs ride the POST | GET only — the input rides ?input= under the URL-length cap; too long or binary falls back to the ticket path |
| Custom client headers | a plain fetch — .fetchOptions headers and credentials apply | browser JS cannot attach custom headers to a handshake (new WebSocket(url) exposes none) — only browser-set ones (Cookie, Origin) arrive |
| CORS | ordinary CORS rules | a WebSocket handshake skips CORS entirely — control Origin with a middleware |
| Connector errors | the typed error arrives in connection.error | a 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) |
| Reconnects | always this path | never — 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 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.
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:
undefined) → joins none — a clean deny;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.
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 holdrooms 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 })?.roomsA 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().
.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.
local floorThe 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.)
Naming —
input/message/data. One rule across every socket callback:inputis the payload of a CALL — someone called a point and gets an answer (the channel connect, a space join, a serverHandler send — query vocabulary);messageis the payload of a PUSH — the server broadcasts, nobody awaits (sendToClient→.clientReply, theonMessageFromServerlisteners,iterateMessagesFromServer);datais the other side's ANSWER to you (await sendToServer()resolves with it,onReplyFromServer.data, a collected push reply). So.serverReplyreadsinputand.clientReplyreadsmessage— 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.
.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 connectionA 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 inputThe 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 roomThere 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.
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 senduseSocketMutation(options?) /
fetchSocketMutation(input?, options?) (the non-hook run through the mutation
cache machinery — the plain sendToServer bypasses it) /
getSocketMutationKey / getSocketMutationOptions / getSocketMutationCache
/ getSocketMutationsCacheEach 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 entryThe 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 allsetSocketQueryData(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.
replyThe 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.
.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.
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.
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.
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.
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).
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 themA 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.
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.
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.
amendIdentitychannel.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.
space.kickSpace 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.
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:
| Floor | Reads | Shape |
|---|---|---|
server | the whole cluster, over the bus | async — a snapshot over a window |
server.local | this process only | synchronous, plain values |
client | this browser tab only | synchronous, 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 → AsyncIterableEvery 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 holdSame $-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.
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.*.
server.backplaneThe 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.
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),
}| Adapter | For | Install |
|---|---|---|
@point0/engine/backplane/postgres | Postgres via postgres.js — real push, no Redis | postgres |
@point0/engine/backplane/ioredis | an ioredis client you already hold (BullMQ, Sentinel, custom TLS) | ioredis |
@point0/engine/backplane/node-redis | a node-redis (redis package, v4/v5) client you already hold | redis |
@point0/engine/backplane/bun-redis | Bun'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 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.
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 holdThere 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.
.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.
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 backreconnectAll() 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.
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: 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).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.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.
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.
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.
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).
| Event | When it fires |
|---|---|
pointChannelConnectServer* | four phases around the connector run — before the claim |
pointChannelConnectClient* | four phases around the connect request |
pointChannelOpenServer / pointChannelCloseServer | server-only singles — a connection became live / went away |
pointSpaceJoinServer* | four phases around each .joiner / .enroller run |
pointSpaceJoinClient* | four phases around the join frame |
pointSpaceLeaveServer | server-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.
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).
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 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* 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.
Ways to put the pieces together — nothing here is required.
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],
}))
},
},
})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 } })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.
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.
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.
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(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)..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..use fits channels (a connect is an ordinary request); on
spaces and handlers it's a type error, as is .ctx..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.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.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.<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)..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./_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.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.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.
| Callback | Receives | Returns |
|---|---|---|
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 own | a 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 side | the 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() }.
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):
| Group | Option | Default | What it does |
|---|---|---|---|
| (call site only) | enabled | true | connect or not — a call-site field, never on the point |
client | reconnect | on | a boolean or { enabled, immediately, delay, backoff, maxDelay, retries } — first retry immediate, then 300 ms doubling up to 5 s |
client | upgradable | false | allow 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) |
client | linger | 1000 | ms a connection outlives its last hold |
client | ping | 30_000 | keepalive 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 |
client | upgradeTimeout | 5000 | ms 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) |
client | resumeTimeout | 5000 | ms a resume offer waits for its answer before that connection falls back to the full connect (a rolling deploy may not speak resume yet) |
client | onConnect / 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) |
server | maxMessageSize | 1_048_576 | rejects bigger incoming frames |
server | maxConnections | 32 | live 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 |
server | connectionTtl | 90_000 | ms a connection record lives in the backplane without a ping renewal (keep it above ping) |
server | resume | see right | the 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) | preventTransformer | false | both 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) | resumable | false | both 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:
| Group | Option | Default | What it does |
|---|---|---|---|
client | linger | 1000 | ms a membership outlives its last hold — the membership twin of the channel linger |
client | onEnter / onLeave | — | membership lifecycle callbacks; onEnter fires on every landed join — membershipIndex 0 = the first, > 0 = a replay; the props also carry resumed/gapless |
server | maxRooms | 256 | rooms of this space one connection may be in, however it got there (join / enroller / enroll); Infinity opts out |
server | onBeforeJoiner | — | guard per incoming join — throw to refuse it. Typed at the closer |
server | onAfterJoiner | — | observer after the join settles — audit/metrics |
server | resume | — | { 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(...)):
| Group | Option | Default | What it does |
|---|---|---|---|
client | timeout | 5000 | how 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 |
client | queue | true | queue through a reconnect; false = fail fast |
client | onReplyFromServer | — | callback on every reply — here and up the chain (there data is unknown, narrow by point) |
server | onBeforeServerReply | — | guard per incoming message — throw to refuse it. Typed at the closer |
server | onAfterServerReply | — | observer after the reply settles — audit/metrics |
.clientHandler({...}) — also via .clientHandlerOptions():
| Group | Option | Default | What it does |
|---|---|---|---|
client | onMessageFromServer | — | module-level listener — here and up the chain; listeners add up |
server | timeout | 5000 | the 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.
| Point | Method | Returns |
|---|---|---|
| channel | useConnection(input?, options?) / connect(input?, options?) | the connection facade |
| channel | getConnection(input?) / getConnectionOrUndefined(input?) — client side | the live connection, no hold |
| channel | <Connection input options gate LoadingComponent ErrorComponent> / .lets | holds a connection / grows spaces and handlers |
| channel | kick(target?) / refresh(target?) / amendIdentity(target, patch) — server | Promise<void> each |
| channel | connections.server.count/.list/.forEach(target?, options?) — server, the cluster | number / Array<{ connectionId, identity, spaces }> / stream |
| channel | connections.server.local.count/.list(target?) — server, synchronous, this process | number / Array<{ connectionId, identity, spaces }> |
| channel | connections.client.count/.list() — client, synchronous, this tab | number / ClientChannelConnection[] — the live facades |
| space | useMembership(input, options?) / join(input, options?, channelInput?) | the membership facade |
| space | getMembership(membershipInput, channelInput?) / getMembershipOrUndefined(...) | the live membership, no hold |
| space | <Membership input options gate LoadingComponent ErrorComponent> / .lets | holds a membership / grows handlers |
| space | kick(target?) — server | Promise<void> — a forced leave of the matching rooms |
| space | memberships.server.count/.list/.forEach(target?, options?) — server, the cluster | number / Array<{ connectionId, identity, rooms }> / stream |
| space | memberships.server.local.count/.list/.rooms(target?) — server, synchronous, this process | number / Array<{ connectionId, identity, rooms }> / Room[] |
| space | memberships.client.count/.list() — client, synchronous, this tab (enrolled included) | number / ClientSpaceMembership[] — the live facades |
| serverHandler | sendToServer(input?, options?) — client side | Promise<reply> |
| serverHandler | useSocketMutation / useSocketQuery / useSocketInfiniteQuery | the flavor's TanStack surface (mutation is the default) |
| serverHandler | the 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) |
| serverHandler | the mutation flavor's family — fetchSocketMutation / getSocketMutationKey / getSocketMutationOptions / getSocketMutationCache(s) | mirrors the regular mutation surface |
| clientHandler | sendToClient(message, target?, replies?) — server side (the $-dictionary target) | follows replies: nothing / async iterable / array / void |
| clientHandler | useOnMessageFromServer(cb, options?) / onMessageFromServer(cb) — client side | hook: data with lastMessageFromServerAsData / imperative: { remove() } |
| clientHandler | iterateMessagesFromServer(options?) — the pushes as an async iterable | AsyncGenerator<message> — parks through a drop, ends when the target closes, throws its typed error |
| either handler | handler(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.