With love for developers of all backgrounds around the world ❤️
Point0 emits a lifecycle event for every query, mutation, and fetch it runs.
Subscribe to them on any point with .on / .serverOn / .clientOn to log,
report errors, or collect metrics — without touching the loaders themselves. The
common case is one error subscriber on the root:
export const root = Point0.lets
.root()
.on('error', ({ side, name, error, meta }) => {
// 'error' is sugar for the four error events; meta is a log-friendly projection
console.error({ side, name, error, ...meta })
})
.root()Every failed query or mutation now reaches your handler, with side, the event
name, the typed error, and a slim meta you can spread straight in.
Three methods, all on every point type, all chainable (they return the same point):
export const root = Point0.lets
.root()
.on('pointQuerySuccess', (e) => {
/* kept in both bundles */
})
.serverOn('engineFetchError', (e) => {
/* cut from the client bundle */
})
.clientOn('pointMutationSuccess', (e) => {
/* cut from the server bundle */
})
.root().on — not cut from either bundle: kept in both (isomorphic), so it fires
on both sides..serverOn — cut from the client bundle: its body and the imports it uses
are removed, so the handler — server secrets included — never ships to the
browser. It fires only for server-side events..clientOn — cut from the server bundle: body and its imports removed. It
fires only for client-side events.The side is decided when the event is emitted, from where the emitting code
runs — not from the event name. A .serverOn callback never sees a client-side
event, and vice versa.
Subscriptions accumulate: each call adds to the list, and a point inherits every subscription from its parents up the chain. Put app-wide logging on the root and it covers everything beneath.
export const root = Point0.lets
.root()
.on('pointQueryStart', (e) => {
/* one event */
})
.on(['pointQuerySuccess', 'pointMutationSuccess'], (e) => {
/* several */
})
.on('*', (e) => {
/* every event */
})
.root()With '*' the callback receives the full event union; with a single name it's
narrowed to that event. The wildcard still respects the side filter —
.serverOn('*') fires only for server-side events.
Most families come in four lifecycle phases — Start, Settled, Success,
Error. The subscription family streams, so its four are Start, Data,
Settled, Error — a value per Data, the outcome on Settled. The table
below lists the families; the Reference enumerates every name:
| Family | What it tracks | Side |
|---|---|---|
pointQuery* | a query running (useQuery / fetchQuery) | client | server |
pointInfiniteQuery* | an infinite query running | client | server |
pointMutation* | a mutation running | client | server |
pointSubscriptionServer* / Client* | a subscription stream: the loader run / a fetch attempt | per side |
pointChannelConnectServer* / Client* | a channel connect: the connector run / the connect, settled by its claim | per side |
pointChannelOpenServer / pointChannelCloseServer | a connection went live / away (singles) | server only |
pointChannelClaimServerError | a connection failed to claim its place on the socket (single) | server only |
pointSpaceJoinServer* / Client* | a space join: the .joiner run / the join frame | per side |
pointSpaceLeaveServer | a membership left its rooms (single) | server only |
pointHandlerServer* / Client* | a message: .serverReply / a clientHandler dispatch | per side |
pointHandlerServerLateError | a .serverReply that threw after its early reply (single) | server only |
pointHandlerSendClient* / SendServer* | TRANSMITTING a message: sendToServer / sendToClient | per side |
socketServer* / socketClient* | the socket itself: upgrades, opens, drops, refusals (singles) | per side |
pointFetchServer* | a point's server-fetch step (the SSR / fetch machinery) | client | server |
pointPrefetchPage* | a page being prefetched before navigation | client | server |
engineFetch* | the engine's outgoing HTTP fetch (the actual request) | server only |
emitError | a subscriber callback itself threw (see below) | client | server |
Each family gives you <Family>Start, <Family>Settled, <Family>Success, and
<Family>Error — except the subscription families, whose set is Start,
Data, Settled (with outcome: 'completed' | 'failed' | 'broken'), and
Error. Three families add a fifth phase, Cancelled — pointQuery,
pointInfiniteQuery, and pointFetchServer: a run whose AbortSignal fired
(you navigated away, the component unmounted, a refetch superseded it) is a
settled non-error outcome, emitted instead of Error and deliberately kept
out of the 'error' shorthand, so cancellations never reach your reporter.
Some operations are reported twice, from different heights, and the pair is not a duplicate:
pointQuery* / pointMutation* describe the loader running;
engineFetch* describes the outgoing HTTP request underneath it.pointHandlerServer* / pointHandlerClient* describe the
receiving side RUNNING the message (a .serverReply, a clientHandler
dispatch); pointHandlerSendClient* / pointHandlerSendServer* describe the
sending side TRANSMITTING it. A send that never left — no live connection, a
dead socket, a timeout — produces no execution event anywhere, and only the
transport family reports it. Below both sits the socket itself: a message the
engine refused before any point ran (socketServerSendRefused), a connection
that never claimed its place (pointChannelClaimServerError), a transport
that never came up (socketClientError).What "success" means on the transport families is per side:
pointHandlerSendClientSuccess fires when the server's reply resolves the send
(that is what sendToServer() awaits), while pointHandlerSendServerSuccess
fires when the engine accepted the push for delivery — a push is
fire-and-forget, so it means handed to the transport, never delivered. A
push addressed to a room nobody is in is a successful send.
For any one run:
Start fires before the work begins.Settled fires on every outcome — success or error.Success fires on a successful result.Error fires only on a genuine error.// a query that succeeds: pointQueryStart → pointQuerySettled → pointQuerySuccess
// a query that throws: pointQueryStart → pointQuerySettled → pointQueryErrorOne edge case: a redirect is a success, not an error. When a loader
redirects (throw redirect(...)), the query settles down the success path —
Settled then Success fire, not Error. See Navigation for
redirects.
engineFetch* is server-onlyengineFetch* wraps the actual outgoing HTTP request, which only the server
makes — so those events are typed 'server' and are only reachable through
.on and .serverOn. Naming engineFetch* inside .clientOn is a type
error: it isn't in the client event set.
The other point* events report side: 'client' even during SSR, because the
query/fetch code that emits them is client-authored (it runs on the server under
SSR, but it's the same code). Only engineFetch* — the HTTP layer — reports
side: 'server'. During one SSR page load you'll see both:
export const root = Point0.lets
.root()
.on('*', (e) => order.push([e.name, e.side]))
.root()
// pointQueryStart client
// pointFetchServerStart client
// engineFetchStart server ← the actual HTTP request
// engineFetchSettled server
// engineFetchSuccess server
// pointFetchServerSettled client
// pointFetchServerSuccess client
// pointQuerySettled client
// pointQuerySuccess client'error' shorthand.on('error', cb) is sugar — it expands to nineteen subscriptions, one per
error event:
export const root = Point0.lets
.root()
.on('error', (e) => {
/* … */
})
.root()Spelled out, that one call is:
export const root = Point0.lets
.root()
.on(
[
'pointMutationError',
'pointQueryError',
'pointInfiniteQueryError',
'pointChannelConnectServerError',
'pointChannelConnectClientError',
'pointChannelClaimServerError',
'pointHandlerServerError',
'pointHandlerServerLateError',
'pointHandlerClientError',
'pointHandlerSendClientError',
'pointHandlerSendServerError',
'pointSpaceJoinServerError',
'pointSpaceJoinClientError',
'pointSubscriptionServerError',
'pointSubscriptionClientError',
'socketServerSendRefused',
'socketClientError',
'engineFetchError',
'rscError',
],
(e) => {
/* … */
},
)
.root()Inside the callback, error is narrowed to a non-undefined error instance.
One user throw can produce more than one of them: a failing server query
surfaces as engineFetchError (server) and pointQueryError (client), so
an .on('error') logger may see the same failure from two angles.
GOTCHA: the shorthand covers query, infinite-query, mutation, every socket error (channel connect, space join, the handler families, both transport families, the late-reply single, and the three refusal singles — a claim, a refused send, a transport that never came up), engineFetch, and rscError (a failed
defer()subtree) — notpointFetchServerErrororpointPrefetchPageError, and never aCancelledevent. To catch those two, name them explicitly:.on(['pointFetchServerError', 'pointPrefetchPageError'], cb).
Because pointHandlerSendClientError is in there, a fire-and-forget send —
void handler.sendToServer(...), where nobody awaits the promise — is still
reported: the root .on('error') subscriber sees the failure the caller chose
not to await.
Every callback receives one object with the same five fields:
export const root = Point0.lets
.root()
.on('pointQueryError', ({ side, name, data, error, meta }) => {
side // => 'client' | 'server' — where the emitting code ran
name // => 'pointQueryError'
data // the raw payload — rich, but heavy to log
error // the typed error instance (your error class — ErrorPoint0 by default;
// undefined on non-error events)
meta // a slim, log-friendly projection of data
})
.root()side — 'client' or 'server', set at emit time (see
side above).name — the event name.data — the full payload (the query result, the request object, the
QueryClient, …). Prefer meta for logging.error — the error instance, hoisted to the top level so an 'error'
handler can read it directly — the same object as data.error. Typed
(your error class) on error events, undefined on every
other event (the key is always present).meta — the log-friendly projection, below.meta: the log-friendly projectionmeta is a plain record (Record<string, unknown>) built per event from
data, meant to go straight into a logger:
export const root = Point0.lets
.root()
.on('pointQueryStart', ({ meta }) => {
// the point's id (<scope>:<type>:<name>), not the object
meta.point // => 'root:page:home'
meta.input // => { id: 123 } — the input, sanitized (see below)
})
.root()data carries heavy objects (responses, requests, query results) that you don't
want in a log line; meta replaces them with compact forms: points become their
string id (<scope>:<type>:<name>), requests become { method, path }, errors
and redirects are serialized, and it drops bulky members. For an engineFetch
event, meta.result and meta.response are dropped; for the SSR case, a
settled event's meta.request.renders reports how many SSR render passes ran.
meta does not carry the error — on an error event, meta.error is
undefined. The error lives on the envelope error (and data.error). So a
typical error log spreads meta and adds the parts it needs:
export const root = Point0.lets
.root()
.on('error', ({ side, name, error, meta }) => {
console.error({ ...meta, side, name, error })
})
.root()meta.input runs through a sanitizer that replaces binary values with
placeholder strings, so file uploads never bloat or break a log line:
// a mutation input of { photo: File, note: 'hi' } logs as:
meta.input // => { photo: '[File: photo.png (5120 bytes)]', note: 'hi' }File → [File: <name> (<size> bytes)], Blob → [Blob: <size> bytes],
FormData → [FormData]. Nested binaries (inside arrays/objects) are replaced
too; everything else passes through unchanged.
emitErrorCallbacks are fire-and-forget: they may be sync or async, and Point0 does not await them. A slow handler never blocks a request, and the completion order of async handlers across subscriptions isn't guaranteed.
If a callback throws, the framework does not crash. The error is caught and
re-emitted as an emitError event, carrying the original event and the thrown
error — so you can observe your own handler failures:
export const root = Point0.lets
.root()
.on('emitError', ({ error, data, meta }) => {
// error — what your handler threw (coerced to the error class)
// data.event — the full original event that was being handled
// meta.event — its slim { name, meta } projection
console.error('event handler failed', data.event.name, error)
})
.root()A throw inside an emitError handler is swallowed silently — there's no
recursion, so a broken error reporter can't cause an emit loop.
Funnel events through one subscriber on the root and let your logging stack fan
out — the .on('error') subscriber at the top of this page is the whole
pattern. Swap console.error for your own logger and you get error reporting
with no extra call sites at the points. In Start0, for example, that subscriber
writes to a LogTape logger whose sink forwards error records to Sentry — the
same single root subscription, just a richer sink behind it.
Subscribe to the success/settled events the same way for request metrics or audit logs.
pointQueryStart pointQuerySettled pointQuerySuccess pointQueryError pointQueryCancelled
pointInfiniteQueryStart pointInfiniteQuerySettled pointInfiniteQuerySuccess pointInfiniteQueryError pointInfiniteQueryCancelled
pointMutationStart pointMutationSettled pointMutationSuccess pointMutationError
pointSubscriptionServerStart pointSubscriptionServerData pointSubscriptionServerSettled pointSubscriptionServerError
pointSubscriptionClientStart pointSubscriptionClientData pointSubscriptionClientSettled pointSubscriptionClientError
pointChannelConnectServerStart pointChannelConnectServerSettled pointChannelConnectServerSuccess pointChannelConnectServerError
pointChannelConnectClientStart pointChannelConnectClientSettled pointChannelConnectClientSuccess pointChannelConnectClientError
pointSpaceJoinServerStart pointSpaceJoinServerSettled pointSpaceJoinServerSuccess pointSpaceJoinServerError
pointSpaceJoinClientStart pointSpaceJoinClientSettled pointSpaceJoinClientSuccess pointSpaceJoinClientError
pointHandlerServerStart pointHandlerServerSettled pointHandlerServerSuccess pointHandlerServerError
pointHandlerClientStart pointHandlerClientSettled pointHandlerClientSuccess pointHandlerClientError
pointHandlerSendClientStart pointHandlerSendClientSettled pointHandlerSendClientSuccess pointHandlerSendClientError
pointHandlerSendServerStart pointHandlerSendServerSettled pointHandlerSendServerSuccess pointHandlerSendServerError
pointFetchServerStart pointFetchServerSettled pointFetchServerSuccess pointFetchServerError pointFetchServerCancelled
pointPrefetchPageStart pointPrefetchPageSettled pointPrefetchPageSuccess pointPrefetchPageError
engineFetchStart engineFetchSettled engineFetchSuccess engineFetchError
pointChannelOpenServer pointChannelCloseServer pointSpaceLeaveServer pointHandlerServerLateError
pointChannelClaimServerError
socketServerUpgrade socketServerConnect socketServerDisconnect socketServerSendRefused
socketClientConnect socketClientDisconnect socketClientError
rscError emitErrorThe socket families (pointChannelConnect*, pointSpaceJoin*, pointHandler*,
pointHandlerSend*), the server-only singles (pointChannelOpenServer /
pointChannelCloseServer / pointSpaceLeaveServer /
pointHandlerServerLateError / pointChannelClaimServerError), and the
socket-level socket* singles are split by side — the two sides are genuinely
different operations with different data, not one event observed twice. When
each fires, the counters and entry markers the client families carry, and the
open / close / leave reasons: Socket → The events.
The subscription families fire per stream ATTEMPT on the client (Start's
attempt counts reconnects — there is no separate reconnect event) and per
streamed response on the server: Data on every streamed value, Settled with
the outcome (completed / failed / broken), nothing after a deliberate
cancel. Details: Subscription.
| Method | Fires on | Name argument accepts |
|---|---|---|
.on | both sides | any event name, 'error', '*', or an array of names |
.serverOn | server-side events | server event names, 'error', '*', array |
.clientOn | client-side events | client event names, 'error', '*', array |
All three are on every point type, accumulate, and are inherited down the chain.
engineFetch* names are valid in .on / .serverOn only — a type error in
.clientOn.
| Field | Type | Notes |
|---|---|---|
side | 'client' | 'server' | where the emitting code ran, set at emit time |
name | the event name | |
data | the raw payload (per event) | rich; not log-friendly |
error | error instance, or undefined | present on error events; same object as data.error |
meta | Record<string, unknown> | log-friendly projection of data |
data| Family | data carries |
|---|---|
pointQuery* | { queryKey, point, input, mode, data?, error?, redirect? } |
pointInfiniteQuery* | same as query, for the infinite case |
pointMutation* | { point, input } + one of output / error / redirect |
pointSubscriptionServer* / Client* | { point, input } + value (Data) / outcome; attempt on ClientStart |
pointChannelConnectServer* / Client* | { point, input } + connectionId / error; identity on Server; connectionIndex (every phase) + resumed/gapless (success) on Client, which settles at the CLAIM — its Error covers the claim refusals too |
pointChannelOpenServer | { point, connectionId, identity, resumed } — server-only |
pointChannelCloseServer | { point, connectionId, identity, reason } — server-only |
pointChannelClaimServerError | { scope, point, connectionId, reason, error } — server-only; point/connectionId are undefined when the refusal came before the ticket resolved |
pointSpaceJoinServer* / Client* | { point, input, connectionId } + rooms / error; identity + resumed on Server; membershipIndex (every phase) + resumed/gapless (success) on Client |
pointSpaceLeaveServer | { point, connectionId, identity, rooms, reason } — server-only |
pointHandlerServer* / Client* | { point, input, connectionId } + output / error; identity on Server |
pointHandlerServerLateError | { point, input, connectionId, identity, error } — server-only |
pointHandlerSendClient* | { point, input, connectionId } + output / error; connectionId is undefined on Start and on a failure with no connection |
pointHandlerSendServer* | { point, input } + error — server-only; no connectionId (a push can address many connections at once) |
socketServer* / socketClient* | { scope } — socket-level, no point; + socketIndex on socketClientConnect |
socketServerSendRefused | { scope, reason, handlerName, connectionId, error } — server-only; a send the engine refused before any point ran |
socketClientError | { scope, socketIndex, reason, error } — client-only; reason is 'open' (never came up) or 'exhausted' (the reconnect gave up) |
pointFetchServer* | { input, point } + the fetch-server output on settled/success/error |
pointPrefetchPage* | { point, input, options, error? } |
engineFetch* | { request, scope, result?, error? } |
rscError | { error, label, holeId } — a failed defer() subtree, server-side |
emitError | { error, event } — the original event and the thrown error |
The event types are exported from @point0/core: AnyEventerEvent,
EventerEvent, EventerEventMeta, EventerSide, and the per-event types. The
barrel is type-only (export type * from './eventer.js'), so the runtime
uniqEventerErrorEventNames constant — the nineteen names the 'error'
shorthand expands to — is not importable as a value.