@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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/index.d.mts +141 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +261 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -0
- package/src/broker.test.ts +192 -0
- package/src/broker.ts +121 -0
- package/src/client.test.ts +216 -0
- package/src/client.ts +133 -0
- package/src/handler.test.ts +191 -0
- package/src/handler.ts +166 -0
- package/src/index.ts +7 -0
- package/src/sse.test.ts +56 -0
- package/src/sse.ts +36 -0
|
@@ -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
|
+
}
|