WorldForge Studio

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_role for players — they use the read model + SSE).
  • Node < 22 has no global WebSocket — pass webSocketImpl (require('ws').WebSocket; in ESM import { 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 call destroyRealmProvider; 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.