@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.
- package/dist/durable-object.d.ts +102 -0
- package/dist/durable-object.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +293 -0
- package/dist/plugin.d.ts +168 -0
- package/dist/plugin.d.ts.map +1 -0
- package/package.json +35 -0
|
@@ -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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/plugin.d.ts
ADDED
|
@@ -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
|
+
}
|