@statewalker/webrun-http-events 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,192 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { BrokerEvent } from "./broker.js";
3
+ import { newBroker } from "./broker.js";
4
+
5
+ describe("newBroker", () => {
6
+ it("delivers a published event to a subscriber on the same topic", () => {
7
+ const broker = newBroker();
8
+ const seen: unknown[] = [];
9
+ broker.subscribe("build", (e) => seen.push(e.data));
10
+ broker.publish("build", { changed: ["/a.html"] });
11
+ expect(seen).toEqual([{ changed: ["/a.html"] }]);
12
+ });
13
+
14
+ it("does not deliver across topics", () => {
15
+ const broker = newBroker();
16
+ const seen: unknown[] = [];
17
+ broker.subscribe("build", (e) => seen.push(e.data));
18
+ broker.publish("other", { changed: [] });
19
+ expect(seen).toEqual([]);
20
+ });
21
+
22
+ it("assigns monotonically increasing ids per topic", () => {
23
+ const broker = newBroker();
24
+ const a = broker.publish("t", 1);
25
+ const b = broker.publish("t", 2);
26
+ expect(b.id).toBeGreaterThan(a.id);
27
+ });
28
+
29
+ // (2) a publish with no subscribers must not throw and must still advance the id
30
+ it("advances the id when nobody is listening", () => {
31
+ const broker = newBroker();
32
+ const first = broker.publish("quiet", "a");
33
+ const second = broker.publish("quiet", "b");
34
+ expect(second.id).toBe(first.id + 1);
35
+ const { events } = broker.replay("quiet", first.id);
36
+ expect(events.map((e) => e.data)).toEqual(["b"]);
37
+ });
38
+ });
39
+
40
+ describe("newBroker — concurrency and lifecycle", () => {
41
+ // (5) S2 proved exactly one subscriber; a single-callback broker passes every such test
42
+ it("delivers to every subscriber on a topic", () => {
43
+ const broker = newBroker();
44
+ const a: unknown[] = [];
45
+ const b: unknown[] = [];
46
+ broker.subscribe("t", (e) => a.push(e.data));
47
+ broker.subscribe("t", (e) => b.push(e.data));
48
+ broker.publish("t", "x");
49
+ expect(a).toEqual(["x"]);
50
+ expect(b).toEqual(["x"]);
51
+ expect(broker.subscriberCount("t")).toBe(2);
52
+ });
53
+
54
+ // (1) a disconnecting subscriber must be dropped, or a long-lived server leaks
55
+ it("releases one subscriber's slot without disturbing the other", () => {
56
+ const broker = newBroker();
57
+ const a: unknown[] = [];
58
+ const b: unknown[] = [];
59
+ const offA = broker.subscribe("t", (e) => a.push(e.data));
60
+ broker.subscribe("t", (e) => b.push(e.data));
61
+ expect(broker.subscriberCount("t")).toBe(2);
62
+
63
+ broker.publish("t", "before");
64
+ offA();
65
+ broker.publish("t", "after");
66
+
67
+ expect(a).toEqual(["before"]);
68
+ expect(b).toEqual(["before", "after"]);
69
+ expect(broker.subscriberCount("t")).toBe(1);
70
+ });
71
+
72
+ it("tolerates a subscriber unsubscribing during delivery", () => {
73
+ const broker = newBroker();
74
+ const seen: unknown[] = [];
75
+ const off = broker.subscribe("t", () => off());
76
+ broker.subscribe("t", (e) => seen.push(e.data));
77
+ expect(broker.subscriberCount("t")).toBe(2);
78
+
79
+ expect(() => broker.publish("t", "x")).not.toThrow();
80
+
81
+ expect(seen).toEqual(["x"]);
82
+ expect(broker.subscriberCount("t")).toBe(1); // the self-unsubscriber removed itself
83
+ });
84
+
85
+ // Pins the `[...s.subscribers]` copy in publish(). Self-unsubscribe is safe in
86
+ // every engine, so it proves nothing; only removing a NOT-YET-VISITED entry does.
87
+ it("delivers to a not-yet-visited subscriber even if an earlier callback unsubscribes it", () => {
88
+ const broker = newBroker();
89
+ const seenC: unknown[] = [];
90
+ let offC!: () => void;
91
+ broker.subscribe("t", () => offC()); // A: runs first, unsubscribes C
92
+ broker.subscribe("t", () => {}); // B: filler, keeps C unvisited when A runs
93
+ offC = broker.subscribe("t", (e) => seenC.push(e.data)); // C
94
+ broker.publish("t", "x");
95
+ expect(seenC).toEqual(["x"]); // only true because publish snapshots up front
96
+ });
97
+ });
98
+
99
+ describe("newBroker — a failing subscriber", () => {
100
+ // C-1: a bare `for … fn(e)` lets the first throwing subscriber abort delivery to
101
+ // everyone after it and propagate the throw out of publish().
102
+ it("isolates a throwing subscriber from the others and from the publisher", () => {
103
+ const errors: unknown[] = [];
104
+ const broker = newBroker({ onSubscriberError: (err) => errors.push(err) });
105
+ const seen: unknown[] = [];
106
+ broker.subscribe("t", () => {
107
+ throw new Error("boom");
108
+ });
109
+ broker.subscribe("t", (e) => seen.push(e.data));
110
+
111
+ expect(() => broker.publish("t", "x")).not.toThrow();
112
+
113
+ expect(seen).toEqual(["x"]); // the second subscriber was not starved
114
+ expect(errors).toHaveLength(1);
115
+ });
116
+ });
117
+
118
+ describe("newBroker — replay completeness", () => {
119
+ // C-2: the ceiling. A client whose id came from a previous server epoch asks for
120
+ // events above the newest id ever issued; reporting `complete` there tells a stale
121
+ // page it is current, and onGap never fires.
122
+ it("reports an incomplete history when afterId is above the newest id", () => {
123
+ const broker = newBroker();
124
+ broker.publish("t", "a");
125
+ broker.publish("t", "b");
126
+ expect(broker.replay("t", 42)).toEqual({ events: [], complete: false });
127
+ });
128
+
129
+ it("treats an unknown topic as complete only for a client with no history", () => {
130
+ const broker = newBroker();
131
+ expect(broker.replay("absent", 0)).toEqual({ events: [], complete: true });
132
+ expect(broker.replay("absent", 7)).toEqual({ events: [], complete: false });
133
+ });
134
+
135
+ // m-8: with an empty ring every replay used to report complete, disabling the contract.
136
+ it("retains nothing with bufferSize 0, so only a level client is complete", () => {
137
+ const broker = newBroker({ bufferSize: 0 });
138
+ broker.publish("t", "a");
139
+ broker.publish("t", "b");
140
+ expect(broker.replay("t", 0).complete).toBe(false);
141
+ expect(broker.replay("t", 1).complete).toBe(false);
142
+ expect(broker.replay("t", 2)).toEqual({ events: [], complete: true });
143
+ expect(broker.replay("t", 3).complete).toBe(false);
144
+ });
145
+
146
+ it("is complete at the first retained event and incomplete one event earlier", () => {
147
+ const broker = newBroker({ bufferSize: 2 });
148
+ for (const d of ["a", "b", "c"]) broker.publish("t", d); // ids 1..3; the ring holds 2,3
149
+ const at = broker.replay("t", 1);
150
+ expect(at.complete).toBe(true);
151
+ expect(at.events.map((e) => e.data)).toEqual(["b", "c"]);
152
+ expect(broker.replay("t", 0).complete).toBe(false); // id 1 was evicted
153
+ });
154
+ });
155
+
156
+ describe("newBroker — subscribe's shape", () => {
157
+ // I-3: the lastEventId parameter discarded `complete` (the C-2 miss, on the public
158
+ // interface) and reached replay through `this`, so a destructured subscribe threw.
159
+ it("takes exactly two arguments, never replays, and does not depend on `this`", () => {
160
+ const { subscribe, publish, subscriberCount } = newBroker();
161
+ publish("t", "before");
162
+
163
+ const seen: unknown[] = [];
164
+ const loose = subscribe as unknown as (...args: unknown[]) => () => void;
165
+ const off = loose("t", (e: BrokerEvent) => seen.push(e.data), 0);
166
+
167
+ expect(seen).toEqual([]); // no replay on subscribe: callers use replay() and read `complete`
168
+ expect(subscribe.length).toBe(2);
169
+ publish("t", "after");
170
+ expect(seen).toEqual(["after"]);
171
+ off();
172
+ expect(subscriberCount("t")).toBe(0);
173
+ });
174
+
175
+ // I-6: an entry created by a touch that left nothing behind must not outlive it.
176
+ it("drops a topic once it has no subscribers and nothing retained", () => {
177
+ const broker = newBroker();
178
+ const off = broker.subscribe("ghost", () => {});
179
+ expect(broker.topicCount()).toBe(1);
180
+ off();
181
+ expect(broker.topicCount()).toBe(0);
182
+ });
183
+
184
+ it("keeps a topic that still has retained events", () => {
185
+ const broker = newBroker();
186
+ const off = broker.subscribe("t", () => {});
187
+ broker.publish("t", "a");
188
+ off();
189
+ expect(broker.topicCount()).toBe(1);
190
+ expect(broker.replay("t", 0).events.map((e) => e.data)).toEqual(["a"]);
191
+ });
192
+ });
package/src/broker.ts ADDED
@@ -0,0 +1,121 @@
1
+ export type BrokerEvent = { id: number; event?: string; data: unknown };
2
+ export type Subscriber = (e: BrokerEvent) => void;
3
+ export type Unsubscribe = () => void;
4
+
5
+ export interface Broker {
6
+ /**
7
+ * Deliver `data` to every subscriber of `topic` and retain it for replay.
8
+ *
9
+ * Delivery is synchronous and each subscriber is isolated: one that throws is
10
+ * reported to `onSubscriberError` and cannot starve the others or the caller.
11
+ *
12
+ * Publishing from inside a subscriber (a reentrant publish) is NOT supported:
13
+ * the inner event is delivered to completion before the outer one finishes, so
14
+ * subscribers observe ids out of order and a client's Last-Event-ID can move
15
+ * backwards. Publish from a fresh task instead.
16
+ */
17
+ publish(topic: string, data: unknown, event?: string): BrokerEvent;
18
+ /** Register `fn` for future events on `topic`. Use {@link Broker.replay} for history. */
19
+ subscribe(topic: string, fn: Subscriber): Unsubscribe;
20
+ /**
21
+ * Events retained after `afterId`, plus whether that is the whole story.
22
+ * `complete: false` means the caller's history cannot be reconstructed — either
23
+ * the events it wants were evicted, or its `afterId` is above anything this
24
+ * broker has issued (a client left over from a previous process).
25
+ */
26
+ replay(topic: string, afterId: number): { events: BrokerEvent[]; complete: boolean };
27
+ subscriberCount(topic: string): number;
28
+ /** How many topics currently hold state. Topics with no subscribers and no retained events are dropped. */
29
+ topicCount(): number;
30
+ }
31
+
32
+ export interface BrokerOptions {
33
+ /** How many recent events to retain per topic for Last-Event-ID replay. */
34
+ bufferSize?: number;
35
+ /**
36
+ * Called when a subscriber throws. Delivery continues either way; without this
37
+ * the failure would be invisible.
38
+ */
39
+ onSubscriberError?: (error: unknown, e: BrokerEvent) => void;
40
+ }
41
+
42
+ type TopicState = {
43
+ nextId: number;
44
+ /** Bounded ring of recent events, oldest first. */
45
+ buffer: BrokerEvent[];
46
+ subscribers: Set<Subscriber>;
47
+ };
48
+
49
+ export function newBroker({ bufferSize = 64, onSubscriberError }: BrokerOptions = {}): Broker {
50
+ const topics = new Map<string, TopicState>();
51
+
52
+ const state = (topic: string): TopicState => {
53
+ let s = topics.get(topic);
54
+ if (!s) {
55
+ s = { nextId: 1, buffer: [], subscribers: new Set() };
56
+ topics.set(topic, s);
57
+ }
58
+ return s;
59
+ };
60
+
61
+ /** Drop a topic that holds nothing, so request-driven names cannot accumulate. */
62
+ const prune = (topic: string, s: TopicState): void => {
63
+ if (topics.get(topic) === s && s.subscribers.size === 0 && s.buffer.length === 0) {
64
+ topics.delete(topic);
65
+ }
66
+ };
67
+
68
+ const replay: Broker["replay"] = (topic, afterId) => {
69
+ const s = topics.get(topic);
70
+ // An absent topic has issued nothing: only a caller with no history is current.
71
+ if (!s) return { events: [], complete: afterId <= 0 };
72
+ const events = s.buffer.filter((e) => e.id > afterId);
73
+ const latest = s.nextId - 1;
74
+ // An empty ring retains nothing, so nothing before `nextId` can be reconstructed.
75
+ const oldest = s.buffer[0]?.id ?? s.nextId;
76
+ // Incomplete below, when the requested point predates the oldest retained event,
77
+ // and above, when it names an id this broker never issued.
78
+ return { events, complete: afterId >= oldest - 1 && afterId <= latest };
79
+ };
80
+
81
+ return {
82
+ publish(topic, data, event) {
83
+ const s = state(topic);
84
+ const e: BrokerEvent = { id: s.nextId++, data, ...(event === undefined ? {} : { event }) };
85
+ s.buffer.push(e);
86
+ if (s.buffer.length > bufferSize) s.buffer.shift();
87
+ // Copy before iterating: a subscriber may unsubscribe during delivery.
88
+ for (const fn of [...s.subscribers]) {
89
+ try {
90
+ fn(e);
91
+ } catch (error) {
92
+ try {
93
+ onSubscriberError?.(error, e);
94
+ } catch {
95
+ // A reporter that throws must not starve the remaining subscribers either.
96
+ }
97
+ }
98
+ }
99
+ return e;
100
+ },
101
+
102
+ subscribe(topic, fn) {
103
+ const s = state(topic);
104
+ s.subscribers.add(fn);
105
+ return () => {
106
+ s.subscribers.delete(fn);
107
+ prune(topic, s);
108
+ };
109
+ },
110
+
111
+ replay,
112
+
113
+ subscriberCount(topic) {
114
+ return topics.get(topic)?.subscribers.size ?? 0;
115
+ },
116
+
117
+ topicCount() {
118
+ return topics.size;
119
+ },
120
+ };
121
+ }
@@ -0,0 +1,216 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { newPubSubClient } from "./client.js";
3
+
4
+ /** Minimal EventSource stand-in; vitest runs under Node, which has no DOM one. */
5
+ class FakeEventSource {
6
+ static instances: FakeEventSource[] = [];
7
+ listeners = new Map<string, Set<(e: MessageEvent) => void>>();
8
+ closed = false;
9
+ constructor(public url: string) {
10
+ FakeEventSource.instances.push(this);
11
+ }
12
+ addEventListener(type: string, fn: (e: MessageEvent) => void) {
13
+ let s = this.listeners.get(type);
14
+ if (!s) {
15
+ s = new Set();
16
+ this.listeners.set(type, s);
17
+ }
18
+ s.add(fn);
19
+ }
20
+ removeEventListener(type: string, fn: (e: MessageEvent) => void) {
21
+ this.listeners.get(type)?.delete(fn);
22
+ }
23
+ close() {
24
+ this.closed = true;
25
+ }
26
+ emit(type: string, data: string, lastEventId = "1") {
27
+ for (const fn of this.listeners.get(type) ?? []) {
28
+ fn({ type, data, lastEventId } as MessageEvent);
29
+ }
30
+ }
31
+ /** A bare Event, as `error` and `open` are dispatched. */
32
+ emitBare(type: string) {
33
+ for (const fn of this.listeners.get(type) ?? []) {
34
+ fn({ type } as MessageEvent);
35
+ }
36
+ }
37
+ listenerTypes() {
38
+ return [...this.listeners]
39
+ .filter(([, fns]) => fns.size > 0)
40
+ .map(([type]) => type)
41
+ .sort();
42
+ }
43
+ }
44
+
45
+ describe("newPubSubClient", () => {
46
+ it("opens one EventSource per topic at the right URL", () => {
47
+ FakeEventSource.instances.length = 0;
48
+ const client = newPubSubClient("http://h/_events", {
49
+ EventSourceImpl: FakeEventSource as unknown as typeof EventSource,
50
+ });
51
+ client.subscribe("build", () => {});
52
+ expect(FakeEventSource.instances).toHaveLength(1);
53
+ expect(FakeEventSource.instances[0]!.url).toBe("http://h/_events/build");
54
+ });
55
+
56
+ it("parses the JSON payload and reports the event id", () => {
57
+ FakeEventSource.instances.length = 0;
58
+ const client = newPubSubClient("http://h/_events", {
59
+ EventSourceImpl: FakeEventSource as unknown as typeof EventSource,
60
+ });
61
+ const seen: Array<[unknown, number]> = [];
62
+ client.subscribe("build", (data, e) => seen.push([data, e.id]));
63
+ FakeEventSource.instances[0]!.emit("message", '{"changed":["/a.html"]}', "4");
64
+ expect(seen).toEqual([[{ changed: ["/a.html"] }, 4]]);
65
+ });
66
+
67
+ it("closes the EventSource on unsubscribe", () => {
68
+ FakeEventSource.instances.length = 0;
69
+ const client = newPubSubClient("http://h/_events", {
70
+ EventSourceImpl: FakeEventSource as unknown as typeof EventSource,
71
+ });
72
+ const off = client.subscribe("build", () => {});
73
+ off();
74
+ expect(FakeEventSource.instances[0]!.closed).toBe(true);
75
+ });
76
+
77
+ // (4) the client half of the gap contract
78
+ it("routes a gap event to onGap and not to the subscriber", () => {
79
+ FakeEventSource.instances.length = 0;
80
+ const onGap = vi.fn();
81
+ const client = newPubSubClient("http://h/_events", {
82
+ EventSourceImpl: FakeEventSource as unknown as typeof EventSource,
83
+ onGap,
84
+ });
85
+ const seen: unknown[] = [];
86
+ client.subscribe("build", (d) => seen.push(d));
87
+ FakeEventSource.instances[0]!.emit("gap", '{"from":1}');
88
+ expect(onGap).toHaveBeenCalledWith("build");
89
+ expect(seen).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe("newPubSubClient — event names", () => {
94
+ const newClient = (options: Record<string, unknown> = {}) => {
95
+ FakeEventSource.instances.length = 0;
96
+ return newPubSubClient("http://h/_events", {
97
+ EventSourceImpl: FakeEventSource as unknown as typeof EventSource,
98
+ ...options,
99
+ });
100
+ };
101
+
102
+ // I-4: per the SSE spec a frame with `event: X` dispatches only as type X, so the
103
+ // three hardcoded listeners silently dropped every other name — and one of them
104
+ // was "rebuilt", notebook vocabulary in a generic package.
105
+ it("listens for `message` and nothing else by default", () => {
106
+ const client = newClient();
107
+ client.subscribe("build", () => {});
108
+ expect(FakeEventSource.instances[0]!.listenerTypes()).toEqual([
109
+ "error",
110
+ "gap",
111
+ "message",
112
+ "open",
113
+ ]);
114
+ });
115
+
116
+ it("delivers an arbitrary named event to a subscriber that asked for it", () => {
117
+ const client = newClient();
118
+ const seen: Array<[unknown, string | undefined]> = [];
119
+ client.subscribe("build", (d, e) => seen.push([d, e.event]), { events: ["cell-done"] });
120
+ FakeEventSource.instances[0]!.emit("cell-done", '{"cell":3}', "9");
121
+ expect(seen).toEqual([[{ cell: 3 }, "cell-done"]]);
122
+ });
123
+
124
+ it("takes a default event-name set for every subscription on the client", () => {
125
+ const client = newClient({ events: ["message", "rebuilt"] });
126
+ const seen: unknown[] = [];
127
+ client.subscribe("build", (d) => seen.push(d));
128
+ FakeEventSource.instances[0]!.emit("rebuilt", '{"n":1}');
129
+ FakeEventSource.instances[0]!.emit("message", '{"n":2}');
130
+ expect(seen).toEqual([{ n: 1 }, { n: 2 }]);
131
+ });
132
+
133
+ it("removes every listener it added on unsubscribe", () => {
134
+ const client = newClient();
135
+ const off = client.subscribe("build", () => {}, { events: ["message", "cell-done"] });
136
+ off();
137
+ expect(FakeEventSource.instances[0]!.listenerTypes()).toEqual([]);
138
+ });
139
+ });
140
+
141
+ describe("newPubSubClient — a dead subscription", () => {
142
+ // I-5: a real EventSource that gets a non-2xx or a wrong content-type closes for
143
+ // good. With no error listener the page looked healthy and never updated again.
144
+ it("reports a stream error through onError", () => {
145
+ FakeEventSource.instances.length = 0;
146
+ const onError = vi.fn();
147
+ const onOpen = vi.fn();
148
+ const client = newPubSubClient("http://h/_events", {
149
+ EventSourceImpl: FakeEventSource as unknown as typeof EventSource,
150
+ onError,
151
+ onOpen,
152
+ });
153
+ client.subscribe("build", () => {});
154
+ FakeEventSource.instances[0]!.emitBare("open");
155
+ FakeEventSource.instances[0]!.emitBare("error");
156
+ expect(onOpen).toHaveBeenCalledWith("build");
157
+ expect(onError).toHaveBeenCalledWith("build", expect.objectContaining({ type: "error" }));
158
+ });
159
+ });
160
+
161
+ describe("newPubSubClient — reserved control names", () => {
162
+ // `gap`, `error` and `open` are the client's own control channels. Asking for one
163
+ // as a data event handed the gap frame to the data callback — re-opening the one
164
+ // invariant this package exists to protect, since a caller that mistakes a gap for
165
+ // a payload patches forward from a false baseline believing its history complete —
166
+ // and made a real transport error throw SyntaxError from JSON.parse(undefined).
167
+ const reserved = ["gap", "error", "open"];
168
+
169
+ it("refuses a reserved name in a subscription's event set, before opening anything", () => {
170
+ FakeEventSource.instances.length = 0;
171
+ const client = newPubSubClient("http://h/_events", {
172
+ EventSourceImpl: FakeEventSource as unknown as typeof EventSource,
173
+ });
174
+ for (const name of reserved) {
175
+ expect(() => client.subscribe("build", () => {}, { events: ["message", name] })).toThrow(
176
+ /reserved/,
177
+ );
178
+ }
179
+ expect(FakeEventSource.instances).toHaveLength(0); // nothing was opened and leaked
180
+ });
181
+
182
+ it("refuses a reserved name in the client-wide default set", () => {
183
+ for (const name of reserved) {
184
+ expect(() =>
185
+ newPubSubClient("http://h/_events", {
186
+ EventSourceImpl: FakeEventSource as unknown as typeof EventSource,
187
+ events: [name],
188
+ }),
189
+ ).toThrow(/reserved/);
190
+ }
191
+ });
192
+
193
+ it("keeps a gap off the data callback and a transport error harmless", () => {
194
+ FakeEventSource.instances.length = 0;
195
+ const onGap = vi.fn();
196
+ const onError = vi.fn();
197
+ const client = newPubSubClient("http://h/_events", {
198
+ EventSourceImpl: FakeEventSource as unknown as typeof EventSource,
199
+ onGap,
200
+ onError,
201
+ });
202
+ const seen: unknown[] = [];
203
+ expect(() =>
204
+ client.subscribe("build", (d) => seen.push(d), { events: ["gap", "error"] }),
205
+ ).toThrow(/reserved/);
206
+
207
+ client.subscribe("build", (d) => seen.push(d));
208
+ const source = FakeEventSource.instances[0]!;
209
+ source.emit("gap", '{"from":1}');
210
+ expect(() => source.emitBare("error")).not.toThrow();
211
+
212
+ expect(seen).toEqual([]); // no control frame ever reached the data callback
213
+ expect(onGap).toHaveBeenCalledWith("build");
214
+ expect(onError).toHaveBeenCalled();
215
+ });
216
+ });
package/src/client.ts ADDED
@@ -0,0 +1,133 @@
1
+ import type { Unsubscribe } from "./broker.js";
2
+
3
+ /** The SSE event type a subscription listens for when nothing else is asked for. */
4
+ const DEFAULT_EVENTS = ["message"] as const;
5
+
6
+ /**
7
+ * Names this client owns. They are wired to `onGap`, `onError` and `onOpen`, never
8
+ * to a data callback: a `gap` delivered as data would have the caller patch forward
9
+ * from a false baseline believing its history complete, and `error`/`open` carry a
10
+ * bare `Event` with no `data` to parse.
11
+ */
12
+ const RESERVED_EVENTS: readonly string[] = ["gap", "error", "open"];
13
+
14
+ function checkEvents(events: readonly string[], where: string): readonly string[] {
15
+ for (const name of events) {
16
+ if (RESERVED_EVENTS.includes(name)) {
17
+ throw new TypeError(
18
+ `${where}: "${name}" is a reserved event name — it is delivered to ` +
19
+ `on${name[0]?.toUpperCase()}${name.slice(1)}, not to a subscriber`,
20
+ );
21
+ }
22
+ }
23
+ return events;
24
+ }
25
+
26
+ export interface PubSubClientOptions {
27
+ /** Injected for tests, and for runtimes with a non-global EventSource. */
28
+ EventSourceImpl?: typeof EventSource;
29
+ /**
30
+ * Default event names for every subscription on this client; `["message"]`
31
+ * unless given. A frame published with `event: X` is dispatched by the browser
32
+ * ONLY as type X — it does not also fire `message` — so a name that is not
33
+ * listed here is received by nobody.
34
+ *
35
+ * `gap`, `error` and `open` are RESERVED: they are this client's control
36
+ * channels, reported through `onGap`, `onError` and `onOpen`. Listing one here
37
+ * throws a `TypeError` rather than handing a control frame to a data callback.
38
+ */
39
+ events?: readonly string[];
40
+ /**
41
+ * Called when the server reports it could not replay the client's full
42
+ * history. The caller should re-sync from scratch rather than assume the
43
+ * events it did receive are complete.
44
+ *
45
+ * A gap is not the only way a subscription stops being trustworthy: a stream
46
+ * that fails permanently is reported through {@link PubSubClientOptions.onError}.
47
+ */
48
+ onGap?: (topic: string) => void;
49
+ /**
50
+ * Called when the stream errors. `EventSource` retries a dropped connection on
51
+ * its own, but a non-2xx response or a wrong content-type closes it FOR GOOD —
52
+ * the subscription is then dead and this is the only notice of it. Check
53
+ * `readyState === 2` (CLOSED) to tell the two apart, and resubscribe.
54
+ */
55
+ onError?: (topic: string, e: Event) => void;
56
+ /** Called when the stream opens, including after `EventSource`'s own reconnect. */
57
+ onOpen?: (topic: string) => void;
58
+ }
59
+
60
+ export interface SubscribeOptions {
61
+ /**
62
+ * Event names for this subscription, overriding the client's default set.
63
+ * `gap`, `error` and `open` are reserved; see {@link PubSubClientOptions.events}.
64
+ */
65
+ events?: readonly string[];
66
+ }
67
+
68
+ export interface PubSubClient {
69
+ subscribe(
70
+ topic: string,
71
+ cb: (data: unknown, e: { id: number; event?: string }) => void,
72
+ options?: SubscribeOptions,
73
+ ): Unsubscribe;
74
+ close(): void;
75
+ }
76
+
77
+ export function newPubSubClient(
78
+ baseUrl: string,
79
+ { EventSourceImpl, events, onGap, onError, onOpen }: PubSubClientOptions = {},
80
+ ): PubSubClient {
81
+ const Impl = EventSourceImpl ?? globalThis.EventSource;
82
+ if (!Impl) throw new Error("no EventSource available; pass options.EventSourceImpl");
83
+
84
+ const open = new Set<EventSource>();
85
+ const base = baseUrl.replace(/\/$/, "");
86
+ const defaultEvents =
87
+ events === undefined ? DEFAULT_EVENTS : checkEvents(events, "newPubSubClient");
88
+
89
+ return {
90
+ subscribe(topic, cb, options = {}) {
91
+ // Validated before anything is opened, so a rejected call leaks no EventSource.
92
+ const names =
93
+ options.events === undefined ? defaultEvents : checkEvents(options.events, "subscribe");
94
+
95
+ const source = new Impl(`${base}/${topic}`);
96
+ open.add(source);
97
+
98
+ const listeners: Array<[string, (e: Event) => void]> = [];
99
+ const on = (type: string, fn: (e: Event) => void) => {
100
+ listeners.push([type, fn]);
101
+ source.addEventListener(type, fn);
102
+ };
103
+
104
+ for (const name of names) {
105
+ on(name, (e) => {
106
+ const m = e as MessageEvent;
107
+ cb(JSON.parse(m.data), {
108
+ id: Number(m.lastEventId),
109
+ // `message` is the unnamed default; anything else names itself.
110
+ ...(name === "message" ? {} : { event: name }),
111
+ });
112
+ });
113
+ }
114
+ // The gap signal is the client half of the replay contract; it never
115
+ // reaches the subscriber, which would mistake it for a payload.
116
+ on("gap", () => onGap?.(topic));
117
+ on("error", (e) => onError?.(topic, e));
118
+ on("open", () => onOpen?.(topic));
119
+
120
+ return () => {
121
+ for (const [type, fn] of listeners) source.removeEventListener(type, fn);
122
+ listeners.length = 0;
123
+ source.close();
124
+ open.delete(source);
125
+ };
126
+ },
127
+
128
+ close() {
129
+ for (const s of open) s.close();
130
+ open.clear();
131
+ },
132
+ };
133
+ }