04 — Realtime
The live view over SSE and the collaborative Yjs document over the realm WebSocket.
Both live behind the /realtime subpath so a REST-only consumer never
loads them:
import { openWorldStream, watchWorld, openRealmProvider, readRoot, rootMap } from '@blueforge-studio/worldforge-sdk/realtime';
Unlike the REST client, these functions take auth: AuthOption ({ apiKey },
{ jwt } or { getToken }) plus baseUrl in one options object, and an
optional injectable fetch. yjs and y-websocket are regular dependencies of the
SDK; import yjs yourself to create the Y.Doc.
SSE live view
GET /worlds/:id/stream pushes edge-triggered events: presence,
region reveals, proposal updates (owner/GM only), and clock/weather/music
trigger events. The browser's EventSource can't set an
Authorization header, so the SDK uses fetch + its own parser — API
keys work.
// Async-iterator style:
const stream = openWorldStream({ baseUrl, worldId, auth: { apiKey } });
for await (const ev of stream) {
if (ev.kind === 'proposal') await refreshInbox();
}
// Or callback style (returns close() immediately):
const stop = watchWorld({ baseUrl, worldId, auth: { apiKey } }, {
presence: (data) => setViewers(data),
proposal: (data) => refreshInbox(data),
'region_revealed': (data) => refreshRegions(data),
}, console.error);
// later: stop()
ev.kind is an open string — new kinds ship additively; KnownStreamKind
exists for narrowing, not validation. A WorldStreamEvent is { kind, data, id? }: data is the parsed JSON payload, or the raw string when the
payload was not JSON. KnownStreamKind is presence, presence_updated,
region_revealed, proposal, keepalive, weather:update,
clock:update and music:play; per the SDK's source comments the server
also sends dialogue:* and encounter_spawned, which are not in that
union (not re-verified against the server here). keepalive fires every
15 s (it arrives as an ordinary event, so a watchWorld handler keyed
keepalive sees it).
WorldStreamOptions is { baseUrl, worldId, auth, fetch?, signal? }.
openWorldStream returns an async generator with an extra close();
aborting signal closes it too. The connection opens lazily, on the first
for await pull: a non-2xx response throws WorldForgeApiError from that
loop (not from openWorldStream). watchWorld(opts, handlers, onError?)
starts reading immediately, calls handlers[ev.kind] with ev.data (kinds
without a handler are dropped), reports real errors to onError (an abort
is a normal end and is not reported) and returns stop().
createSseFrameParser() is the exported chunk-boundary-safe frame parser
both are built on — useful for tests.
No auto-reconnect. The stream is not a replay log: after a drop,
re-open and re-read GET /worlds/:id to catch up. API keys can
subscribe; nothing here needs a user session.
Realm WebSocket (co-editing)
The shared document syncs over ws(s)://<origin>/realm/:worldId?ticket=…
with a single-use, 60 s ticket minted by POST /realm/tickets — the
long-lived JWT never enters a URL.
import * as Y from 'yjs';
import { openRealmProvider, destroyRealmProvider, rootMap, readRoot } from '@blueforge-studio/worldforge-sdk/realtime';
const doc = new Y.Doc();
const provider = await openRealmProvider({ baseUrl, worldId, doc, auth: { jwt } });
// …edit doc…
destroyRealmProvider(provider);
openRealmProvider first mints a ticket (a refusal throws
WorldForgeApiError here, e.g. 403 realm_requires_user_session), then
returns a y-websocket WebsocketProvider connected to
<baseUrl as ws(s)>/realm/<worldId>?ticket=…. OpenRealmOptions is
{ baseUrl, worldId, doc, auth, fetch?, webSocketImpl?, reconnect? };
reconnect: false turns the fresh-ticket reconnect loop off (for tests or
manual control; the provider then reconnects with y-websocket's own
behaviour and the already-used ticket). Use
destroyRealmProvider rather than provider.destroy() — it also stops the
reconnect loop from resurrecting the provider.
Lower-level pieces, exported for custom wiring and tests:
fetchRealmTicket({ baseUrl, worldId, auth, fetch? }) →
RealmTicket ({ ticket, expiresAt }), and
installFreshTicketReconnect(provider, opts) over the minimal
RealmProviderLike shape.
Hard limits, by design:
- User JWT only — an API key gets
403 realm_requires_user_session. Owner or GM roles only (403 read_only_rolefor players — they use the read model + SSE). - Node < 22 has no global
WebSocket— passwebSocketImpl(require('ws').WebSocket; in ESMimport { WebSocket } from 'ws'). Browsers and Node ≥ 22 need nothing. - Reconnects fetch a fresh ticket automatically (the old one was
burned by the upgrade); backoff doubles from 100 ms up to the provider's
maxBackoffTime. As written, any failure to fetch the new ticket — including a refusal such as a revoked session — is retried with that backoff until you calldestroyRealmProvider; the code does not distinguish a refusal from a network error (the source comments say a refusal stops it, but the implementation retries). Local editing carries on in the meantime.
The hydrated-doc gotcha (readRoot)
After sync, root values arrive as live Y.Map / Y.Array instances,
not plain JSON. Reading them directly gives you a proxy that looks
wrong and serializes wrong:
const root = rootMap(doc); // doc.getMap('root')
const world = readRoot<WorldDocument>(root, 'world'); // unwraps Y types → plain JSON; undefined if the key is absent
const raw = root.get('world'); // may be a Y.Map — don't
Always read through readRoot.