@saacms/plugin-realtime 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,102 @@
1
+ /**
2
+ * @saacms/plugin-realtime — v0.3 Durable Object (cross-isolate fan-out +
3
+ * `Last-Event-ID` replay buffer + DO-side heartbeat).
4
+ *
5
+ * On Cloudflare every Worker request is a fresh isolate, so a `publish()`
6
+ * triggered by a write in isolate A never reaches an SSE subscriber held
7
+ * open in isolate B's per-instance `Map`. The Durable Object fixes this:
8
+ * the host wires `binding.idFromName("<slug>:<id>")` so every isolate that
9
+ * touches a given resource key routes to the SAME DO instance. That DO holds
10
+ * the open subscriber streams in process-local memory; one DO == one resource
11
+ * key, so no key filtering is needed inside the DO.
12
+ *
13
+ * Frame format is reused verbatim from the v0.1 path (`formatFrame` +
14
+ * `KEEPALIVE_FRAME` exported by `plugin.ts`) so a subscriber cannot tell
15
+ * whether it was served in-isolate or via the DO.
16
+ *
17
+ * v0.2 scope (still here, behaviourally UNCHANGED):
18
+ *
19
+ * - `GET` + `Accept: text/event-stream` → register the request's stream
20
+ * controller in an in-memory `Set`, return the SSE `Response` (first
21
+ * frame `:keepalive\n\n`, then `event:/id:/data:` frames).
22
+ * - `POST` with a JSON `RealtimeEvent` body → broadcast it to every
23
+ * registered controller in THIS DO, return `204`.
24
+ * - Dead-controller cleanup on `enqueue` throw + on stream `cancel`.
25
+ *
26
+ * v0.3 scope (THIS dispatch — purely additive; the no-`Last-Event-ID`
27
+ * subscribe path, the binding contract, and the frame format are untouched):
28
+ *
29
+ * 1. A bounded in-memory ring buffer. Every published event is pushed
30
+ * `{ id, frameBytes, tsMs }` BEFORE fan-out, then evicted oldest-first
31
+ * when `length > maxEvents` OR an entry's age exceeds `maxAgeMs`
32
+ * (defaults 100 / 1h).
33
+ * 2. `Last-Event-ID` replay on subscribe. A reconnecting client whose id is
34
+ * still in the buffer receives every buffered frame AFTER it (in order)
35
+ * BEFORE any live frame; an id older than the buffer (evicted) or never
36
+ * seen gets one `event: saacms-resync` frame instead so it knows to
37
+ * refetch canonical state (it cannot trust a partial gap). No
38
+ * `Last-Event-ID` ⇒ exact v0.2 behaviour (keepalive then live).
39
+ * 3. A DO-side heartbeat `setInterval` (default 30s), created lazily on the
40
+ * first subscriber and cleared when the last one leaves (no leaked timer
41
+ * in an idle DO). `stop()` clears it deterministically for tests.
42
+ *
43
+ * Retention/heartbeat wiring: the host registers the DO class with the
44
+ * Workers runtime, which constructs it with no args, so the simplest wiring
45
+ * that does NOT touch the v0.2 binding contract (`idFromName().get().fetch`)
46
+ * is: the DO defaults to 100 events / 1h / 30s and accepts an OPTIONAL
47
+ * constructor override (`new SaacmsRealtimeDO({ retention, heartbeatMs, now })`).
48
+ * Tests use that seam directly; a host that wants non-default retention
49
+ * subclasses/wraps the registered class. Threading `RealtimeOptions.retention`
50
+ * from the plugin through the synthetic binding request is deferred (it would
51
+ * require widening `RealtimeRouteContext` to forward client headers, which is
52
+ * out of v0.3's "inside the DO" scope).
53
+ *
54
+ * TODO(v0.4): `state.storage`-backed buffer so it survives DO eviction; v0.3
55
+ * is in-memory only — a DO eviction loses the buffer, and the resync frame
56
+ * is the documented coverage for that case.
57
+ */
58
+ /** Optional constructor override for retention / heartbeat / clock seam. */
59
+ export interface RealtimeDOConfig {
60
+ readonly retention?: {
61
+ readonly maxEvents?: number;
62
+ readonly maxAgeMs?: number;
63
+ };
64
+ /** DO-side heartbeat interval in ms. Default 30_000. */
65
+ readonly heartbeatMs?: number;
66
+ /**
67
+ * Clock seam. Defaults to `Date.now`. Injected by tests so age-based
68
+ * eviction is deterministic without real sleeps (scope test #5).
69
+ */
70
+ readonly now?: () => number;
71
+ }
72
+ /**
73
+ * One Durable Object instance per resource key. The host registers this class
74
+ * with the Workers runtime under the `SAACMS_REALTIME` binding (see ADR 0023
75
+ * §"Architectural lines we draw"). Structurally satisfies the
76
+ * `SaacmsRealtimeDO` contract declared in `plugin.ts` — TS is structural, so
77
+ * the matching `fetch(request): Promise<Response>` shape is the conformance;
78
+ * it is checked at every use site (`DurableObjectStubLike.fetch` delegation
79
+ * in the binding path + the test mock).
80
+ */
81
+ export declare class SaacmsRealtimeDO {
82
+ #private;
83
+ constructor(config?: RealtimeDOConfig);
84
+ fetch(request: Request): Promise<Response>;
85
+ /**
86
+ * Stop the DO-side heartbeat timer. Auto-invoked when the last subscriber
87
+ * leaves; exposed so tests can clear it deterministically (mirrors the v0.1
88
+ * publisher's `stop()`).
89
+ */
90
+ stop(): void;
91
+ /**
92
+ * Test-only debug accessor — mirrors `RealtimePublisherInternal.
93
+ * __subscriberCount` so the DO's pruning invariant can be asserted without
94
+ * poking at the private `Set`. Not part of the host-facing surface.
95
+ */
96
+ __subscriberCount(): number;
97
+ /** Test-only: is the lazy heartbeat interval currently live? */
98
+ __heartbeatActive(): boolean;
99
+ /** Test-only: current ring-buffer length (post-eviction). */
100
+ __bufferSize(): number;
101
+ }
102
+ //# sourceMappingURL=durable-object.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"durable-object.d.ts","sourceRoot":"","sources":["../src/durable-object.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAOH,4EAA4E;AAC5E,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,SAAS,CAAC,EAAE;QACnB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;QAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAC3B,CAAA;IACD,wDAAwD;IACxD,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAC5B;AAaD;;;;;;;;GAQG;AACH,qBAAa,gBAAgB;;gBAYf,MAAM,CAAC,EAAE,gBAAgB;IAO/B,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IA0JhD;;;;OAIG;IACH,IAAI,IAAI,IAAI;IAIZ;;;;OAIG;IACH,iBAAiB,IAAI,MAAM;IAI3B,gEAAgE;IAChE,iBAAiB,IAAI,OAAO;IAI5B,6DAA6D;IAC7D,YAAY,IAAI,MAAM;CAGvB"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @saacms/plugin-realtime — public surface.
3
+ *
4
+ * Opt-in SSE/Durable-Object pub-sub plugin per ADR 0023 amendment 2026-05-15.
5
+ * The framework is feature-complete WITHOUT this plugin via polling + ETag
6
+ * (the correctness layer); installing this plugin upgrades record-edit
7
+ * propagation from "≤ Cache-Control max-age" latency to sub-200ms (the
8
+ * efficiency layer).
9
+ *
10
+ * v0.1: single-isolate in-memory fan-out. v0.2: opt-in Durable Object
11
+ * cross-isolate fan-out when a `DurableObjectNamespace` binding is supplied;
12
+ * the no-binding path stays byte-for-byte behaviourally identical to v0.1.
13
+ */
14
+ export { realtime } from "./plugin.ts";
15
+ export type { RealtimeOptions, RealtimeEvent, RealtimePublisher, RealtimePublisherInternal, RealtimeRouteContext, DurableObjectIdLike, DurableObjectStubLike, DurableObjectNamespaceLike, } from "./plugin.ts";
16
+ export { SaacmsRealtimeDO } from "./durable-object.ts";
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AACtC,YAAY,EACV,eAAe,EACf,aAAa,EACb,iBAAiB,EACjB,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACnB,qBAAqB,EACrB,0BAA0B,GAC3B,MAAM,aAAa,CAAA;AAMpB,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,293 @@
1
+ // src/plugin.ts
2
+ var DEFAULT_HEARTBEAT_MS = 30000;
3
+ var KEEPALIVE_FRAME = `:keepalive
4
+
5
+ `;
6
+ var SUBSCRIBE_ROUTE = "GET /api/saacms/v1/:collection/:id/subscribe";
7
+ function resourceKey(slug, id) {
8
+ return `${slug}:${id}`;
9
+ }
10
+ function pickEventId(event) {
11
+ if ((event.type === "snapshot" || event.type === "changed") && event.etag != null) {
12
+ return event.etag;
13
+ }
14
+ return new Date().toISOString();
15
+ }
16
+ function pickEventData(event) {
17
+ return event.type === "deleted" ? { id: event.id } : event.record;
18
+ }
19
+ function formatFrame(event) {
20
+ const id = pickEventId(event);
21
+ const data = JSON.stringify(pickEventData(event));
22
+ return `event: ${event.type}
23
+ id: ${id}
24
+ data: ${data}
25
+
26
+ `;
27
+ }
28
+ function realtime(options) {
29
+ const registry = new Map;
30
+ const encoder = new TextEncoder;
31
+ const keepaliveBytes = encoder.encode(KEEPALIVE_FRAME);
32
+ const heartbeatMs = options?.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
33
+ const binding = options?.binding;
34
+ function doStub(key) {
35
+ return binding.get(binding.idFromName(key));
36
+ }
37
+ function add(key, handle) {
38
+ let set = registry.get(key);
39
+ if (set == null) {
40
+ set = new Set;
41
+ registry.set(key, set);
42
+ }
43
+ set.add(handle);
44
+ }
45
+ function remove(key, handle) {
46
+ const set = registry.get(key);
47
+ if (set == null)
48
+ return;
49
+ set.delete(handle);
50
+ if (set.size === 0)
51
+ registry.delete(key);
52
+ }
53
+ function broadcast(key, bytes) {
54
+ const set = registry.get(key);
55
+ if (set == null || set.size === 0)
56
+ return;
57
+ const dead = [];
58
+ for (const handle of set) {
59
+ try {
60
+ handle.controller.enqueue(bytes);
61
+ } catch {
62
+ dead.push(handle);
63
+ }
64
+ }
65
+ for (const h of dead)
66
+ remove(key, h);
67
+ }
68
+ function handleSubscribe(c) {
69
+ const slug = c.req.param("collection") ?? "";
70
+ const id = c.req.param("id") ?? "";
71
+ const key = resourceKey(slug, id);
72
+ if (binding != null) {
73
+ return doStub(key).fetch(new Request("https://saacms-realtime-do/subscribe", {
74
+ method: "GET",
75
+ headers: { Accept: "text/event-stream" }
76
+ }));
77
+ }
78
+ let handle;
79
+ const stream = new ReadableStream({
80
+ start(controller) {
81
+ handle = { controller };
82
+ add(key, handle);
83
+ controller.enqueue(keepaliveBytes);
84
+ },
85
+ cancel() {
86
+ if (handle != null)
87
+ remove(key, handle);
88
+ }
89
+ });
90
+ return new Response(stream, {
91
+ status: 200,
92
+ headers: {
93
+ "Content-Type": "text/event-stream",
94
+ "Cache-Control": "no-cache, no-transform",
95
+ Connection: "keep-alive"
96
+ }
97
+ });
98
+ }
99
+ const publisher = {
100
+ async publish(event) {
101
+ const key = resourceKey(event.slug, event.id);
102
+ if (binding != null) {
103
+ await doStub(key).fetch(new Request("https://saacms-realtime-do/publish", {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/json" },
106
+ body: JSON.stringify(event)
107
+ }));
108
+ return;
109
+ }
110
+ const bytes = encoder.encode(formatFrame(event));
111
+ broadcast(key, bytes);
112
+ },
113
+ stop() {
114
+ clearInterval(heartbeatTimer);
115
+ },
116
+ __subscriberCount(slug, id) {
117
+ return registry.get(resourceKey(slug, id))?.size ?? 0;
118
+ }
119
+ };
120
+ const heartbeatTimer = setInterval(() => {
121
+ for (const key of registry.keys())
122
+ broadcast(key, keepaliveBytes);
123
+ }, heartbeatMs);
124
+ heartbeatTimer.unref?.();
125
+ return {
126
+ name: "@saacms/plugin-realtime",
127
+ version: "0.1.0",
128
+ routes: {
129
+ [SUBSCRIBE_ROUTE]: handleSubscribe
130
+ },
131
+ services: {
132
+ realtime: publisher
133
+ }
134
+ };
135
+ }
136
+ // src/durable-object.ts
137
+ var DEFAULT_MAX_EVENTS = 100;
138
+ var DEFAULT_MAX_AGE_MS = 3600000;
139
+ var DEFAULT_HEARTBEAT_MS2 = 30000;
140
+
141
+ class SaacmsRealtimeDO {
142
+ #subscribers = new Set;
143
+ #encoder = new TextEncoder;
144
+ #keepaliveBytes = this.#encoder.encode(KEEPALIVE_FRAME);
145
+ #buffer = [];
146
+ #maxEvents;
147
+ #maxAgeMs;
148
+ #heartbeatMs;
149
+ #now;
150
+ #heartbeatTimer;
151
+ constructor(config) {
152
+ this.#maxEvents = config?.retention?.maxEvents ?? DEFAULT_MAX_EVENTS;
153
+ this.#maxAgeMs = config?.retention?.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
154
+ this.#heartbeatMs = config?.heartbeatMs ?? DEFAULT_HEARTBEAT_MS2;
155
+ this.#now = config?.now ?? Date.now;
156
+ }
157
+ async fetch(request) {
158
+ if (request.method === "POST") {
159
+ return this.#handlePublish(request);
160
+ }
161
+ const accept = request.headers.get("Accept") ?? "";
162
+ if (request.method === "GET" && accept.includes("text/event-stream")) {
163
+ return this.#handleSubscribe(request);
164
+ }
165
+ return new Response("Not Found", { status: 404 });
166
+ }
167
+ #idFromFrame(frame) {
168
+ return frame.split(`
169
+ `, 2)[1].slice("id: ".length);
170
+ }
171
+ #prune() {
172
+ const cutoff = this.#now() - this.#maxAgeMs;
173
+ while (this.#buffer.length > 0 && this.#buffer[0].tsMs < cutoff) {
174
+ this.#buffer.shift();
175
+ }
176
+ while (this.#buffer.length > this.#maxEvents) {
177
+ this.#buffer.shift();
178
+ }
179
+ }
180
+ #resyncFrame() {
181
+ const newest = this.#buffer.length > 0 ? this.#buffer[this.#buffer.length - 1].id : new Date(this.#now()).toISOString();
182
+ return `event: saacms-resync
183
+ id: ${newest}
184
+ data: {"reason":"buffer-miss"}
185
+
186
+ `;
187
+ }
188
+ #removeSubscriber(controller) {
189
+ this.#subscribers.delete(controller);
190
+ if (this.#subscribers.size === 0)
191
+ this.#stopHeartbeat();
192
+ }
193
+ #ensureHeartbeat() {
194
+ if (this.#heartbeatTimer != null || this.#subscribers.size === 0)
195
+ return;
196
+ this.#heartbeatTimer = setInterval(() => {
197
+ const dead = [];
198
+ for (const controller of this.#subscribers) {
199
+ try {
200
+ controller.enqueue(this.#keepaliveBytes);
201
+ } catch {
202
+ dead.push(controller);
203
+ }
204
+ }
205
+ for (const c of dead)
206
+ this.#removeSubscriber(c);
207
+ }, this.#heartbeatMs);
208
+ this.#heartbeatTimer.unref?.();
209
+ }
210
+ #stopHeartbeat() {
211
+ if (this.#heartbeatTimer != null) {
212
+ clearInterval(this.#heartbeatTimer);
213
+ this.#heartbeatTimer = undefined;
214
+ }
215
+ }
216
+ #handleSubscribe(request) {
217
+ const lastEventId = request.headers.get("Last-Event-ID");
218
+ let replayFrames = [];
219
+ let resyncBytes;
220
+ if (lastEventId != null) {
221
+ this.#prune();
222
+ const idx = this.#buffer.findIndex((e) => e.id === lastEventId);
223
+ if (idx === -1) {
224
+ resyncBytes = this.#encoder.encode(this.#resyncFrame());
225
+ } else {
226
+ replayFrames = this.#buffer.slice(idx + 1).map((e) => e.frameBytes);
227
+ }
228
+ }
229
+ let controllerRef;
230
+ const stream = new ReadableStream({
231
+ start: (controller) => {
232
+ controllerRef = controller;
233
+ controller.enqueue(this.#keepaliveBytes);
234
+ if (resyncBytes != null)
235
+ controller.enqueue(resyncBytes);
236
+ for (const frame of replayFrames)
237
+ controller.enqueue(frame);
238
+ this.#subscribers.add(controller);
239
+ this.#ensureHeartbeat();
240
+ },
241
+ cancel: () => {
242
+ if (controllerRef != null)
243
+ this.#removeSubscriber(controllerRef);
244
+ }
245
+ });
246
+ return new Response(stream, {
247
+ status: 200,
248
+ headers: {
249
+ "Content-Type": "text/event-stream",
250
+ "Cache-Control": "no-cache, no-transform",
251
+ Connection: "keep-alive"
252
+ }
253
+ });
254
+ }
255
+ async#handlePublish(request) {
256
+ const event = await request.json();
257
+ const frame = formatFrame(event);
258
+ const frameBytes = this.#encoder.encode(frame);
259
+ this.#buffer.push({
260
+ id: this.#idFromFrame(frame),
261
+ frameBytes,
262
+ tsMs: this.#now()
263
+ });
264
+ this.#prune();
265
+ const dead = [];
266
+ for (const controller of this.#subscribers) {
267
+ try {
268
+ controller.enqueue(frameBytes);
269
+ } catch {
270
+ dead.push(controller);
271
+ }
272
+ }
273
+ for (const c of dead)
274
+ this.#removeSubscriber(c);
275
+ return new Response(null, { status: 204 });
276
+ }
277
+ stop() {
278
+ this.#stopHeartbeat();
279
+ }
280
+ __subscriberCount() {
281
+ return this.#subscribers.size;
282
+ }
283
+ __heartbeatActive() {
284
+ return this.#heartbeatTimer != null;
285
+ }
286
+ __bufferSize() {
287
+ return this.#buffer.length;
288
+ }
289
+ }
290
+ export {
291
+ realtime,
292
+ SaacmsRealtimeDO
293
+ };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @saacms/plugin-realtime — v0.1 (single-isolate, in-memory).
3
+ *
4
+ * Ships the contract end-to-end on a single Workers/Bun/Node isolate so the
5
+ * wiring + SSE frame format are proven before the v0.2 cross-isolate
6
+ * Durable-Object fan-out lands. The v0.2 dispatch swaps the in-memory
7
+ * `Map<resourceKey, Set<SubscriberHandle>>` for a `SAACMS_REALTIME`
8
+ * Durable Object stub bound in `wrangler.toml` per ADR 0023 §"Architectural
9
+ * lines we draw" + ADR 0024 §"Platform-specific enhancement layer".
10
+ *
11
+ * v0.1 scope (this file):
12
+ *
13
+ * 1. Per-plugin-instance subscriber registry closed over by `realtime()`.
14
+ * State is NOT module-level — two instances created in the same test
15
+ * file have independent registries. This is the test #11 invariant.
16
+ * 2. `GET /api/saacms/v1/<slug>/<id>/subscribe` SSE handler that returns a
17
+ * `text/event-stream` ReadableStream. Registers on `start`, unregisters
18
+ * on `cancel`. First frame is `:keepalive\n\n` per RFC 8895 §6 comment
19
+ * lines so clients see the connection is live before any record event.
20
+ * 3. `services.realtime.publish(event)` — looks up subscribers by
21
+ * `"<slug>:<id>"` and writes one SSE frame per subscriber. Failed
22
+ * enqueues (controller closed) drop the subscriber from the registry.
23
+ * 4. Single `setInterval(heartbeatMs)` per instance walks the registry
24
+ * and writes `:keepalive\n\n` to every open subscriber. Default
25
+ * 30s; tests inject a small value via `opts.heartbeatMs`. The timer
26
+ * is `.unref()`-ed so it never keeps the Bun/Node process alive.
27
+ *
28
+ * Plugin `routes` shape (test #10 + future runtime adoption):
29
+ *
30
+ * ```ts
31
+ * routes: {
32
+ * "GET /api/saacms/v1/:collection/:id/subscribe": (c) => Response
33
+ * }
34
+ * ```
35
+ *
36
+ * One method-plus-path key, one Hono `Context => Response | Promise<Response>`
37
+ * handler value. The runtime's plugin-route adoption (a separate dispatch)
38
+ * parses the `"<METHOD> <path>"` key and calls `app[method.toLowerCase()]`.
39
+ * Tests mount the handler manually on a fresh `new Hono()` and exercise it.
40
+ *
41
+ * v0.2 (THIS dispatch) adds — purely additive, the v0.1 path above is
42
+ * untouched and remains the fallback when no DO binding is supplied:
43
+ *
44
+ * - Cloudflare Durable Object class (`SaacmsRealtimeDO`, in the sibling
45
+ * `durable-object.ts`) for cross-isolate fan-out; one DO instance per
46
+ * resource key holds the open request handles.
47
+ * - A binding-presence branch in `realtime()`: when `options.binding` is
48
+ * supplied the subscribe handler + `publish()` route through
49
+ * `binding.idFromName("<slug>:<id>").get(...).fetch(...)`; when it is
50
+ * absent the v0.1 in-isolate path runs UNCHANGED (no-platform-dependency
51
+ * guarantee, ADR 0023 amendment 2026-05-15).
52
+ *
53
+ * Deferred to v0.3 (documented TODOs, not built here):
54
+ *
55
+ * - `Last-Event-ID` replay from a DO `state.storage` buffer (RFC 8895 §4).
56
+ * v0.2's DO is in-memory `Set` only.
57
+ * - A DO-side heartbeat. v0.2's DO does NOT run its own keepalive interval;
58
+ * this is acceptable because Cloudflare terminates idle SSE itself and
59
+ * the client `EventSource` auto-reconnects. The in-isolate path keeps its
60
+ * `setInterval` heartbeat (below) unchanged.
61
+ * - OpenAPI spec extender registering the subscribe path.
62
+ * - HATEOAS `_links.subscribe` injection into resource envelopes.
63
+ * - Per-record access-predicate evaluation on the subscription request +
64
+ * per-frame access re-check on publish (ADR 0006 §2 grain).
65
+ */
66
+ import type { PluginDef } from "@saacms/core";
67
+ /**
68
+ * Structural shape of the only Hono Context member the handler reads
69
+ * (`c.req.param("collection")`, `c.req.param("id")`). Typed structurally so the
70
+ * plugin doesn't take a runtime dep on `hono` — Hono's own Context satisfies
71
+ * this shape via duck typing when the runtime mounts the route.
72
+ */
73
+ export interface RealtimeRouteContext {
74
+ readonly req: {
75
+ param(name: string): string | undefined;
76
+ };
77
+ }
78
+ /**
79
+ * Structural mirror of the Workers `DurableObjectId`. Defined locally so the
80
+ * plugin takes NO runtime/type dep on `@cloudflare/workers-types` — exactly
81
+ * how `@saacms/plugin-cache-kv` mirrors `KVNamespace`. The host's real
82
+ * `DurableObjectId` satisfies this via duck typing.
83
+ */
84
+ export interface DurableObjectIdLike {
85
+ toString(): string;
86
+ readonly name?: string;
87
+ }
88
+ /** Structural mirror of the Workers `DurableObjectStub` (only `fetch`). */
89
+ export interface DurableObjectStubLike {
90
+ fetch(request: Request): Promise<Response>;
91
+ }
92
+ /** Structural mirror of the Workers `DurableObjectNamespace` binding. */
93
+ export interface DurableObjectNamespaceLike {
94
+ idFromName(name: string): DurableObjectIdLike;
95
+ get(id: DurableObjectIdLike): DurableObjectStubLike;
96
+ }
97
+ export interface RealtimeOptions {
98
+ /**
99
+ * The Workers `DurableObjectNamespace` binding for cross-isolate fan-out
100
+ * (v0.2). When supplied, subscribe + publish route through one DO per
101
+ * resource key. When OMITTED, the v0.1 in-isolate path runs unchanged —
102
+ * the no-platform-dependency guarantee (ADR 0023 amendment 2026-05-15).
103
+ */
104
+ readonly binding?: DurableObjectNamespaceLike;
105
+ /** Override the Durable Object binding name. Default: SAACMS_REALTIME. */
106
+ readonly bindingName?: string;
107
+ /** Per-resource event-buffer retention. Defaults: 100 events / 1h. */
108
+ readonly retention?: {
109
+ readonly maxEvents?: number;
110
+ readonly maxAgeMs?: number;
111
+ };
112
+ /**
113
+ * Heartbeat interval in ms. Default 30_000. Override in tests so the
114
+ * `:keepalive\n\n` walk can be asserted without real-time waits.
115
+ */
116
+ readonly heartbeatMs?: number;
117
+ }
118
+ /** A single record-lifecycle event the plugin fans out. */
119
+ export type RealtimeEvent = {
120
+ readonly type: "snapshot";
121
+ readonly slug: string;
122
+ readonly id: string;
123
+ readonly record: unknown;
124
+ readonly etag?: string;
125
+ } | {
126
+ readonly type: "changed";
127
+ readonly slug: string;
128
+ readonly id: string;
129
+ readonly record: unknown;
130
+ readonly etag?: string;
131
+ } | {
132
+ readonly type: "deleted";
133
+ readonly slug: string;
134
+ readonly id: string;
135
+ };
136
+ /** Publisher API the runtime calls to fan out a domain event. */
137
+ export interface RealtimePublisher {
138
+ publish(event: RealtimeEvent): Promise<void>;
139
+ /**
140
+ * Stop the heartbeat timer. Tests call this in `afterEach` so the suite
141
+ * exits cleanly; production code may call it on host shutdown.
142
+ */
143
+ stop(): void;
144
+ }
145
+ /**
146
+ * Internal debug accessor exposed for tests to verify registry-state
147
+ * invariants (e.g. that cancelled subscribers are removed; that two plugin
148
+ * instances do not share state). Not part of the public surface — cast to
149
+ * `RealtimePublisherInternal` only in test code.
150
+ */
151
+ export interface RealtimePublisherInternal extends RealtimePublisher {
152
+ __subscriberCount(slug: string, id: string): number;
153
+ }
154
+ /** The Durable Object class shape the host registers with the Workers runtime. */
155
+ export interface SaacmsRealtimeDO {
156
+ fetch(request: Request): Promise<Response>;
157
+ }
158
+ /** Exported so the v0.2 Durable Object reuses the exact v0.1 frame format. */
159
+ export declare const KEEPALIVE_FRAME = ":keepalive\n\n";
160
+ /** Exported so the v0.2 Durable Object reuses the exact v0.1 frame format. */
161
+ export declare function formatFrame(event: RealtimeEvent): string;
162
+ /**
163
+ * Build a `@saacms/plugin-realtime` instance. State is closed over by the
164
+ * returned object so two `realtime()` calls produce independent registries
165
+ * (test #11). Call `services.realtime.stop()` to clear the heartbeat timer.
166
+ */
167
+ export declare function realtime(options?: RealtimeOptions): PluginDef;
168
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@saacms/plugin-realtime",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "import": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc --build",
18
+ "typecheck": "tsc --build --noEmit",
19
+ "prepack": "cp package.json package.json.pack-bak && bun run ../../scripts/prepack-pkg.ts",
20
+ "postpack": "mv package.json.pack-bak package.json"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "@saacms/core": "workspace:*",
27
+ "@cloudflare/workers-types": "^4.20240925.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/bun": "latest",
31
+ "typescript": "^5.7.0"
32
+ },
33
+ "main": "./dist/index.js",
34
+ "types": "./dist/index.d.ts"
35
+ }