Watch
Stream a tenant's events live over Server-Sent Events with handleWatch, resume from any cursor with Last-Event-ID, filter by type, and authorize every stream.
handleWatch streams one tenant's events as Server-Sent Events,
read straight from the . A client opens one long GET, receives each committed event
as it becomes visible, and, when the connection drops, reconnects with the id of the last event it saw and continues
exactly after it: no gap, no duplicate from the server's side.
It is the right tool for anything that follows events while it runs: the console's live stores, a dashboard, a script that waits for a reservation to become active. For a service that must see every event even while it is down, use webhooks, which keep the position on the server.
Mount the endpoint
handleWatch(request, options) is a Fetch-style handler from better-compute/events. The compute server does not
mount it: the host that serves the API does, next to it, and supplies the authorization. That keeps the long-lived
stream out of the JSON API's envelope and lets each host choose how streams are authenticated.
import { defineHost, watchAuthorizer } from 'better-compute/cli';
import { createWatchRegistry, handleWatch } from 'better-compute/events';
export const host = defineHost(({ compute }) => {
const authorize = watchAuthorizer(compute); // decided exactly like events.list
const streams = createWatchRegistry(); // so IAM revocations close open streams at once
return {
iam: { bridge, streams: [streams] }, // bridge: your createComputeIamBridge()
http: {
routes: [
{
path: '/watch',
method: 'GET',
handler: (request: Request) =>
handleWatch(request, { sql: compute.context.sql, authorize, registry: streams }),
},
],
},
};
});better-compute serve answers the host export's routes beside the API, so this serves GET /watch on the API's
listener. watchAuthorizer(compute, { cookie? }) decides the stream through the server's own envelope: it calls
events.list for one event as the caller (authentication, the tenant pre-check, one compute:events:read decision
on compute-tenant/{t}, and the caches), with ?tenantId= defaulting to the caller's tenant. It returns the caller's
subject and, when the IAM port has authorizeSession, a re-authorizer, so the stream can be closed or re-checked
later. Without cookie: true it accepts only the Authorization header.
Prop
Type
Authorization
authorize is the whole security boundary of the stream, so decide it the way the API decides events.list:
compute:events:read on compute-tenant/{tenantId}. The stream then carries only rows whose tenantId is the one
authorize returned; the filter is part of the outbox query, not applied after it.
- Authenticate with the same credentials the API accepts (a bearer token, a session JWT, or on the same origin the
IAM cookie) by passing the request's headers to
compute.context.iam.authenticate. - Default to the caller's own tenant. Accepting
?tenantId=lets a platform administrator follow another tenant, and therequirecall decides whether they may. - When the stream accepts a cookie, add a CSRF guard like the Next.js tab above.
GETrequests carry no custom header by default, and browsers omitOriginon same-originGETs, so the console requiresX-Better-Compute: 1, which a cross-origin page cannot send without a preflight nothing approves.
Revocation
A stream is authorized when it opens, so the grant says how to end it early:
reauthorizeruns everyreauthorizeMs(60 s).falsecloses the stream; a throw counts as IAM unreachable, and afteriamOutageMs(2 minutes) of that the stream closes.registrylets a revocation close matching streams at once:closeWhere({ agentId | delegationId | sessionId | identityId | tenantId + anyAgent }, reason). A tenant alone is refused, since it would close too much. Withhost.iam: { bridge }and the registry inhost.streams(orhost.iam.streams),serveinstalls handlers that close an agent's or delegation's streams ondelegation:revokeandagent:suspend, every agent's in the tenant onagent:suspend-all, and an identity's on its offboarding or session revocation (the client reconnects withLast-Event-IDand is decided again).dev --simpasseshost.streamsto its embedded IAM's handlers too, so the examples'/watchstreams close on revocation in development as well.
A closed stream ends with event: error carrying { "code": "ACCESS_DENIED", "message": <reason> }, and events read during the
revocation are not sent. tests/e2e/access.test.ts checks that a revoked delegation and a member's sign-out close
/watch within 5 seconds.
A refusal, or any error before the stream starts, is a JSON error with the code's status, like the API's:
{ "error": { "code": "ACCESS_DENIED", "message": "…" } } with 403, INVALID_INPUT with 400 for a bad cursor or
type list, and METHOD_NOT_ALLOWED with 405 for anything but GET.
The stream
GET /watch?types=reservation.*,node.connected
Accept: text/event-stream
Authorization: Bearer biam_…
Last-Event-ID: 88213.51119
retry: 2000
id: 88213.51120
event: reservation.active
data: {"id":"evt_01k5y3v0m6y8x7w5t4s3r2p1n0","type":"reservation.active","tenantId":"acme","subject":"reservation/rsv_01k5y3a0b1c2d3e4f5g6h7j8k9","time":"2026-09-24T10:15:02.114Z","sequence":"88213.51120","data":{"state":"active","poolId":"pool_01k5y1z2x3w4v5t6s7r8q9p0n1"}}
: keep-alive 88213.51187The response is text/event-stream with Cache-Control: no-cache, no-transform and X-Accel-Buffering: no, so
nginx-style proxies do not buffer it. It opens with retry:, then sends one block per event:
| Field | Value |
|---|---|
id | the event's outbox position, txid.seq, equal to its sequence |
event | the event type, so EventSource.addEventListener('reservation.active', …) works |
data | the whole event as one line of JSON, the event shape |
A comment line : keep-alive <cursor> goes out every heartbeatMs. If reading the outbox fails mid-stream, the
server sends event: error with data: {"message": …} and closes; the client reconnects and resumes. After
maxDurationMs the server closes the stream on purpose, which also makes clients re-authenticate at least once an
hour.
Cursors and resume
The stream's cursor is the outbox position <txid>.<seq>, with a dot. It is every event's sequence and every
SSE id:.
| Request | Starts after |
|---|---|
| no cursor | the current tail: only events committed from now on; history is not replayed |
Last-Event-ID: 88213.51119 header | that event |
?lastEventId=88213.51119 | that event (for clients that cannot set the header, such as a first EventSource connection) |
?lastEventId=0.0 | the start of the outbox: everything retention has kept |
The header wins when both are sent. A cursor that is not two groups of 1 to 20 digits joined by a dot is
INVALID_INPUT. Note that events.list writes the same position with a colon (88213:51119); replace the colon
with a dot to move from history to the live stream.
The outbox is read with the xmin rule: only transactions older than the snapshot's xmin, in (txid, seq) order. No
event can therefore commit behind a cursor the stream has already handed out, which is what makes resuming exact.
The cost is that an event becomes visible only when every older transaction has finished; see
commit order. A cursor older than what
retention has pruned continues with the oldest row still there, without an error.
To backfill and then follow without a gap, page history with events.list, then open the stream with the last
item's sequence, colon replaced by a dot, as Last-Event-ID.
Filters
?types= takes 1 to 20 comma-separated patterns of up to 128 characters, with the same matching as webhooks: *
(the default), a family such as reservation.* or node.gpu.*, or an exact type. An empty list is
INVALID_INPUT. Unlike webhooks, patterns are not checked against the event catalog, so a typo matches nothing:
take family names from the event reference.
Filtering happens after the tenant filter, and the cursor still advances past events that do not match, so a filtered stream resumes correctly too.
Consume it
The typed client: $watch. client.$watch({ types?, tenantId?, lastEventId?, signal?, path?, maxBackoffMs? }) from
@better-compute/client is an async iterable over the stream. It sends the client's token and headers, yields each
event with its cursor as sequence, reconnects with Last-Event-ID after the server's retry: delay (or with
back-off, up to 30 seconds by default, after failures), stops on break or its signal, and throws
UNAUTHENTICATED on a 401 and ACCESS_DENIED on a 403. It reads {basePath}/watch by default
(/api/compute/watch); pass path: '/watch' for a stream mounted at the root, as in the serve tab above.
tenantId (e2fe6f8) sends ?tenantId=, which the host's watchAuthorizer decides; without it the stream is the
caller's own tenant.
for await (const event of client.$watch({ types: ['reservation.*'], lastEventId: savedCursor, path: '/watch' })) {
handle(event);
savedCursor = event.sequence;
}See the TypeScript client for its options.
The CLI: events watch. better-compute events watch --url … [--tenant T] [--types …] [--since CURSOR] [--count N] follows the same stream with $watch and prints one terminal-safe JSON line per event; its --path
defaults to /watch (the console app serves /api/compute/watch). --since takes the stream's own <txid>.<seq>
cursor, the sequence of a printed line (--since 184467.12, 86706b4), and also the colon form of events.list
pages (184467:12), the same outbox position, which it sends as the dotted one (867c837); events list --cursor
takes only the colon form. See follow events live.
Browsers and Node.js: console-kit. watchEvents from @better-compute/console-kit reads the stream over
fetch with callbacks instead of a loop, and can also send an Authorization header, which EventSource cannot. It keeps the last id, reconnects
after the server's retry: delay when the server closes the stream, backs off (doubling, up to 60 seconds) after
failures, and sends Last-Event-ID on every reconnect.
import { watchEvents } from '@better-compute/console-kit';
const watcher = watchEvents({
url: 'https://compute.example.com/watch',
types: ['reservation.*'],
lastEventId: savedCursor, // optional: resume across restarts
headers: async () => ({ authorization: `Bearer ${await getToken()}` }), // fresh per connection
onEvent: (event) => {
handle(event);
savedCursor = event.sequence as string;
},
onStatus: (status, detail) => console.log(status, detail?.retryInMs),
});startLiveConsole(client, { tenantId, watchUrl, headers, permissions }) goes further: it loads the tenant's
stores and routes every event to them. See console.
The console uses watchEvents against its own /api/compute/watch with the IAM cookie and
X-Better-Compute: 1, shows a "live" badge while the stream is open, and falls back to refreshing loaded stores
every 20 seconds while it is not.
EventSource works when the stream accepts a cookie and needs no custom header, or when you put the credential
somewhere your authorize reads other than a header. It reconnects with Last-Event-ID by itself.
curl is enough to look at a stream:
curl -N https://compute.example.com/watch?types=node.* \
-H "Authorization: Bearer $BETTER_COMPUTE_TOKEN" -H "Accept: text/event-stream"Delivery guarantees
- At least once across reconnects. The server never skips or repeats an event after the cursor it was given.
A client that crashes between handling an event and saving its cursor sees it again, so deduplicate on
id. - In commit order, within one stream.
- One tenant per stream. A platform view of several tenants opens several streams, or uses an event sink.
- No back-pressure on the database. Each open stream polls the outbox every
pollMswhile idle and reads in batches ofbatchwhile behind. Many idle streams mean many small queries; put the watch route on processes sized for it.
Better Compute is created by Sean Filimon
Last updated
Webhooks
Subscribe a tenant's HTTPS endpoint to event types, verify the HMAC signature on each delivery, and handle retries, dead letters, redelivery and secret rotation.
Audit
Every allowed mutation lands in audit_events with its admission context, sealed hourly by signed per-tenant RFC 6962 Merkle checkpoints you can verify and prove.