@rotorsoft/act-http 1.11.3 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { type Patch } from "@rotorsoft/act-patch";
1
2
  import { StateCache } from "./state-cache.js";
2
3
  import { type BroadcastState, type PatchMessage, type Subscriber } from "./types.js";
3
4
  /**
@@ -96,9 +97,10 @@ export declare class BroadcastChannel<S extends BroadcastState = BroadcastState>
96
97
  *
97
98
  * @param streamId - The event store stream ID
98
99
  * @param state - Full state with `_v` set from `snap.event.version`
99
- * @param patches - Array of domain patches, one per emitted event
100
+ * @param patches - Array of domain patches, one per emitted event, as the
101
+ * snapshots carry them (`null` deletes a key)
100
102
  */
101
- publish(streamId: string, state: S, patches?: Partial<S>[]): PatchMessage<S>;
103
+ publish(streamId: string, state: S, patches?: Readonly<Patch<S>>[]): PatchMessage<S>;
102
104
  /**
103
105
  * Publish a state update that doesn't change the event version
104
106
  * (e.g. presence overlay, computed field refresh).
@@ -1 +1 @@
1
- {"version":3,"file":"broadcast.d.ts","sourceRoot":"","sources":["../../../src/sse/broadcast.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,YAAY,EAEjB,KAAK,UAAU,EAChB,MAAM,YAAY,CAAC;AA0GpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,qBAAa,gBAAgB,CAAC,CAAC,SAAS,cAAc,GAAG,cAAc;IACrE,OAAO,CAAC,QAAQ,CAAyC;IACzD,OAAO,CAAC,WAAW,CAAgB;IACnC,OAAO,CAAC,mBAAmB,CAA6C;IACxE,OAAO,CAAC,eAAe,CAA6B;IAEpD,YAAY,OAAO,CAAC,EAAE;QACpB,sEAAsE;QACtE,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB;;;;;;;;;;;;;;WAcG;QACH,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;QAC3C;;;;;WAKG;QACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;QAC/D;;;;WAIG;QACH,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,EAQA;IAED;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IAMxB;;;;OAIG;IACH,OAAO,CAAC,cAAc;IAKtB;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IAS3B;;;;;;;OAOG;IACH,OAAO,CACL,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,CAAC,EACR,OAAO,GAAE,OAAO,CAAC,CAAC,CAAC,EAAO,GACzB,YAAY,CAAC,CAAC,CAAC,CAkCjB;IAED;;;;OAIG;IACH,OAAO,CACL,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,OAAO,CAAC,CAAC,CAAC,GACxB,YAAY,CAAC,CAAC,CAAC,GAAG,SAAS,CA8B7B;IAED;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,CASzD;IAED,kDAAkD;IAClD,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAExC;IAED,8EAA8E;IAC9E,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAErC;IAED,4DAA4D;IAC5D,eAAe,CACb,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,OAAO,CAAC,CAAC,CAAC,GACxB,YAAY,CAAC,CAAC,CAAC,GAAG,SAAS,CAE7B;IAED,oEAAoE;IACpE,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE7C;IAED,0DAA0D;IAC1D,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAEzC;IAED,+EAA+E;IAC/E,IAAI,KAAK,IAAI,UAAU,CAAC,CAAC,CAAC,CAEzB;CACF"}
1
+ {"version":3,"file":"broadcast.d.ts","sourceRoot":"","sources":["../../../src/sse/broadcast.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,KAAK,EAAE,MAAM,sBAAsB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,YAAY,EAEjB,KAAK,UAAU,EAChB,MAAM,YAAY,CAAC;AA0GpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,qBAAa,gBAAgB,CAAC,CAAC,SAAS,cAAc,GAAG,cAAc;IACrE,OAAO,CAAC,QAAQ,CAAyC;IACzD,OAAO,CAAC,WAAW,CAAgB;IACnC,OAAO,CAAC,mBAAmB,CAA6C;IACxE,OAAO,CAAC,eAAe,CAA6B;IAEpD,YAAY,OAAO,CAAC,EAAE;QACpB,sEAAsE;QACtE,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB;;;;;;;;;;;;;;WAcG;QACH,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;QAC3C;;;;;WAKG;QACH,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;QAC/D;;;;WAIG;QACH,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,EAQA;IAED;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IAMxB;;;;OAIG;IACH,OAAO,CAAC,cAAc;IAKtB;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IAS3B;;;;;;;;OAQG;IACH,OAAO,CACL,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,CAAC,EACR,OAAO,GAAE,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAO,GACjC,YAAY,CAAC,CAAC,CAAC,CAkCjB;IAED;;;;OAIG;IACH,OAAO,CACL,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,OAAO,CAAC,CAAC,CAAC,GACxB,YAAY,CAAC,CAAC,CAAC,GAAG,SAAS,CA8B7B;IAED;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,CASzD;IAED,kDAAkD;IAClD,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAExC;IAED,8EAA8E;IAC9E,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAErC;IAED,4DAA4D;IAC5D,eAAe,CACb,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,OAAO,CAAC,CAAC,CAAC,GACxB,YAAY,CAAC,CAAC,CAAC,GAAG,SAAS,CAE7B;IAED,oEAAoE;IACpE,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE7C;IAED,0DAA0D;IAC1D,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAEzC;IAED,+EAA+E;IAC/E,IAAI,KAAK,IAAI,UAAU,CAAC,CAAC,CAAC,CAEzB;CACF"}
@@ -212,7 +212,8 @@ var BroadcastChannel = class {
212
212
  *
213
213
  * @param streamId - The event store stream ID
214
214
  * @param state - Full state with `_v` set from `snap.event.version`
215
- * @param patches - Array of domain patches, one per emitted event
215
+ * @param patches - Array of domain patches, one per emitted event, as the
216
+ * snapshots carry them (`null` deletes a key)
216
217
  */
217
218
  publish(streamId, state, patches = []) {
218
219
  const safe_state = cache_safe(state);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/types.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // A stream's first event is version 0, so a client with no baseline\n // resumes from -1; defaulting to 0 would drop that first patch as stale.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else: a resync frame carries no versions, and the empty-frame\n // branch below would report it as `stale`, the one answer that doesn't\n // refetch.\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n // An empty frame carries nothing to apply: `stale` for a client with a\n // baseline, `behind` for one without, which must fetch one.\n if (!versions.length)\n return { ok: false, reason: hasBaseline ? \"stale\" : \"behind\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled entirely here: the contiguous fold below would\n // apply its payload as that version's domain patch, and a client at\n // `cachedV + 1` would stamp itself caught up and never get the real one.\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { patch as apply_patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport {\n type BroadcastState,\n type PatchMessage,\n resync_frame,\n type Subscriber,\n} from \"./types.js\";\n\n/**\n * Deliver a frame to every subscriber, containing each one: a subscriber\n * that throws must not cost the others the frame or escape into the\n * publisher. One guard around the loop would let the first throw stop it.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite values that don't survive JSON, recursively: Sets become arrays,\n * and with `nulls`, `undefined` becomes `null`.\n *\n * `@rotorsoft/act-patch` deletes a key on either `undefined` or `null`, but\n * `JSON.stringify` drops `undefined` keys, so a cleared field would vanish\n * from the frame and a live client would keep the stale value. One walker\n * serves the frame and the cache, which differ only in that delete encoding\n * (see `wire_safe` / `cache_safe`).\n */\nconst normalize = <T>(value: T, nulls: boolean): T => {\n if (value === null || typeof value !== \"object\") return value;\n if (Array.isArray(value)) return value.map((v) => normalize(v, nulls)) as T;\n // `JSON.stringify` encodes a Set as `{}`; presence (`PresenceTracker.online()`)\n // is a Set, so it would reach clients empty.\n if (value instanceof Set)\n return [...value].map((v) => normalize(v, nulls)) as T;\n // Other non-plain values (Date, Map, class instances) are left to the\n // host's serializer: a Map has no single obvious JSON encoding to guess.\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) return value;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>))\n out[k] = v === undefined && nulls ? null : normalize(v, nulls);\n return out as T;\n};\n\n/**\n * Frame-bound normalization: Sets become arrays, and `undefined` becomes\n * `null` so a delete survives `JSON.stringify`.\n */\nconst wire_safe = <T>(patch: T): T => normalize(patch, true);\n\n/**\n * Cache-bound normalization: Sets become arrays, `undefined` is left alone,\n * because a reconnecting client reseeds from the cache and a cleared key\n * must stay absent there, not `null`.\n *\n * `Object.entries` skips the non-enumerable `OVERLAY_KEYS` marker, so\n * callers tag after normalizing.\n */\nconst cache_safe = <T>(state: T): T => normalize(state, false);\n\n/**\n * Keys an `overlay()` contributed to a stream's cached state, carried on the\n * cached object itself, so `publish()` can keep them when it replaces the\n * entry: a reconnecting client reseeds at the same `_v` and couldn't tell\n * the overlay data was missing.\n *\n * A symbol keeps it off the wire, and living on the entry means it is\n * evicted with it.\n */\nconst OVERLAY_KEYS = Symbol(\"act.sse.overlay_keys\");\n\ntype WithOverlayKeys = { [OVERLAY_KEYS]?: ReadonlySet<string> };\n\n/**\n * Default `onSubscriberError`. This module shares the `sse` subpath with\n * `applyPatchMessage`, which browser code imports, so it never imports the\n * framework: a bundler would follow that into a browser build. Pass\n * `onSubscriberError` to send these to your logger.\n */\nconst default_subscriber_error = (error: unknown, streamId: string): void => {\n // biome-ignore lint/suspicious/noConsole: the default can't reach the framework logger (see above)\n console.error(`sse subscriber threw for \"${streamId}\"`, error);\n};\n\n/** Record which keys an overlay owns, accumulating across overlays. */\nconst tag_overlay_keys = <S extends object>(\n state: S,\n prev: S | undefined,\n keys: readonly string[]\n): S => {\n const carried = (prev as WithOverlayKeys | undefined)?.[OVERLAY_KEYS];\n Object.defineProperty(state, OVERLAY_KEYS, {\n value: new Set([...(carried ?? []), ...keys]),\n enumerable: false,\n configurable: true,\n });\n return state;\n};\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n /** Max number of stream states kept in the LRU cache (default 50). */\n cacheSize?: number;\n /**\n * Called when overlay-contributed state is lost, on either of the two\n * paths that can lose it: `overlay()` finding no cached baseline, and\n * the LRU evicting an entry that carried overlay keys — the latter from\n * inside `publish()`, for a stream other than the one being published.\n *\n * Live subscribers of the affected stream are sent a resync frame, so\n * they refetch rather than keep showing state the server has forgotten.\n * The hook is observability on top of that recovery: a steady stream of\n * calls means `cacheSize` is too small for the working set.\n *\n * A throw is caught and routed to `onSubscriberError` — the resync has\n * already gone out, and a host bug must not cost the publishing stream\n * its own frame. Defaults to a no-op.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher. Defaults to `console.error`;\n * pass your logger here to route it with the rest of your logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50,\n (streamId, dropped) => this.on_cache_evict(streamId, dropped)\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ?? default_subscriber_error;\n }\n\n /**\n * Fan a resync out to a stream's live subscribers. `applyPatchMessage`\n * always reports `behind` for it, so they refetch.\n */\n private broadcast_resync(streamId: string): void {\n fan_out(this.channels.get(streamId), resync_frame<S>(), (error) =>\n this.on_subscriber_error(error, streamId)\n );\n }\n\n /**\n * The LRU dropped an entry. Overlay-contributed keys lived only in the\n * cache, so their loss is reported now, when it happens, rather than at a\n * later commit that may never come.\n */\n private on_cache_evict(streamId: string, dropped: S): void {\n if (!(dropped as S & WithOverlayKeys)[OVERLAY_KEYS]?.size) return;\n this.report_overlay_miss(streamId);\n }\n\n /**\n * Overlay-contributed state for `streamId` is gone. Recover first, report\n * second: the resync goes out before the host hook, and a throwing hook is\n * routed to `onSubscriberError`. Eviction runs inside `publish()` for\n * another stream, so an escaping throw would cost that stream its frame.\n */\n private report_overlay_miss(streamId: string): void {\n this.broadcast_resync(streamId);\n try {\n this.on_overlay_miss(streamId);\n } catch (error) {\n this.on_subscriber_error(error, streamId);\n }\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n // Normalize once, so the reseed and the live frame agree. Then carry the\n // keys an `overlay()` owns across the commit, unless the new domain state\n // sets them, so presence survives a commit and the publisher still wins.\n const safe_state = cache_safe(state);\n const prev = this.state_cache.get(streamId) as\n | (S & WithOverlayKeys)\n | undefined;\n const overlay_keys = prev?.[OVERLAY_KEYS];\n let cached = safe_state;\n if (overlay_keys?.size && prev) {\n const carried = { ...safe_state } as S;\n const kept: string[] = [];\n for (const key of overlay_keys)\n if (!(key in safe_state) && key in prev) {\n (carried as Record<string, unknown>)[key] = (\n prev as Record<string, unknown>\n )[key];\n kept.push(key);\n }\n cached = tag_overlay_keys(carried, undefined, kept);\n }\n this.state_cache.set(streamId, cached);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p);\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to patch, and a stream that only gets overlays never\n // refills the cache, so its live clients would miss every update. A\n // resync makes them refetch. `undefined`: no overlay was broadcast.\n this.report_overlay_miss(streamId);\n return undefined;\n }\n\n // Normalize before patching the cache, so the reseed and the live frame\n // agree; `apply_patch` deletes on `null` as on `undefined`.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(\n streamId,\n tag_overlay_keys(state, prev, Object.keys(safe_patch as object))\n );\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n private on_evict: (key: string, state: S) => void;\n\n /**\n * @param maxSize - Max entries before the LRU drops its oldest.\n * @param onEvict - Called with the entry the LRU just dropped. Eviction is\n * the moment cached-only data is lost, so it is the only place a caller\n * can still see what went.\n */\n constructor(\n maxSize = 50,\n onEvict: (key: string, state: S) => void = () => {}\n ) {\n this.maxSize = maxSize;\n this.on_evict = onEvict;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n const oldest = this.cache.keys().next().value!;\n const dropped = this.cache.get(oldest)!;\n this.cache.delete(oldest);\n this.on_evict(oldest, dropped);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","import type { DeepPartial } from \"@rotorsoft/act-patch\";\n\n/**\n * Base constraint for state objects managed by the broadcast system.\n * Apps extend this with their own domain state shape.\n */\nexport type BroadcastState = Record<string, unknown> & {\n /** Event store stream version — set by the broadcast layer from snap.event.version */\n _v: number;\n};\n\n/**\n * SSE message: version-keyed domain patches.\n * Keys are stringified version numbers, values are domain patches (deep partials).\n * Multi-event commits produce multiple version-keyed entries.\n *\n * The optional `_overlay` marker flags a version-neutral update (presence,\n * computed-field refresh) emitted by {@link BroadcastChannel.overlay} — a\n * single entry keyed at the *current* version. It tells `applyPatchMessage`\n * to merge the entry on top of the client's caught-up state instead of\n * rejecting it as stale (a same-version patch WITHOUT the marker stays\n * stale). Ordinary version-bumping patches from `publish()` omit it.\n *\n * `_resync` carries no versions at all. The server emits it when it cannot\n * construct a patch: when overlay-contributed state is lost, because\n * `overlay()` finds no cached baseline or the LRU evicts an entry that\n * carried overlay keys. A client that receives one always reports `behind`\n * and refetches, so live subscribers don't silently miss updates.\n */\nexport type PatchMessage<S extends BroadcastState = BroadcastState> = Record<\n number,\n DeepPartial<S>\n> & { readonly _overlay?: true; readonly _resync?: true };\n\n/**\n * Subscriber callback — receives version-keyed patch messages.\n */\nexport type Subscriber<S extends BroadcastState = BroadcastState> = (\n msg: PatchMessage<S>\n) => void;\n\n/**\n * The frame that tells a client to refetch: no versions, so\n * `applyPatchMessage` always reports `behind`.\n *\n * Every producer of a resync uses this — `overlay()` on a missing baseline,\n * the cache on evicting overlay state, and the SSE backlog when it cannot\n * drop a frame without losing it silently. One factory so the shape cannot\n * drift between them.\n */\nexport const resync_frame = <S extends BroadcastState>(): PatchMessage<S> =>\n ({ _resync: true }) as PatchMessage<S>;\n\n/**\n * Whether a frame carries no versions, so `applyPatchMessage` can never\n * classify it as a gap.\n *\n * A version-keyed patch that goes missing is self-announcing: the next frame\n * skips a version and the client refetches. An `_overlay` or `_resync` frame\n * is not, which is why anything that drops frames has to tell them apart.\n */\nexport const is_version_neutral = <S extends BroadcastState>(\n msg: PatchMessage<S>\n): boolean => msg._overlay === true || msg._resync === true;\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":"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","names":["import_act_patch","deep_merge","import_act_patch","patch","apply_patch"]}
1
+ {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/types.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // A stream's first event is version 0, so a client with no baseline\n // resumes from -1; defaulting to 0 would drop that first patch as stale.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else: a resync frame carries no versions, and the empty-frame\n // branch below would report it as `stale`, the one answer that doesn't\n // refetch.\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n // An empty frame carries nothing to apply: `stale` for a client with a\n // baseline, `behind` for one without, which must fetch one.\n if (!versions.length)\n return { ok: false, reason: hasBaseline ? \"stale\" : \"behind\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled entirely here: the contiguous fold below would\n // apply its payload as that version's domain patch, and a client at\n // `cachedV + 1` would stamp itself caught up and never get the real one.\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { patch as apply_patch, type Patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport {\n type BroadcastState,\n type PatchMessage,\n resync_frame,\n type Subscriber,\n} from \"./types.js\";\n\n/**\n * Deliver a frame to every subscriber, containing each one: a subscriber\n * that throws must not cost the others the frame or escape into the\n * publisher. One guard around the loop would let the first throw stop it.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite values that don't survive JSON, recursively: Sets become arrays,\n * and with `nulls`, `undefined` becomes `null`.\n *\n * `@rotorsoft/act-patch` deletes a key on either `undefined` or `null`, but\n * `JSON.stringify` drops `undefined` keys, so a cleared field would vanish\n * from the frame and a live client would keep the stale value. One walker\n * serves the frame and the cache, which differ only in that delete encoding\n * (see `wire_safe` / `cache_safe`).\n */\nconst normalize = <T>(value: T, nulls: boolean): T => {\n if (value === null || typeof value !== \"object\") return value;\n if (Array.isArray(value)) return value.map((v) => normalize(v, nulls)) as T;\n // `JSON.stringify` encodes a Set as `{}`; presence (`PresenceTracker.online()`)\n // is a Set, so it would reach clients empty.\n if (value instanceof Set)\n return [...value].map((v) => normalize(v, nulls)) as T;\n // Other non-plain values (Date, Map, class instances) are left to the\n // host's serializer: a Map has no single obvious JSON encoding to guess.\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) return value;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>))\n out[k] = v === undefined && nulls ? null : normalize(v, nulls);\n return out as T;\n};\n\n/**\n * Frame-bound normalization: Sets become arrays, and `undefined` becomes\n * `null` so a delete survives `JSON.stringify`.\n */\nconst wire_safe = <T>(patch: T): T => normalize(patch, true);\n\n/**\n * Cache-bound normalization: Sets become arrays, `undefined` is left alone,\n * because a reconnecting client reseeds from the cache and a cleared key\n * must stay absent there, not `null`.\n *\n * `Object.entries` skips the non-enumerable `OVERLAY_KEYS` marker, so\n * callers tag after normalizing.\n */\nconst cache_safe = <T>(state: T): T => normalize(state, false);\n\n/**\n * Keys an `overlay()` contributed to a stream's cached state, carried on the\n * cached object itself, so `publish()` can keep them when it replaces the\n * entry: a reconnecting client reseeds at the same `_v` and couldn't tell\n * the overlay data was missing.\n *\n * A symbol keeps it off the wire, and living on the entry means it is\n * evicted with it.\n */\nconst OVERLAY_KEYS = Symbol(\"act.sse.overlay_keys\");\n\ntype WithOverlayKeys = { [OVERLAY_KEYS]?: ReadonlySet<string> };\n\n/**\n * Default `onSubscriberError`. This module shares the `sse` subpath with\n * `applyPatchMessage`, which browser code imports, so it never imports the\n * framework: a bundler would follow that into a browser build. Pass\n * `onSubscriberError` to send these to your logger.\n */\nconst default_subscriber_error = (error: unknown, streamId: string): void => {\n // biome-ignore lint/suspicious/noConsole: the default can't reach the framework logger (see above)\n console.error(`sse subscriber threw for \"${streamId}\"`, error);\n};\n\n/** Record which keys an overlay owns, accumulating across overlays. */\nconst tag_overlay_keys = <S extends object>(\n state: S,\n prev: S | undefined,\n keys: readonly string[]\n): S => {\n const carried = (prev as WithOverlayKeys | undefined)?.[OVERLAY_KEYS];\n Object.defineProperty(state, OVERLAY_KEYS, {\n value: new Set([...(carried ?? []), ...keys]),\n enumerable: false,\n configurable: true,\n });\n return state;\n};\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n /** Max number of stream states kept in the LRU cache (default 50). */\n cacheSize?: number;\n /**\n * Called when overlay-contributed state is lost, on either of the two\n * paths that can lose it: `overlay()` finding no cached baseline, and\n * the LRU evicting an entry that carried overlay keys — the latter from\n * inside `publish()`, for a stream other than the one being published.\n *\n * Live subscribers of the affected stream are sent a resync frame, so\n * they refetch rather than keep showing state the server has forgotten.\n * The hook is observability on top of that recovery: a steady stream of\n * calls means `cacheSize` is too small for the working set.\n *\n * A throw is caught and routed to `onSubscriberError` — the resync has\n * already gone out, and a host bug must not cost the publishing stream\n * its own frame. Defaults to a no-op.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher. Defaults to `console.error`;\n * pass your logger here to route it with the rest of your logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50,\n (streamId, dropped) => this.on_cache_evict(streamId, dropped)\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ?? default_subscriber_error;\n }\n\n /**\n * Fan a resync out to a stream's live subscribers. `applyPatchMessage`\n * always reports `behind` for it, so they refetch.\n */\n private broadcast_resync(streamId: string): void {\n fan_out(this.channels.get(streamId), resync_frame<S>(), (error) =>\n this.on_subscriber_error(error, streamId)\n );\n }\n\n /**\n * The LRU dropped an entry. Overlay-contributed keys lived only in the\n * cache, so their loss is reported now, when it happens, rather than at a\n * later commit that may never come.\n */\n private on_cache_evict(streamId: string, dropped: S): void {\n if (!(dropped as S & WithOverlayKeys)[OVERLAY_KEYS]?.size) return;\n this.report_overlay_miss(streamId);\n }\n\n /**\n * Overlay-contributed state for `streamId` is gone. Recover first, report\n * second: the resync goes out before the host hook, and a throwing hook is\n * routed to `onSubscriberError`. Eviction runs inside `publish()` for\n * another stream, so an escaping throw would cost that stream its frame.\n */\n private report_overlay_miss(streamId: string): void {\n this.broadcast_resync(streamId);\n try {\n this.on_overlay_miss(streamId);\n } catch (error) {\n this.on_subscriber_error(error, streamId);\n }\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event, as the\n * snapshots carry them (`null` deletes a key)\n */\n publish(\n streamId: string,\n state: S,\n patches: Readonly<Patch<S>>[] = []\n ): PatchMessage<S> {\n // Normalize once, so the reseed and the live frame agree. Then carry the\n // keys an `overlay()` owns across the commit, unless the new domain state\n // sets them, so presence survives a commit and the publisher still wins.\n const safe_state = cache_safe(state);\n const prev = this.state_cache.get(streamId) as\n | (S & WithOverlayKeys)\n | undefined;\n const overlay_keys = prev?.[OVERLAY_KEYS];\n let cached = safe_state;\n if (overlay_keys?.size && prev) {\n const carried = { ...safe_state } as S;\n const kept: string[] = [];\n for (const key of overlay_keys)\n if (!(key in safe_state) && key in prev) {\n (carried as Record<string, unknown>)[key] = (\n prev as Record<string, unknown>\n )[key];\n kept.push(key);\n }\n cached = tag_overlay_keys(carried, undefined, kept);\n }\n this.state_cache.set(streamId, cached);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p) as PatchMessage<S>[number];\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to patch, and a stream that only gets overlays never\n // refills the cache, so its live clients would miss every update. A\n // resync makes them refetch. `undefined`: no overlay was broadcast.\n this.report_overlay_miss(streamId);\n return undefined;\n }\n\n // Normalize before patching the cache, so the reseed and the live frame\n // agree; `apply_patch` deletes on `null` as on `undefined`.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(\n streamId,\n tag_overlay_keys(state, prev, Object.keys(safe_patch as object))\n );\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n private on_evict: (key: string, state: S) => void;\n\n /**\n * @param maxSize - Max entries before the LRU drops its oldest.\n * @param onEvict - Called with the entry the LRU just dropped. Eviction is\n * the moment cached-only data is lost, so it is the only place a caller\n * can still see what went.\n */\n constructor(\n maxSize = 50,\n onEvict: (key: string, state: S) => void = () => {}\n ) {\n this.maxSize = maxSize;\n this.on_evict = onEvict;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n const oldest = this.cache.keys().next().value!;\n const dropped = this.cache.get(oldest)!;\n this.cache.delete(oldest);\n this.on_evict(oldest, dropped);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","import type { DeepPartial } from \"@rotorsoft/act-patch\";\n\n/**\n * Base constraint for state objects managed by the broadcast system.\n * Apps extend this with their own domain state shape.\n */\nexport type BroadcastState = Record<string, unknown> & {\n /** Event store stream version — set by the broadcast layer from snap.event.version */\n _v: number;\n};\n\n/**\n * SSE message: version-keyed domain patches.\n * Keys are stringified version numbers, values are domain patches (deep partials).\n * Multi-event commits produce multiple version-keyed entries.\n *\n * The optional `_overlay` marker flags a version-neutral update (presence,\n * computed-field refresh) emitted by {@link BroadcastChannel.overlay} — a\n * single entry keyed at the *current* version. It tells `applyPatchMessage`\n * to merge the entry on top of the client's caught-up state instead of\n * rejecting it as stale (a same-version patch WITHOUT the marker stays\n * stale). Ordinary version-bumping patches from `publish()` omit it.\n *\n * `_resync` carries no versions at all. The server emits it when it cannot\n * construct a patch: when overlay-contributed state is lost, because\n * `overlay()` finds no cached baseline or the LRU evicts an entry that\n * carried overlay keys. A client that receives one always reports `behind`\n * and refetches, so live subscribers don't silently miss updates.\n */\nexport type PatchMessage<S extends BroadcastState = BroadcastState> = Record<\n number,\n DeepPartial<S>\n> & { readonly _overlay?: true; readonly _resync?: true };\n\n/**\n * Subscriber callback — receives version-keyed patch messages.\n */\nexport type Subscriber<S extends BroadcastState = BroadcastState> = (\n msg: PatchMessage<S>\n) => void;\n\n/**\n * The frame that tells a client to refetch: no versions, so\n * `applyPatchMessage` always reports `behind`.\n *\n * Every producer of a resync uses this — `overlay()` on a missing baseline,\n * the cache on evicting overlay state, and the SSE backlog when it cannot\n * drop a frame without losing it silently. One factory so the shape cannot\n * drift between them.\n */\nexport const resync_frame = <S extends BroadcastState>(): PatchMessage<S> =>\n ({ _resync: true }) as PatchMessage<S>;\n\n/**\n * Whether a frame carries no versions, so `applyPatchMessage` can never\n * classify it as a gap.\n *\n * A version-keyed patch that goes missing is self-announcing: the next frame\n * skips a version and the client refetches. An `_overlay` or `_resync` frame\n * is not, which is why anything that drops frames has to tell them apart.\n */\nexport const is_version_neutral = <S extends BroadcastState>(\n msg: PatchMessage<S>\n): boolean => msg._overlay === true || msg._resync === true;\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":"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","names":["import_act_patch","deep_merge","import_act_patch","patch","apply_patch"]}
package/dist/sse/index.js CHANGED
@@ -186,7 +186,8 @@ var BroadcastChannel = class {
186
186
  *
187
187
  * @param streamId - The event store stream ID
188
188
  * @param state - Full state with `_v` set from `snap.event.version`
189
- * @param patches - Array of domain patches, one per emitted event
189
+ * @param patches - Array of domain patches, one per emitted event, as the
190
+ * snapshots carry them (`null` deletes a key)
190
191
  */
191
192
  publish(streamId, state, patches = []) {
192
193
  const safe_state = cache_safe(state);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // A stream's first event is version 0, so a client with no baseline\n // resumes from -1; defaulting to 0 would drop that first patch as stale.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else: a resync frame carries no versions, and the empty-frame\n // branch below would report it as `stale`, the one answer that doesn't\n // refetch.\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n // An empty frame carries nothing to apply: `stale` for a client with a\n // baseline, `behind` for one without, which must fetch one.\n if (!versions.length)\n return { ok: false, reason: hasBaseline ? \"stale\" : \"behind\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled entirely here: the contiguous fold below would\n // apply its payload as that version's domain patch, and a client at\n // `cachedV + 1` would stamp itself caught up and never get the real one.\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { patch as apply_patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport {\n type BroadcastState,\n type PatchMessage,\n resync_frame,\n type Subscriber,\n} from \"./types.js\";\n\n/**\n * Deliver a frame to every subscriber, containing each one: a subscriber\n * that throws must not cost the others the frame or escape into the\n * publisher. One guard around the loop would let the first throw stop it.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite values that don't survive JSON, recursively: Sets become arrays,\n * and with `nulls`, `undefined` becomes `null`.\n *\n * `@rotorsoft/act-patch` deletes a key on either `undefined` or `null`, but\n * `JSON.stringify` drops `undefined` keys, so a cleared field would vanish\n * from the frame and a live client would keep the stale value. One walker\n * serves the frame and the cache, which differ only in that delete encoding\n * (see `wire_safe` / `cache_safe`).\n */\nconst normalize = <T>(value: T, nulls: boolean): T => {\n if (value === null || typeof value !== \"object\") return value;\n if (Array.isArray(value)) return value.map((v) => normalize(v, nulls)) as T;\n // `JSON.stringify` encodes a Set as `{}`; presence (`PresenceTracker.online()`)\n // is a Set, so it would reach clients empty.\n if (value instanceof Set)\n return [...value].map((v) => normalize(v, nulls)) as T;\n // Other non-plain values (Date, Map, class instances) are left to the\n // host's serializer: a Map has no single obvious JSON encoding to guess.\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) return value;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>))\n out[k] = v === undefined && nulls ? null : normalize(v, nulls);\n return out as T;\n};\n\n/**\n * Frame-bound normalization: Sets become arrays, and `undefined` becomes\n * `null` so a delete survives `JSON.stringify`.\n */\nconst wire_safe = <T>(patch: T): T => normalize(patch, true);\n\n/**\n * Cache-bound normalization: Sets become arrays, `undefined` is left alone,\n * because a reconnecting client reseeds from the cache and a cleared key\n * must stay absent there, not `null`.\n *\n * `Object.entries` skips the non-enumerable `OVERLAY_KEYS` marker, so\n * callers tag after normalizing.\n */\nconst cache_safe = <T>(state: T): T => normalize(state, false);\n\n/**\n * Keys an `overlay()` contributed to a stream's cached state, carried on the\n * cached object itself, so `publish()` can keep them when it replaces the\n * entry: a reconnecting client reseeds at the same `_v` and couldn't tell\n * the overlay data was missing.\n *\n * A symbol keeps it off the wire, and living on the entry means it is\n * evicted with it.\n */\nconst OVERLAY_KEYS = Symbol(\"act.sse.overlay_keys\");\n\ntype WithOverlayKeys = { [OVERLAY_KEYS]?: ReadonlySet<string> };\n\n/**\n * Default `onSubscriberError`. This module shares the `sse` subpath with\n * `applyPatchMessage`, which browser code imports, so it never imports the\n * framework: a bundler would follow that into a browser build. Pass\n * `onSubscriberError` to send these to your logger.\n */\nconst default_subscriber_error = (error: unknown, streamId: string): void => {\n // biome-ignore lint/suspicious/noConsole: the default can't reach the framework logger (see above)\n console.error(`sse subscriber threw for \"${streamId}\"`, error);\n};\n\n/** Record which keys an overlay owns, accumulating across overlays. */\nconst tag_overlay_keys = <S extends object>(\n state: S,\n prev: S | undefined,\n keys: readonly string[]\n): S => {\n const carried = (prev as WithOverlayKeys | undefined)?.[OVERLAY_KEYS];\n Object.defineProperty(state, OVERLAY_KEYS, {\n value: new Set([...(carried ?? []), ...keys]),\n enumerable: false,\n configurable: true,\n });\n return state;\n};\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n /** Max number of stream states kept in the LRU cache (default 50). */\n cacheSize?: number;\n /**\n * Called when overlay-contributed state is lost, on either of the two\n * paths that can lose it: `overlay()` finding no cached baseline, and\n * the LRU evicting an entry that carried overlay keys — the latter from\n * inside `publish()`, for a stream other than the one being published.\n *\n * Live subscribers of the affected stream are sent a resync frame, so\n * they refetch rather than keep showing state the server has forgotten.\n * The hook is observability on top of that recovery: a steady stream of\n * calls means `cacheSize` is too small for the working set.\n *\n * A throw is caught and routed to `onSubscriberError` — the resync has\n * already gone out, and a host bug must not cost the publishing stream\n * its own frame. Defaults to a no-op.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher. Defaults to `console.error`;\n * pass your logger here to route it with the rest of your logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50,\n (streamId, dropped) => this.on_cache_evict(streamId, dropped)\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ?? default_subscriber_error;\n }\n\n /**\n * Fan a resync out to a stream's live subscribers. `applyPatchMessage`\n * always reports `behind` for it, so they refetch.\n */\n private broadcast_resync(streamId: string): void {\n fan_out(this.channels.get(streamId), resync_frame<S>(), (error) =>\n this.on_subscriber_error(error, streamId)\n );\n }\n\n /**\n * The LRU dropped an entry. Overlay-contributed keys lived only in the\n * cache, so their loss is reported now, when it happens, rather than at a\n * later commit that may never come.\n */\n private on_cache_evict(streamId: string, dropped: S): void {\n if (!(dropped as S & WithOverlayKeys)[OVERLAY_KEYS]?.size) return;\n this.report_overlay_miss(streamId);\n }\n\n /**\n * Overlay-contributed state for `streamId` is gone. Recover first, report\n * second: the resync goes out before the host hook, and a throwing hook is\n * routed to `onSubscriberError`. Eviction runs inside `publish()` for\n * another stream, so an escaping throw would cost that stream its frame.\n */\n private report_overlay_miss(streamId: string): void {\n this.broadcast_resync(streamId);\n try {\n this.on_overlay_miss(streamId);\n } catch (error) {\n this.on_subscriber_error(error, streamId);\n }\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n // Normalize once, so the reseed and the live frame agree. Then carry the\n // keys an `overlay()` owns across the commit, unless the new domain state\n // sets them, so presence survives a commit and the publisher still wins.\n const safe_state = cache_safe(state);\n const prev = this.state_cache.get(streamId) as\n | (S & WithOverlayKeys)\n | undefined;\n const overlay_keys = prev?.[OVERLAY_KEYS];\n let cached = safe_state;\n if (overlay_keys?.size && prev) {\n const carried = { ...safe_state } as S;\n const kept: string[] = [];\n for (const key of overlay_keys)\n if (!(key in safe_state) && key in prev) {\n (carried as Record<string, unknown>)[key] = (\n prev as Record<string, unknown>\n )[key];\n kept.push(key);\n }\n cached = tag_overlay_keys(carried, undefined, kept);\n }\n this.state_cache.set(streamId, cached);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p);\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to patch, and a stream that only gets overlays never\n // refills the cache, so its live clients would miss every update. A\n // resync makes them refetch. `undefined`: no overlay was broadcast.\n this.report_overlay_miss(streamId);\n return undefined;\n }\n\n // Normalize before patching the cache, so the reseed and the live frame\n // agree; `apply_patch` deletes on `null` as on `undefined`.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(\n streamId,\n tag_overlay_keys(state, prev, Object.keys(safe_patch as object))\n );\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n private on_evict: (key: string, state: S) => void;\n\n /**\n * @param maxSize - Max entries before the LRU drops its oldest.\n * @param onEvict - Called with the entry the LRU just dropped. Eviction is\n * the moment cached-only data is lost, so it is the only place a caller\n * can still see what went.\n */\n constructor(\n maxSize = 50,\n onEvict: (key: string, state: S) => void = () => {}\n ) {\n this.maxSize = maxSize;\n this.on_evict = onEvict;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n const oldest = this.cache.keys().next().value!;\n const dropped = this.cache.get(oldest)!;\n this.cache.delete(oldest);\n this.on_evict(oldest, dropped);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":"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","names":["patch"]}
1
+ {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // A stream's first event is version 0, so a client with no baseline\n // resumes from -1; defaulting to 0 would drop that first patch as stale.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else: a resync frame carries no versions, and the empty-frame\n // branch below would report it as `stale`, the one answer that doesn't\n // refetch.\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n // An empty frame carries nothing to apply: `stale` for a client with a\n // baseline, `behind` for one without, which must fetch one.\n if (!versions.length)\n return { ok: false, reason: hasBaseline ? \"stale\" : \"behind\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled entirely here: the contiguous fold below would\n // apply its payload as that version's domain patch, and a client at\n // `cachedV + 1` would stamp itself caught up and never get the real one.\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { patch as apply_patch, type Patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport {\n type BroadcastState,\n type PatchMessage,\n resync_frame,\n type Subscriber,\n} from \"./types.js\";\n\n/**\n * Deliver a frame to every subscriber, containing each one: a subscriber\n * that throws must not cost the others the frame or escape into the\n * publisher. One guard around the loop would let the first throw stop it.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite values that don't survive JSON, recursively: Sets become arrays,\n * and with `nulls`, `undefined` becomes `null`.\n *\n * `@rotorsoft/act-patch` deletes a key on either `undefined` or `null`, but\n * `JSON.stringify` drops `undefined` keys, so a cleared field would vanish\n * from the frame and a live client would keep the stale value. One walker\n * serves the frame and the cache, which differ only in that delete encoding\n * (see `wire_safe` / `cache_safe`).\n */\nconst normalize = <T>(value: T, nulls: boolean): T => {\n if (value === null || typeof value !== \"object\") return value;\n if (Array.isArray(value)) return value.map((v) => normalize(v, nulls)) as T;\n // `JSON.stringify` encodes a Set as `{}`; presence (`PresenceTracker.online()`)\n // is a Set, so it would reach clients empty.\n if (value instanceof Set)\n return [...value].map((v) => normalize(v, nulls)) as T;\n // Other non-plain values (Date, Map, class instances) are left to the\n // host's serializer: a Map has no single obvious JSON encoding to guess.\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) return value;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>))\n out[k] = v === undefined && nulls ? null : normalize(v, nulls);\n return out as T;\n};\n\n/**\n * Frame-bound normalization: Sets become arrays, and `undefined` becomes\n * `null` so a delete survives `JSON.stringify`.\n */\nconst wire_safe = <T>(patch: T): T => normalize(patch, true);\n\n/**\n * Cache-bound normalization: Sets become arrays, `undefined` is left alone,\n * because a reconnecting client reseeds from the cache and a cleared key\n * must stay absent there, not `null`.\n *\n * `Object.entries` skips the non-enumerable `OVERLAY_KEYS` marker, so\n * callers tag after normalizing.\n */\nconst cache_safe = <T>(state: T): T => normalize(state, false);\n\n/**\n * Keys an `overlay()` contributed to a stream's cached state, carried on the\n * cached object itself, so `publish()` can keep them when it replaces the\n * entry: a reconnecting client reseeds at the same `_v` and couldn't tell\n * the overlay data was missing.\n *\n * A symbol keeps it off the wire, and living on the entry means it is\n * evicted with it.\n */\nconst OVERLAY_KEYS = Symbol(\"act.sse.overlay_keys\");\n\ntype WithOverlayKeys = { [OVERLAY_KEYS]?: ReadonlySet<string> };\n\n/**\n * Default `onSubscriberError`. This module shares the `sse` subpath with\n * `applyPatchMessage`, which browser code imports, so it never imports the\n * framework: a bundler would follow that into a browser build. Pass\n * `onSubscriberError` to send these to your logger.\n */\nconst default_subscriber_error = (error: unknown, streamId: string): void => {\n // biome-ignore lint/suspicious/noConsole: the default can't reach the framework logger (see above)\n console.error(`sse subscriber threw for \"${streamId}\"`, error);\n};\n\n/** Record which keys an overlay owns, accumulating across overlays. */\nconst tag_overlay_keys = <S extends object>(\n state: S,\n prev: S | undefined,\n keys: readonly string[]\n): S => {\n const carried = (prev as WithOverlayKeys | undefined)?.[OVERLAY_KEYS];\n Object.defineProperty(state, OVERLAY_KEYS, {\n value: new Set([...(carried ?? []), ...keys]),\n enumerable: false,\n configurable: true,\n });\n return state;\n};\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n /** Max number of stream states kept in the LRU cache (default 50). */\n cacheSize?: number;\n /**\n * Called when overlay-contributed state is lost, on either of the two\n * paths that can lose it: `overlay()` finding no cached baseline, and\n * the LRU evicting an entry that carried overlay keys — the latter from\n * inside `publish()`, for a stream other than the one being published.\n *\n * Live subscribers of the affected stream are sent a resync frame, so\n * they refetch rather than keep showing state the server has forgotten.\n * The hook is observability on top of that recovery: a steady stream of\n * calls means `cacheSize` is too small for the working set.\n *\n * A throw is caught and routed to `onSubscriberError` — the resync has\n * already gone out, and a host bug must not cost the publishing stream\n * its own frame. Defaults to a no-op.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher. Defaults to `console.error`;\n * pass your logger here to route it with the rest of your logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50,\n (streamId, dropped) => this.on_cache_evict(streamId, dropped)\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ?? default_subscriber_error;\n }\n\n /**\n * Fan a resync out to a stream's live subscribers. `applyPatchMessage`\n * always reports `behind` for it, so they refetch.\n */\n private broadcast_resync(streamId: string): void {\n fan_out(this.channels.get(streamId), resync_frame<S>(), (error) =>\n this.on_subscriber_error(error, streamId)\n );\n }\n\n /**\n * The LRU dropped an entry. Overlay-contributed keys lived only in the\n * cache, so their loss is reported now, when it happens, rather than at a\n * later commit that may never come.\n */\n private on_cache_evict(streamId: string, dropped: S): void {\n if (!(dropped as S & WithOverlayKeys)[OVERLAY_KEYS]?.size) return;\n this.report_overlay_miss(streamId);\n }\n\n /**\n * Overlay-contributed state for `streamId` is gone. Recover first, report\n * second: the resync goes out before the host hook, and a throwing hook is\n * routed to `onSubscriberError`. Eviction runs inside `publish()` for\n * another stream, so an escaping throw would cost that stream its frame.\n */\n private report_overlay_miss(streamId: string): void {\n this.broadcast_resync(streamId);\n try {\n this.on_overlay_miss(streamId);\n } catch (error) {\n this.on_subscriber_error(error, streamId);\n }\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event, as the\n * snapshots carry them (`null` deletes a key)\n */\n publish(\n streamId: string,\n state: S,\n patches: Readonly<Patch<S>>[] = []\n ): PatchMessage<S> {\n // Normalize once, so the reseed and the live frame agree. Then carry the\n // keys an `overlay()` owns across the commit, unless the new domain state\n // sets them, so presence survives a commit and the publisher still wins.\n const safe_state = cache_safe(state);\n const prev = this.state_cache.get(streamId) as\n | (S & WithOverlayKeys)\n | undefined;\n const overlay_keys = prev?.[OVERLAY_KEYS];\n let cached = safe_state;\n if (overlay_keys?.size && prev) {\n const carried = { ...safe_state } as S;\n const kept: string[] = [];\n for (const key of overlay_keys)\n if (!(key in safe_state) && key in prev) {\n (carried as Record<string, unknown>)[key] = (\n prev as Record<string, unknown>\n )[key];\n kept.push(key);\n }\n cached = tag_overlay_keys(carried, undefined, kept);\n }\n this.state_cache.set(streamId, cached);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p) as PatchMessage<S>[number];\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to patch, and a stream that only gets overlays never\n // refills the cache, so its live clients would miss every update. A\n // resync makes them refetch. `undefined`: no overlay was broadcast.\n this.report_overlay_miss(streamId);\n return undefined;\n }\n\n // Normalize before patching the cache, so the reseed and the live frame\n // agree; `apply_patch` deletes on `null` as on `undefined`.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(\n streamId,\n tag_overlay_keys(state, prev, Object.keys(safe_patch as object))\n );\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n private on_evict: (key: string, state: S) => void;\n\n /**\n * @param maxSize - Max entries before the LRU drops its oldest.\n * @param onEvict - Called with the entry the LRU just dropped. Eviction is\n * the moment cached-only data is lost, so it is the only place a caller\n * can still see what went.\n */\n constructor(\n maxSize = 50,\n onEvict: (key: string, state: S) => void = () => {}\n ) {\n this.maxSize = maxSize;\n this.on_evict = onEvict;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n const oldest = this.cache.keys().next().value!;\n const dropped = this.cache.get(oldest)!;\n this.cache.delete(oldest);\n this.on_evict(oldest, dropped);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":"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","names":["patch"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rotorsoft/act-http",
3
3
  "type": "module",
4
- "version": "1.11.3",
4
+ "version": "1.12.0",
5
5
  "description": "HTTP integrations for act apps — webhooks and SSE",
6
6
  "keywords": [
7
7
  "typescript",
@@ -91,7 +91,7 @@
91
91
  },
92
92
  "peerDependencies": {
93
93
  "@hono/node-server": "^2.1.5",
94
- "@rotorsoft/act": "^1.35.3",
94
+ "@rotorsoft/act": "^1.36.0",
95
95
  "@rotorsoft/act-ops": "^0.2.11",
96
96
  "@trpc/server": ">=11.19.0",
97
97
  "express": "^5.3.0",