@rotorsoft/act-http 0.1.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.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Generic presence tracker — ref-counted online status per stream per identity.
3
+ *
4
+ * Supports multi-tab: each subscribe increments the ref count, each
5
+ * unsubscribe decrements it. An identity is considered online when
6
+ * ref count > 0.
7
+ *
8
+ * ## Usage
9
+ *
10
+ * ```typescript
11
+ * const presence = new PresenceTracker();
12
+ *
13
+ * // On SSE connect:
14
+ * presence.add(gameId, playerId);
15
+ *
16
+ * // On SSE disconnect:
17
+ * presence.remove(gameId, playerId);
18
+ *
19
+ * // Query:
20
+ * presence.getOnline(gameId); // Set<string>
21
+ * ```
22
+ */
23
+ export declare class PresenceTracker {
24
+ private streams;
25
+ /** Increment ref count for an identity on a stream. */
26
+ add(streamId: string, identityId: string): void;
27
+ /** Decrement ref count. Removes the identity when count reaches 0. */
28
+ remove(streamId: string, identityId: string): void;
29
+ /** Get the set of online identity IDs for a stream. */
30
+ getOnline(streamId: string): Set<string>;
31
+ /** Check if a specific identity is online for a stream. */
32
+ isOnline(streamId: string, identityId: string): boolean;
33
+ }
34
+ //# sourceMappingURL=presence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presence.d.ts","sourceRoot":"","sources":["../../../src/sse/presence.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,eAAe;IAC1B,OAAO,CAAC,OAAO,CAA0C;IAEzD,uDAAuD;IACvD,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IAM/C,sEAAsE;IACtE,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IASlD,uDAAuD;IACvD,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC;IAKxC,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO;CAGxD"}
@@ -0,0 +1,29 @@
1
+ import type { BroadcastState } from "./types.js";
2
+ /**
3
+ * Generic LRU cache for aggregate state objects.
4
+ *
5
+ * Keyed by stream ID. Each entry stores the full state (with `_v` from the
6
+ * event store). Used as the "previous state" baseline for computing patches,
7
+ * and as the fast path for reconnects.
8
+ *
9
+ * The cache is shared between the broadcast hot path and read queries.
10
+ * Projections should maintain their own cache to avoid double-apply bugs.
11
+ */
12
+ export declare class StateCache<S extends BroadcastState = BroadcastState> {
13
+ private cache;
14
+ private maxSize;
15
+ constructor(maxSize?: number);
16
+ /** Get a cached state, promoting it to MRU position. */
17
+ get(key: string): S | undefined;
18
+ /** Set a cached state, evicting the LRU entry if at capacity. */
19
+ set(key: string, state: S): void;
20
+ /** Remove a cached entry. */
21
+ delete(key: string): void;
22
+ /** Check if a key exists in the cache. */
23
+ has(key: string): boolean;
24
+ /** Current number of cached entries. */
25
+ get size(): number;
26
+ /** Direct access to the underlying map (for iteration). */
27
+ entries(): IterableIterator<[string, S]>;
28
+ }
29
+ //# sourceMappingURL=state-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-cache.d.ts","sourceRoot":"","sources":["../../../src/sse/state-cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD;;;;;;;;;GASG;AACH,qBAAa,UAAU,CAAC,CAAC,SAAS,cAAc,GAAG,cAAc;IAC/D,OAAO,CAAC,KAAK,CAAwB;IACrC,OAAO,CAAC,OAAO,CAAS;gBAEZ,OAAO,SAAK;IAIxB,wDAAwD;IACxD,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS;IAS/B,iEAAiE;IACjE,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,IAAI;IAQhC,6BAA6B;IAC7B,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAIzB,0CAA0C;IAC1C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIzB,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,2DAA2D;IAC3D,OAAO,IAAI,gBAAgB,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;CAGzC"}
@@ -0,0 +1,20 @@
1
+ import type { DeepPartial } from "@rotorsoft/act-patch";
2
+ /**
3
+ * Base constraint for state objects managed by the broadcast system.
4
+ * Apps extend this with their own domain state shape.
5
+ */
6
+ export type BroadcastState = Record<string, unknown> & {
7
+ /** Event store stream version — set by the broadcast layer from snap.event.version */
8
+ _v: number;
9
+ };
10
+ /**
11
+ * SSE message: version-keyed domain patches.
12
+ * Keys are stringified version numbers, values are domain patches (deep partials).
13
+ * Multi-event commits produce multiple version-keyed entries.
14
+ */
15
+ export type PatchMessage<S extends BroadcastState = BroadcastState> = Record<number, DeepPartial<S>>;
16
+ /**
17
+ * Subscriber callback — receives version-keyed patch messages.
18
+ */
19
+ export type Subscriber<S extends BroadcastState = BroadcastState> = (msg: PatchMessage<S>) => void;
20
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/sse/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAExD;;;GAGG;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IACrD,sFAAsF;IACtF,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,SAAS,cAAc,GAAG,cAAc,IAAI,MAAM,CAC1E,MAAM,EACN,WAAW,CAAC,CAAC,CAAC,CACf,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,UAAU,CAAC,CAAC,SAAS,cAAc,GAAG,cAAc,IAAI,CAClE,GAAG,EAAE,YAAY,CAAC,CAAC,CAAC,KACjB,IAAI,CAAC"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * @module act-http/webhook
4
+ *
5
+ * Reaction-handler sugar for POSTing committed events to external URLs.
6
+ *
7
+ * Wraps `fetch` with timeouts, automatic `Idempotency-Key` derivation, and
8
+ * status-classified errors. Designed to be composed with the reaction
9
+ * options shipped in ACT-601 (`maxRetries`, `blockOnError`, `backoff`):
10
+ *
11
+ * ```ts
12
+ * import { webhook } from "@rotorsoft/act-http/webhook";
13
+ *
14
+ * .on("OrderConfirmed")
15
+ * .do(
16
+ * webhook({
17
+ * url: "https://api.example.com/webhooks/orders",
18
+ * headers: (e) => ({ Authorization: "Bearer ..." }),
19
+ * body: (e) => ({ orderId: e.stream, total: e.data.total }),
20
+ * timeoutMs: 5_000,
21
+ * }),
22
+ * { maxRetries: 5, backoff: { strategy: "exponential", baseMs: 200, maxMs: 30_000 } }
23
+ * )
24
+ * .to(resolver);
25
+ * ```
26
+ */
27
+ import type { ReactionHandler, Schemas } from "@rotorsoft/act";
28
+ import { type WebhookConfig } from "./types.js";
29
+ export type { WebhookConfig, WebhookResolver } from "./types.js";
30
+ export { WebhookError } from "./types.js";
31
+ /**
32
+ * Build a reaction handler that POSTs each event to an external URL.
33
+ *
34
+ * Behavior:
35
+ *
36
+ * - Network errors and timeouts throw {@link WebhookError} with
37
+ * `status: 0`, `retryable: true`.
38
+ * - 5xx responses throw with `retryable: true`.
39
+ * - 4xx responses throw with `retryable: false`.
40
+ * - 2xx and 3xx return successfully.
41
+ *
42
+ * Drain retry behavior follows the reaction's `maxRetries` / `backoff`
43
+ * options. To skip retries entirely for client errors, set
44
+ * `maxRetries: 0` on the reaction — both 4xx and 5xx will block on the
45
+ * first failed attempt.
46
+ */
47
+ export declare function webhook<TEvents extends Schemas = Schemas>(config: WebhookConfig<TEvents>): ReactionHandler<TEvents, keyof TEvents>;
48
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/webhook/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAa,eAAe,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAC1E,OAAO,EAAE,KAAK,aAAa,EAAgB,MAAM,YAAY,CAAC;AAE9D,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AACjE,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAsB1C;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,OAAO,CAAC,OAAO,SAAS,OAAO,GAAG,OAAO,EACvD,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,GAC7B,eAAe,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,CAqEzC"}
@@ -0,0 +1,82 @@
1
+ import type { Committed, Schemas } from "@rotorsoft/act";
2
+ /**
3
+ * Function or static value resolver. Used so callers can pass either a
4
+ * constant or a per-event function for headers / body / url.
5
+ *
6
+ * The static side `T` is constrained to non-function types so that a
7
+ * passed `(event) => ...` is unambiguously typed as the function variant.
8
+ */
9
+ export type WebhookResolver<TEvents extends Schemas, T> = T | ((event: Committed<TEvents, keyof TEvents>) => T);
10
+ /**
11
+ * Plain-data body shape the helper accepts as a static value. Functions
12
+ * are deliberately excluded so the union with the resolver function is
13
+ * unambiguous at the call site (TypeScript can discriminate by shape).
14
+ */
15
+ export type WebhookBody = string | {
16
+ readonly [k: string]: unknown;
17
+ } | readonly unknown[];
18
+ /**
19
+ * Configuration for {@link webhook}.
20
+ *
21
+ * @template TEvents - Event schemas; resolvers receive the typed committed event.
22
+ */
23
+ export type WebhookConfig<TEvents extends Schemas = Schemas> = {
24
+ /** Target URL — static string or per-event function. */
25
+ readonly url: WebhookResolver<TEvents, string>;
26
+ /** HTTP method. Defaults to `"POST"`. */
27
+ readonly method?: "POST" | "PUT" | "PATCH" | "DELETE";
28
+ /**
29
+ * Headers to send. Resolver may return a record per event. The
30
+ * `Content-Type: application/json` and `Idempotency-Key` headers are
31
+ * applied automatically; both can be overridden by returning a header
32
+ * with the same name (case-insensitive).
33
+ */
34
+ readonly headers?: WebhookResolver<TEvents, Record<string, string>>;
35
+ /**
36
+ * Request body. Static plain data (object, array, string) or a
37
+ * per-event function returning the same. Strings are sent as-is;
38
+ * anything else is JSON-serialized. Defaults to the committed event
39
+ * itself.
40
+ */
41
+ readonly body?: WebhookBody | ((event: Committed<TEvents, keyof TEvents>) => WebhookBody);
42
+ /**
43
+ * Per-request timeout in milliseconds. Defaults to 5000.
44
+ * The handler throws after the timeout via `AbortController`.
45
+ */
46
+ readonly timeoutMs?: number;
47
+ /**
48
+ * Override for the auto-generated `Idempotency-Key`. By default, the
49
+ * helper sends `event.id` (the immutable, monotonic event identifier).
50
+ * Return a string to override; return `null` to skip the header entirely.
51
+ */
52
+ readonly idempotencyKey?: (event: Committed<TEvents, keyof TEvents>) => string | null;
53
+ /**
54
+ * Injection point for tests. Defaults to global `fetch`.
55
+ */
56
+ readonly fetch?: typeof fetch;
57
+ };
58
+ /**
59
+ * Error thrown by the webhook handler on network failure, timeout, or
60
+ * non-2xx response. The `status` field is `0` for network / timeout
61
+ * errors and the HTTP status code otherwise.
62
+ *
63
+ * `retryable` reflects the helper's classification: network errors,
64
+ * timeouts, and 5xx are flagged retryable; 4xx is not. The current drain
65
+ * pipeline does not distinguish — both are caught and counted against
66
+ * `maxRetries`. Callers who want different retry semantics per category
67
+ * can introspect the error in a wrapping handler or tune `maxRetries` /
68
+ * `backoff` on the reaction options.
69
+ */
70
+ export declare class WebhookError extends Error {
71
+ readonly status: number;
72
+ readonly retryable: boolean;
73
+ readonly url: string;
74
+ readonly responseBody?: string;
75
+ constructor(message: string, init: {
76
+ status: number;
77
+ retryable: boolean;
78
+ url: string;
79
+ responseBody?: string;
80
+ });
81
+ }
82
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/webhook/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAEzD;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,CAAC,OAAO,SAAS,OAAO,EAAE,CAAC,IAClD,CAAC,GACD,CAAC,CAAC,KAAK,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;AAEtD;;;;GAIG;AACH,MAAM,MAAM,WAAW,GACnB,MAAM,GACN;IAAE,QAAQ,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,GACjC,SAAS,OAAO,EAAE,CAAC;AAEvB;;;;GAIG;AACH,MAAM,MAAM,aAAa,CAAC,OAAO,SAAS,OAAO,GAAG,OAAO,IAAI;IAC7D,wDAAwD;IACxD,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC/C,yCAAyC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;IACtD;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpE;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,CAAC,EACV,WAAW,GACX,CAAC,CAAC,KAAK,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,KAAK,WAAW,CAAC,CAAC;IAChE;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,CACxB,KAAK,EAAE,SAAS,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,KACrC,MAAM,GAAG,IAAI,CAAC;IACnB;;OAEG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CAC/B,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,qBAAa,YAAa,SAAQ,KAAK;IACrC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;gBAG7B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;QACJ,MAAM,EAAE,MAAM,CAAC;QACf,SAAS,EAAE,OAAO,CAAC;QACnB,GAAG,EAAE,MAAM,CAAC;QACZ,YAAY,CAAC,EAAE,MAAM,CAAC;KACvB;CASJ"}
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/sse/index.ts
21
+ var sse_exports = {};
22
+ __export(sse_exports, {
23
+ BroadcastChannel: () => BroadcastChannel,
24
+ PresenceTracker: () => PresenceTracker,
25
+ StateCache: () => StateCache,
26
+ applyPatchMessage: () => applyPatchMessage,
27
+ patch: () => import_act_patch3.patch
28
+ });
29
+ module.exports = __toCommonJS(sse_exports);
30
+ var import_act_patch3 = require("@rotorsoft/act-patch");
31
+
32
+ // src/sse/apply-patch.ts
33
+ var import_act_patch = require("@rotorsoft/act-patch");
34
+ function applyPatchMessage(msg, cached) {
35
+ const cachedV = cached?._v ?? 0;
36
+ const versions = Object.keys(msg).map(Number).sort((a, b) => a - b);
37
+ if (!versions.length) return { ok: false, reason: "stale" };
38
+ const minV = versions[0];
39
+ const maxV = versions[versions.length - 1];
40
+ if (maxV <= cachedV) return { ok: false, reason: "stale" };
41
+ if (!cached || minV > cachedV + 1) return { ok: false, reason: "behind" };
42
+ let state = cached;
43
+ for (const v of versions) {
44
+ if (v <= cachedV) continue;
45
+ state = { ...(0, import_act_patch.patch)(state, msg[v]), _v: v };
46
+ }
47
+ return { ok: true, state };
48
+ }
49
+
50
+ // src/sse/broadcast.ts
51
+ var import_act_patch2 = require("@rotorsoft/act-patch");
52
+
53
+ // src/sse/state-cache.ts
54
+ var StateCache = class {
55
+ cache = /* @__PURE__ */ new Map();
56
+ maxSize;
57
+ constructor(maxSize = 50) {
58
+ this.maxSize = maxSize;
59
+ }
60
+ /** Get a cached state, promoting it to MRU position. */
61
+ get(key) {
62
+ const s = this.cache.get(key);
63
+ if (s) {
64
+ this.cache.delete(key);
65
+ this.cache.set(key, s);
66
+ }
67
+ return s;
68
+ }
69
+ /** Set a cached state, evicting the LRU entry if at capacity. */
70
+ set(key, state) {
71
+ this.cache.delete(key);
72
+ this.cache.set(key, state);
73
+ if (this.cache.size > this.maxSize) {
74
+ this.cache.delete(this.cache.keys().next().value);
75
+ }
76
+ }
77
+ /** Remove a cached entry. */
78
+ delete(key) {
79
+ this.cache.delete(key);
80
+ }
81
+ /** Check if a key exists in the cache. */
82
+ has(key) {
83
+ return this.cache.has(key);
84
+ }
85
+ /** Current number of cached entries. */
86
+ get size() {
87
+ return this.cache.size;
88
+ }
89
+ /** Direct access to the underlying map (for iteration). */
90
+ entries() {
91
+ return this.cache.entries();
92
+ }
93
+ };
94
+
95
+ // src/sse/broadcast.ts
96
+ var BroadcastChannel = class {
97
+ channels = /* @__PURE__ */ new Map();
98
+ stateCache;
99
+ constructor(options) {
100
+ this.stateCache = new StateCache(options?.cacheSize ?? 50);
101
+ }
102
+ /**
103
+ * Publish domain patches from a commit.
104
+ * patches[i] corresponds to version baseV + i + 1.
105
+ *
106
+ * @param streamId - The event store stream ID
107
+ * @param state - Full state with `_v` set from `snap.event.version`
108
+ * @param patches - Array of domain patches, one per emitted event
109
+ */
110
+ publish(streamId, state, patches = []) {
111
+ this.stateCache.set(streamId, state);
112
+ const baseV = state._v - patches.length;
113
+ const msg = {};
114
+ patches.forEach((p, i) => {
115
+ msg[baseV + i + 1] = p;
116
+ });
117
+ const subs = this.channels.get(streamId);
118
+ if (subs?.size) {
119
+ for (const cb of subs) cb(msg);
120
+ }
121
+ return msg;
122
+ }
123
+ /**
124
+ * Publish a state update that doesn't change the event version
125
+ * (e.g. presence overlay, computed field refresh).
126
+ * Uses the same version as the cached state, single entry.
127
+ */
128
+ publishOverlay(streamId, overlayPatch) {
129
+ const prev = this.stateCache.get(streamId);
130
+ if (!prev) return void 0;
131
+ const state = (0, import_act_patch2.patch)(prev, overlayPatch);
132
+ this.stateCache.set(streamId, state);
133
+ const msg = { [state._v]: overlayPatch };
134
+ const subs = this.channels.get(streamId);
135
+ if (subs?.size) {
136
+ for (const cb of subs) cb(msg);
137
+ }
138
+ return msg;
139
+ }
140
+ /**
141
+ * Subscribe to broadcast messages for a stream.
142
+ * Returns a cleanup function that removes the subscription.
143
+ */
144
+ subscribe(streamId, cb) {
145
+ if (!this.channels.has(streamId)) this.channels.set(streamId, /* @__PURE__ */ new Set());
146
+ this.channels.get(streamId).add(cb);
147
+ return () => {
148
+ this.channels.get(streamId)?.delete(cb);
149
+ if (this.channels.get(streamId)?.size === 0) {
150
+ this.channels.delete(streamId);
151
+ }
152
+ };
153
+ }
154
+ /** Get the number of subscribers for a stream. */
155
+ getSubscriberCount(streamId) {
156
+ return this.channels.get(streamId)?.size ?? 0;
157
+ }
158
+ /** Get the cached state for a stream (for reconnects / initial SSE yield). */
159
+ getState(streamId) {
160
+ return this.stateCache.get(streamId);
161
+ }
162
+ /** Direct access to the state cache (for app-specific reads like presence). */
163
+ get cache() {
164
+ return this.stateCache;
165
+ }
166
+ };
167
+
168
+ // src/sse/presence.ts
169
+ var PresenceTracker = class {
170
+ streams = /* @__PURE__ */ new Map();
171
+ /** Increment ref count for an identity on a stream. */
172
+ add(streamId, identityId) {
173
+ if (!this.streams.has(streamId)) this.streams.set(streamId, /* @__PURE__ */ new Map());
174
+ const counts = this.streams.get(streamId);
175
+ counts.set(identityId, (counts.get(identityId) ?? 0) + 1);
176
+ }
177
+ /** Decrement ref count. Removes the identity when count reaches 0. */
178
+ remove(streamId, identityId) {
179
+ const counts = this.streams.get(streamId);
180
+ if (!counts) return;
181
+ const n = (counts.get(identityId) ?? 1) - 1;
182
+ if (n <= 0) counts.delete(identityId);
183
+ else counts.set(identityId, n);
184
+ if (counts.size === 0) this.streams.delete(streamId);
185
+ }
186
+ /** Get the set of online identity IDs for a stream. */
187
+ getOnline(streamId) {
188
+ const counts = this.streams.get(streamId);
189
+ return counts ? new Set(counts.keys()) : /* @__PURE__ */ new Set();
190
+ }
191
+ /** Check if a specific identity is online for a stream. */
192
+ isOnline(streamId, identityId) {
193
+ return (this.streams.get(streamId)?.get(identityId) ?? 0) > 0;
194
+ }
195
+ };
196
+ // Annotate the CommonJS export names for ESM import in node:
197
+ 0 && (module.exports = {
198
+ BroadcastChannel,
199
+ PresenceTracker,
200
+ StateCache,
201
+ applyPatchMessage,
202
+ patch
203
+ });
204
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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 deepMerge } 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 *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.getState.getData({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.getState.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.getState.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 const cachedV = cached?._v ?? 0;\n const versions = Object.keys(msg)\n .map(Number)\n .sort((a, b) => a - b);\n\n if (!versions.length) return { ok: false, reason: \"stale\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n if (maxV <= cachedV) return { ok: false, reason: \"stale\" };\n if (!cached || minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n let state = cached;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deepMerge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { patch as applyPatch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\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.getState(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 stateCache: StateCache<S>;\n\n constructor(options?: { cacheSize?: number }) {\n this.stateCache = new StateCache<S>(options?.cacheSize ?? 50);\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 this.stateCache.set(streamId, state);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = p;\n });\n\n const subs = this.channels.get(streamId);\n if (subs?.size) {\n for (const cb of subs) cb(msg);\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 publishOverlay(\n streamId: string,\n overlayPatch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.stateCache.get(streamId);\n if (!prev) return undefined;\n\n const state = applyPatch(prev, overlayPatch) as S;\n this.stateCache.set(streamId, state);\n\n const msg: PatchMessage<S> = { [state._v]: overlayPatch };\n const subs = this.channels.get(streamId);\n if (subs?.size) {\n for (const cb of subs) cb(msg);\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 getSubscriberCount(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 getState(streamId: string): S | undefined {\n return this.stateCache.get(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.stateCache;\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\n constructor(maxSize = 50) {\n this.maxSize = maxSize;\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 this.cache.delete(this.cache.keys().next().value!);\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(gameId, playerId);\n *\n * // On SSE disconnect:\n * presence.remove(gameId, playerId);\n *\n * // Query:\n * presence.getOnline(gameId); // 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, identityId: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identityId, (counts.get(identityId) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identityId: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identityId) ?? 1) - 1;\n if (n <= 0) counts.delete(identityId);\n else counts.set(identityId, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n getOnline(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, identityId: string): boolean {\n return (this.streams.get(streamId)?.get(identityId) ?? 0) > 0;\n }\n}\n"],"mappings":"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","names":["import_act_patch","deepMerge","import_act_patch","applyPatch"]}
@@ -0,0 +1,175 @@
1
+ // src/sse/index.ts
2
+ import { patch } from "@rotorsoft/act-patch";
3
+
4
+ // src/sse/apply-patch.ts
5
+ import { patch as deepMerge } from "@rotorsoft/act-patch";
6
+ function applyPatchMessage(msg, cached) {
7
+ const cachedV = cached?._v ?? 0;
8
+ const versions = Object.keys(msg).map(Number).sort((a, b) => a - b);
9
+ if (!versions.length) return { ok: false, reason: "stale" };
10
+ const minV = versions[0];
11
+ const maxV = versions[versions.length - 1];
12
+ if (maxV <= cachedV) return { ok: false, reason: "stale" };
13
+ if (!cached || minV > cachedV + 1) return { ok: false, reason: "behind" };
14
+ let state = cached;
15
+ for (const v of versions) {
16
+ if (v <= cachedV) continue;
17
+ state = { ...deepMerge(state, msg[v]), _v: v };
18
+ }
19
+ return { ok: true, state };
20
+ }
21
+
22
+ // src/sse/broadcast.ts
23
+ import { patch as applyPatch } from "@rotorsoft/act-patch";
24
+
25
+ // src/sse/state-cache.ts
26
+ var StateCache = class {
27
+ cache = /* @__PURE__ */ new Map();
28
+ maxSize;
29
+ constructor(maxSize = 50) {
30
+ this.maxSize = maxSize;
31
+ }
32
+ /** Get a cached state, promoting it to MRU position. */
33
+ get(key) {
34
+ const s = this.cache.get(key);
35
+ if (s) {
36
+ this.cache.delete(key);
37
+ this.cache.set(key, s);
38
+ }
39
+ return s;
40
+ }
41
+ /** Set a cached state, evicting the LRU entry if at capacity. */
42
+ set(key, state) {
43
+ this.cache.delete(key);
44
+ this.cache.set(key, state);
45
+ if (this.cache.size > this.maxSize) {
46
+ this.cache.delete(this.cache.keys().next().value);
47
+ }
48
+ }
49
+ /** Remove a cached entry. */
50
+ delete(key) {
51
+ this.cache.delete(key);
52
+ }
53
+ /** Check if a key exists in the cache. */
54
+ has(key) {
55
+ return this.cache.has(key);
56
+ }
57
+ /** Current number of cached entries. */
58
+ get size() {
59
+ return this.cache.size;
60
+ }
61
+ /** Direct access to the underlying map (for iteration). */
62
+ entries() {
63
+ return this.cache.entries();
64
+ }
65
+ };
66
+
67
+ // src/sse/broadcast.ts
68
+ var BroadcastChannel = class {
69
+ channels = /* @__PURE__ */ new Map();
70
+ stateCache;
71
+ constructor(options) {
72
+ this.stateCache = new StateCache(options?.cacheSize ?? 50);
73
+ }
74
+ /**
75
+ * Publish domain patches from a commit.
76
+ * patches[i] corresponds to version baseV + i + 1.
77
+ *
78
+ * @param streamId - The event store stream ID
79
+ * @param state - Full state with `_v` set from `snap.event.version`
80
+ * @param patches - Array of domain patches, one per emitted event
81
+ */
82
+ publish(streamId, state, patches = []) {
83
+ this.stateCache.set(streamId, state);
84
+ const baseV = state._v - patches.length;
85
+ const msg = {};
86
+ patches.forEach((p, i) => {
87
+ msg[baseV + i + 1] = p;
88
+ });
89
+ const subs = this.channels.get(streamId);
90
+ if (subs?.size) {
91
+ for (const cb of subs) cb(msg);
92
+ }
93
+ return msg;
94
+ }
95
+ /**
96
+ * Publish a state update that doesn't change the event version
97
+ * (e.g. presence overlay, computed field refresh).
98
+ * Uses the same version as the cached state, single entry.
99
+ */
100
+ publishOverlay(streamId, overlayPatch) {
101
+ const prev = this.stateCache.get(streamId);
102
+ if (!prev) return void 0;
103
+ const state = applyPatch(prev, overlayPatch);
104
+ this.stateCache.set(streamId, state);
105
+ const msg = { [state._v]: overlayPatch };
106
+ const subs = this.channels.get(streamId);
107
+ if (subs?.size) {
108
+ for (const cb of subs) cb(msg);
109
+ }
110
+ return msg;
111
+ }
112
+ /**
113
+ * Subscribe to broadcast messages for a stream.
114
+ * Returns a cleanup function that removes the subscription.
115
+ */
116
+ subscribe(streamId, cb) {
117
+ if (!this.channels.has(streamId)) this.channels.set(streamId, /* @__PURE__ */ new Set());
118
+ this.channels.get(streamId).add(cb);
119
+ return () => {
120
+ this.channels.get(streamId)?.delete(cb);
121
+ if (this.channels.get(streamId)?.size === 0) {
122
+ this.channels.delete(streamId);
123
+ }
124
+ };
125
+ }
126
+ /** Get the number of subscribers for a stream. */
127
+ getSubscriberCount(streamId) {
128
+ return this.channels.get(streamId)?.size ?? 0;
129
+ }
130
+ /** Get the cached state for a stream (for reconnects / initial SSE yield). */
131
+ getState(streamId) {
132
+ return this.stateCache.get(streamId);
133
+ }
134
+ /** Direct access to the state cache (for app-specific reads like presence). */
135
+ get cache() {
136
+ return this.stateCache;
137
+ }
138
+ };
139
+
140
+ // src/sse/presence.ts
141
+ var PresenceTracker = class {
142
+ streams = /* @__PURE__ */ new Map();
143
+ /** Increment ref count for an identity on a stream. */
144
+ add(streamId, identityId) {
145
+ if (!this.streams.has(streamId)) this.streams.set(streamId, /* @__PURE__ */ new Map());
146
+ const counts = this.streams.get(streamId);
147
+ counts.set(identityId, (counts.get(identityId) ?? 0) + 1);
148
+ }
149
+ /** Decrement ref count. Removes the identity when count reaches 0. */
150
+ remove(streamId, identityId) {
151
+ const counts = this.streams.get(streamId);
152
+ if (!counts) return;
153
+ const n = (counts.get(identityId) ?? 1) - 1;
154
+ if (n <= 0) counts.delete(identityId);
155
+ else counts.set(identityId, n);
156
+ if (counts.size === 0) this.streams.delete(streamId);
157
+ }
158
+ /** Get the set of online identity IDs for a stream. */
159
+ getOnline(streamId) {
160
+ const counts = this.streams.get(streamId);
161
+ return counts ? new Set(counts.keys()) : /* @__PURE__ */ new Set();
162
+ }
163
+ /** Check if a specific identity is online for a stream. */
164
+ isOnline(streamId, identityId) {
165
+ return (this.streams.get(streamId)?.get(identityId) ?? 0) > 0;
166
+ }
167
+ };
168
+ export {
169
+ BroadcastChannel,
170
+ PresenceTracker,
171
+ StateCache,
172
+ applyPatchMessage,
173
+ patch
174
+ };
175
+ //# sourceMappingURL=index.js.map