@statewalker/webrun-http-events 0.1.0 → 0.2.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/README.md +154 -39
- package/dist/index.d.mts +6 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -3
- package/src/handler.ts +6 -1
package/README.md
CHANGED
|
@@ -1,16 +1,51 @@
|
|
|
1
1
|
# @statewalker/webrun-http-events
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
a standard `FetchHandler` on the server side and a matching `EventSource` client on the page side.
|
|
3
|
+
## What it is
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Publish/subscribe over
|
|
6
|
+
[Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html).
|
|
7
|
+
The server half is a standard `(Request) => Promise<Response>` handler that
|
|
8
|
+
streams `text/event-stream`; the client half wraps `EventSource` and delivers
|
|
9
|
+
parsed payloads per topic. Topics and event names are yours; the package has no
|
|
10
|
+
vocabulary of its own.
|
|
11
|
+
|
|
12
|
+
## Why it exists
|
|
13
|
+
|
|
14
|
+
Pushing events from a worker, a ServiceWorker or a Node process to a page needs
|
|
15
|
+
more than a stream of frames. A reconnecting `EventSource` has to get back what
|
|
16
|
+
it missed, a client that cannot get it back has to be told so, a named event
|
|
17
|
+
has to reach the listener that expects it, and a subscription that dies has to
|
|
18
|
+
be reported. This package implements those rules once, on top of a plain fetch
|
|
19
|
+
handler, so it runs wherever a `Request`/`Response` handler runs: behind a
|
|
20
|
+
`@statewalker/webrun-http-browser` ServiceWorker, inside a `Duplex` from
|
|
21
|
+
`@statewalker/webrun-http-streams`, or on a real HTTP server.
|
|
22
|
+
|
|
23
|
+
## How to use
|
|
8
24
|
|
|
9
25
|
```sh
|
|
10
|
-
|
|
26
|
+
pnpm add @statewalker/webrun-http-events
|
|
11
27
|
```
|
|
12
28
|
|
|
13
|
-
|
|
29
|
+
- **Peer dependencies:** none. **Runtime dependencies:** none.
|
|
30
|
+
- **Entry point:** one, `.`. ESM only: `dist/index.mjs` with
|
|
31
|
+
`dist/index.d.mts`, built by tsdown. The `source` export condition points at
|
|
32
|
+
`src/index.ts`.
|
|
33
|
+
- **Environment:** the server half (`newPubSub`, `newBroker`,
|
|
34
|
+
`formatSseEvent`) uses no DOM API and runs in a page, a Worker, a
|
|
35
|
+
ServiceWorker or Node. The client half (`newPubSubClient`) needs an
|
|
36
|
+
`EventSource`: the global one in a browser, or one passed as
|
|
37
|
+
`EventSourceImpl` elsewhere.
|
|
38
|
+
|
|
39
|
+
| Export | What it is |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `newPubSub(options?)` | `{ handler, publish, subscriberCount, topicCount }`. Types: `PubSub`, `PubSubOptions`, `FetchHandler`. |
|
|
42
|
+
| `newPubSubClient(baseUrl, options?)` | `{ subscribe(topic, cb, options?), close() }`. Types: `PubSubClient`, `PubSubClientOptions`, `SubscribeOptions`. |
|
|
43
|
+
| `newBroker(options?)` | The in-memory topic broker behind `newPubSub`: `publish`, `subscribe`, `replay`, `subscriberCount`, `topicCount`. Types: `Broker`, `BrokerEvent`, `BrokerOptions`, `Subscriber`, `Unsubscribe`. |
|
|
44
|
+
| `formatSseEvent(event)` | Formats one `{ id, event?, data }` as an SSE frame. |
|
|
45
|
+
|
|
46
|
+
## Examples
|
|
47
|
+
|
|
48
|
+
### Server
|
|
14
49
|
|
|
15
50
|
```ts
|
|
16
51
|
import { newPubSub } from "@statewalker/webrun-http-events";
|
|
@@ -24,24 +59,25 @@ export const fetch = (request: Request) => events.handler(request);
|
|
|
24
59
|
events.publish("build", { changed: ["/index.html"] }, "rebuilt");
|
|
25
60
|
```
|
|
26
61
|
|
|
27
|
-
`newPubSub
|
|
28
|
-
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `heartbeatMs` | Interval for `: ping` comment frames. 0 (default) means no heartbeat. |
|
|
34
|
-
| `onSubscriberError` | Called when a subscriber throws; delivery to the others continues either way. |
|
|
62
|
+
| `newPubSub` option | Default | Meaning |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| `topics` | none | Allow-list of topic names. Without it any request path allocates a topic, so set it on a public endpoint. |
|
|
65
|
+
| `bufferSize` | `64` | Events retained per topic for `Last-Event-ID` replay. |
|
|
66
|
+
| `heartbeatMs` | `0` | Interval for `: ping` comment frames, which keep a half-open connection visible to proxies. `0` means no heartbeat. |
|
|
67
|
+
| `onSubscriberError` | none | `(error, event)`, called when a subscriber throws. Delivery to the others continues either way. |
|
|
35
68
|
|
|
36
|
-
The handler answers `405
|
|
69
|
+
The handler answers `405 method not allowed` (with `allow: GET`) to anything
|
|
70
|
+
but `GET`, and `404 no such topic` to an empty or unlisted topic. A stream
|
|
71
|
+
response carries `cache-control: no-cache` and `x-accel-buffering: no`, which
|
|
72
|
+
stops proxy buffering from stalling delivery.
|
|
37
73
|
|
|
38
|
-
|
|
74
|
+
### Client
|
|
39
75
|
|
|
40
76
|
```ts
|
|
41
77
|
import { newPubSubClient } from "@statewalker/webrun-http-events";
|
|
42
78
|
|
|
43
79
|
const client = newPubSubClient("/_events", {
|
|
44
|
-
events: ["message", "rebuilt"], // see "event
|
|
80
|
+
events: ["message", "rebuilt"], // see "A named event fires only under its name"
|
|
45
81
|
onGap: (topic) => location.reload(), // history could not be replayed: re-sync
|
|
46
82
|
onError: (topic, e) => console.warn("subscription failed", topic, e),
|
|
47
83
|
});
|
|
@@ -49,39 +85,118 @@ const client = newPubSubClient("/_events", {
|
|
|
49
85
|
const off = client.subscribe("build", (data, { id, event }) => {
|
|
50
86
|
console.log(id, event, data);
|
|
51
87
|
});
|
|
88
|
+
|
|
89
|
+
// later
|
|
90
|
+
off(); // one subscription
|
|
91
|
+
client.close(); // every subscription of this client
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Each `subscribe` opens one `EventSource` on `<baseUrl>/<topic>`. Options:
|
|
95
|
+
`events`, `onGap`, `onError`, `onOpen`, and `EventSourceImpl` for runtimes
|
|
96
|
+
without a global `EventSource`. With no `EventSource` at all,
|
|
97
|
+
`newPubSubClient` throws `no EventSource available; pass options.EventSourceImpl`.
|
|
98
|
+
|
|
99
|
+
### Broker and frames without HTTP
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { formatSseEvent, newBroker } from "@statewalker/webrun-http-events";
|
|
103
|
+
|
|
104
|
+
const broker = newBroker({ bufferSize: 2 });
|
|
105
|
+
const off = broker.subscribe("t", (e) => console.log(e.id, e.data));
|
|
106
|
+
broker.publish("t", 1);
|
|
107
|
+
broker.publish("t", 2);
|
|
108
|
+
broker.publish("t", 3);
|
|
109
|
+
|
|
110
|
+
broker.replay("t", 0); // { events: [id 2, id 3], complete: false } — id 1 was evicted
|
|
111
|
+
broker.replay("t", 1); // { events: [id 2, id 3], complete: true }
|
|
112
|
+
|
|
113
|
+
formatSseEvent({ id: 1, event: "x", data: "hi" }); // 'id: 1\nevent: x\ndata: "hi"\n\n'
|
|
114
|
+
off();
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Internals
|
|
118
|
+
|
|
119
|
+
### A named event fires only under its name
|
|
120
|
+
|
|
121
|
+
A frame published with a name (`publish(topic, data, "rebuilt")`) is
|
|
122
|
+
dispatched by `EventSource` only under that name; it does not also fire
|
|
123
|
+
`message`. So every name a page wants must be listed, per client (`events`) or
|
|
124
|
+
per subscription (`subscribe(topic, cb, { events })`). The default is
|
|
125
|
+
`["message"]`, which is what an unnamed `publish(topic, data)` produces.
|
|
126
|
+
|
|
127
|
+
`gap`, `error` and `open` are reserved. They are the client's control channels,
|
|
128
|
+
reported through `onGap`, `onError` and `onOpen`. Listing one as a data event
|
|
129
|
+
throws, before any `EventSource` is opened:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
TypeError: newPubSubClient: "gap" is a reserved event name — it is delivered to onGap, not to a subscriber
|
|
52
133
|
```
|
|
53
134
|
|
|
54
|
-
|
|
135
|
+
A `gap` delivered as data would let the caller patch forward from a history it
|
|
136
|
+
believes complete, and `error`/`open` carry a bare `Event` with no data to
|
|
137
|
+
parse.
|
|
55
138
|
|
|
56
|
-
|
|
57
|
-
**only** under that name — it does not also fire `message`. So every name a page wants must be
|
|
58
|
-
listed, either per client (`events`) or per subscription (`client.subscribe(topic, cb, { events })`).
|
|
59
|
-
The default is `["message"]`, which is what an unnamed `publish(topic, data)` produces.
|
|
139
|
+
### Replay is all or nothing
|
|
60
140
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
141
|
+
Every event carries a per-topic id starting at 1. A reconnecting `EventSource`
|
|
142
|
+
sends `Last-Event-ID`, and the server replays what it still retains. When it
|
|
143
|
+
cannot reconstruct the client's history, it sends a `gap` frame first instead
|
|
144
|
+
of a partial replay that would look whole:
|
|
64
145
|
|
|
65
|
-
|
|
146
|
+
- the requested events were evicted from the ring of `bufferSize`;
|
|
147
|
+
- the id is above anything this server issued (a client left over from a
|
|
148
|
+
previous process);
|
|
149
|
+
- the header is not a non-negative safe integer.
|
|
66
150
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
evicted, the id predates a server restart, or the header was unreadable — it sends a `gap` event
|
|
70
|
-
instead of a partial replay that would look whole. `onGap` means *you are not current*: reload or
|
|
71
|
-
re-sync rather than patching forward.
|
|
151
|
+
`onGap` means "you are not current": reload or re-sync rather than patch
|
|
152
|
+
forward.
|
|
72
153
|
|
|
73
|
-
###
|
|
154
|
+
### A permanent failure is reported once
|
|
74
155
|
|
|
75
|
-
`EventSource` reconnects on its own after a dropped connection, but a non-2xx
|
|
76
|
-
content
|
|
156
|
+
`EventSource` reconnects on its own after a dropped connection, but a non-2xx
|
|
157
|
+
response or a wrong content type closes it for good. That is reported through
|
|
158
|
+
`onError`; check `readyState === 2` (CLOSED) to tell the two apart. A
|
|
77
159
|
subscription that died is never resurrected by itself.
|
|
78
160
|
|
|
79
|
-
|
|
161
|
+
### Delivery is synchronous and isolated
|
|
162
|
+
|
|
163
|
+
`publish` delivers to every subscriber before it returns. A subscriber that
|
|
164
|
+
throws is reported to `onSubscriberError` and does not starve the others or the
|
|
165
|
+
publisher; a reporter that throws is swallowed for the same reason. A stream
|
|
166
|
+
whose client went away, or whose frame cannot be produced, unsubscribes itself.
|
|
167
|
+
|
|
168
|
+
Reentrant publishing (calling `publish` from inside a subscriber) is not
|
|
169
|
+
supported. The inner event is delivered to completion first, so subscribers
|
|
170
|
+
observe ids out of order and a client's `Last-Event-ID` can move backwards.
|
|
171
|
+
Publish from a fresh task instead.
|
|
172
|
+
|
|
173
|
+
A topic with no subscribers and no retained events is dropped, so topic names
|
|
174
|
+
taken from request paths do not accumulate.
|
|
175
|
+
|
|
176
|
+
### Framing
|
|
177
|
+
|
|
178
|
+
`data` is always JSON-serialised, strings included, and every line is prefixed
|
|
179
|
+
with `data: `. A payload JSON cannot represent (`undefined`, a function, a
|
|
180
|
+
symbol, a BigInt, a circular object) is framed as `null`; the event still
|
|
181
|
+
reaches the client with its id and name. CR and LF are stripped from event
|
|
182
|
+
names, since a newline there would inject frames.
|
|
183
|
+
|
|
184
|
+
### Dependencies
|
|
185
|
+
|
|
186
|
+
Zero runtime dependencies; only platform APIs (`Request`, `Response`,
|
|
187
|
+
`ReadableStream`, `TextEncoder`, `EventSource` on the client).
|
|
188
|
+
`@statewalker/webrun-http-browser` is a dev dependency, used by the browser
|
|
189
|
+
test.
|
|
190
|
+
|
|
191
|
+
### Tests
|
|
80
192
|
|
|
81
|
-
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
-
|
|
193
|
+
- `pnpm test`: builds with tsdown, then runs the unit tests with a fake
|
|
194
|
+
`EventSource` and in-process responses.
|
|
195
|
+
- `pnpm run test:browser`: drives a real Chromium tab through a real
|
|
196
|
+
ServiceWorker (`@statewalker/webrun-http-browser`'s `sw-worker.js`) and a
|
|
197
|
+
real `text/event-stream` fetch, including past the 30 s ServiceWorker idle
|
|
198
|
+
window.
|
|
199
|
+
- `pnpm run typecheck`.
|
|
85
200
|
|
|
86
201
|
## License
|
|
87
202
|
|
package/dist/index.d.mts
CHANGED
|
@@ -97,7 +97,12 @@ interface PubSubClient {
|
|
|
97
97
|
export declare function newPubSubClient(baseUrl: string, { EventSourceImpl, events, onGap, onError, onOpen }?: PubSubClientOptions): PubSubClient;
|
|
98
98
|
//#endregion
|
|
99
99
|
//#region src/handler.d.ts
|
|
100
|
-
|
|
100
|
+
/**
|
|
101
|
+
* A handler: sync or async, as a Service Worker's or a Durable Object's `fetch`. The same type as
|
|
102
|
+
* `FetchHandler` in `@statewalker/webrun-http-streams` (a type test there keeps them identical),
|
|
103
|
+
* declared here so this package keeps zero runtime dependencies.
|
|
104
|
+
*/
|
|
105
|
+
type FetchHandler = (request: Request) => Response | Promise<Response>;
|
|
101
106
|
interface PubSubOptions extends BrokerOptions {
|
|
102
107
|
/**
|
|
103
108
|
* When given, only these topic names are served; anything else answers 404.
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/broker.ts","../src/client.ts","../src/handler.ts","../src/sse.ts"],"mappings":";KAAY;EAAgB;EAAY;EAAgB;;KAC5C,cAAc,GAAG;KACjB;UAEK;;;;;;;;;;;;EAYf,QAAQ,eAAe,eAAe,iBAAiB;;EAEvD,UAAU,eAAe,IAAI,aAAa;;;;;;;EAO1C,OAAO,eAAe;IAAoB,QAAQ;IAAe;;EACjE,gBAAgB;;EAEhB;;UAGe;;EAEf;;;;;EAKA,qBAAqB,gBAAgB,GAAG;;wBAU1B,YAAY,YAAiB,sBAAqB,gBAAqB;;;UCvBtE;;EAEf,yBAAyB;;;;;;;;;;;EAWzB;;;;;;;;;EASA,SAAS;;;;;;;EAOT,WAAW,eAAe,GAAG;;EAE7B,UAAU;;UAGK;;;;;EAKf;;UAGe;EACf,UACE,eACA,KAAK,eAAe;IAAK;IAAY;cACrC,UAAU,mBACT;EACH;;wBAGc,gBACd,mBACE,iBAAiB,QAAQ,OAAO,SAAS,WAAU,sBACpD
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/broker.ts","../src/client.ts","../src/handler.ts","../src/sse.ts"],"mappings":";KAAY;EAAgB;EAAY;EAAgB;;KAC5C,cAAc,GAAG;KACjB;UAEK;;;;;;;;;;;;EAYf,QAAQ,eAAe,eAAe,iBAAiB;;EAEvD,UAAU,eAAe,IAAI,aAAa;;;;;;;EAO1C,OAAO,eAAe;IAAoB,QAAQ;IAAe;;EACjE,gBAAgB;;EAEhB;;UAGe;;EAEf;;;;;EAKA,qBAAqB,gBAAgB,GAAG;;wBAU1B,YAAY,YAAiB,sBAAqB,gBAAqB;;;UCvBtE;;EAEf,yBAAyB;;;;;;;;;;;EAWzB;;;;;;;;;EASA,SAAS;;;;;;;EAOT,WAAW,eAAe,GAAG;;EAE7B,UAAU;;UAGK;;;;;EAKf;;UAGe;EACf,UACE,eACA,KAAK,eAAe;IAAK;IAAY;cACrC,UAAU,mBACT;EACH;;wBAGc,gBACd,mBACE,iBAAiB,QAAQ,OAAO,SAAS,WAAU,sBACpD;;;;;;;;KCvES,gBAAgB,SAAS,YAAY,WAAW,QAAQ;UAEnD,sBAAsB;;;;;;EAMrC;;;;;;;EAOA;;UAGe;EACf,SAAS;EACT,QAAQ,eAAe,eAAe;EACtC,gBAAgB;;EAEhB;;wBA4Fc,UAAU,UAAS,gBAAqB;;;;;;;;;;;;;;;wBC/FxC,eAAe,GAAG"}
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/broker.ts","../src/client.ts","../src/sse.ts","../src/handler.ts"],"sourcesContent":["export type BrokerEvent = { id: number; event?: string; data: unknown };\nexport type Subscriber = (e: BrokerEvent) => void;\nexport type Unsubscribe = () => void;\n\nexport interface Broker {\n /**\n * Deliver `data` to every subscriber of `topic` and retain it for replay.\n *\n * Delivery is synchronous and each subscriber is isolated: one that throws is\n * reported to `onSubscriberError` and cannot starve the others or the caller.\n *\n * Publishing from inside a subscriber (a reentrant publish) is NOT supported:\n * the inner event is delivered to completion before the outer one finishes, so\n * subscribers observe ids out of order and a client's Last-Event-ID can move\n * backwards. Publish from a fresh task instead.\n */\n publish(topic: string, data: unknown, event?: string): BrokerEvent;\n /** Register `fn` for future events on `topic`. Use {@link Broker.replay} for history. */\n subscribe(topic: string, fn: Subscriber): Unsubscribe;\n /**\n * Events retained after `afterId`, plus whether that is the whole story.\n * `complete: false` means the caller's history cannot be reconstructed — either\n * the events it wants were evicted, or its `afterId` is above anything this\n * broker has issued (a client left over from a previous process).\n */\n replay(topic: string, afterId: number): { events: BrokerEvent[]; complete: boolean };\n subscriberCount(topic: string): number;\n /** How many topics currently hold state. Topics with no subscribers and no retained events are dropped. */\n topicCount(): number;\n}\n\nexport interface BrokerOptions {\n /** How many recent events to retain per topic for Last-Event-ID replay. */\n bufferSize?: number;\n /**\n * Called when a subscriber throws. Delivery continues either way; without this\n * the failure would be invisible.\n */\n onSubscriberError?: (error: unknown, e: BrokerEvent) => void;\n}\n\ntype TopicState = {\n nextId: number;\n /** Bounded ring of recent events, oldest first. */\n buffer: BrokerEvent[];\n subscribers: Set<Subscriber>;\n};\n\nexport function newBroker({ bufferSize = 64, onSubscriberError }: BrokerOptions = {}): Broker {\n const topics = new Map<string, TopicState>();\n\n const state = (topic: string): TopicState => {\n let s = topics.get(topic);\n if (!s) {\n s = { nextId: 1, buffer: [], subscribers: new Set() };\n topics.set(topic, s);\n }\n return s;\n };\n\n /** Drop a topic that holds nothing, so request-driven names cannot accumulate. */\n const prune = (topic: string, s: TopicState): void => {\n if (topics.get(topic) === s && s.subscribers.size === 0 && s.buffer.length === 0) {\n topics.delete(topic);\n }\n };\n\n const replay: Broker[\"replay\"] = (topic, afterId) => {\n const s = topics.get(topic);\n // An absent topic has issued nothing: only a caller with no history is current.\n if (!s) return { events: [], complete: afterId <= 0 };\n const events = s.buffer.filter((e) => e.id > afterId);\n const latest = s.nextId - 1;\n // An empty ring retains nothing, so nothing before `nextId` can be reconstructed.\n const oldest = s.buffer[0]?.id ?? s.nextId;\n // Incomplete below, when the requested point predates the oldest retained event,\n // and above, when it names an id this broker never issued.\n return { events, complete: afterId >= oldest - 1 && afterId <= latest };\n };\n\n return {\n publish(topic, data, event) {\n const s = state(topic);\n const e: BrokerEvent = { id: s.nextId++, data, ...(event === undefined ? {} : { event }) };\n s.buffer.push(e);\n if (s.buffer.length > bufferSize) s.buffer.shift();\n // Copy before iterating: a subscriber may unsubscribe during delivery.\n for (const fn of [...s.subscribers]) {\n try {\n fn(e);\n } catch (error) {\n try {\n onSubscriberError?.(error, e);\n } catch {\n // A reporter that throws must not starve the remaining subscribers either.\n }\n }\n }\n return e;\n },\n\n subscribe(topic, fn) {\n const s = state(topic);\n s.subscribers.add(fn);\n return () => {\n s.subscribers.delete(fn);\n prune(topic, s);\n };\n },\n\n replay,\n\n subscriberCount(topic) {\n return topics.get(topic)?.subscribers.size ?? 0;\n },\n\n topicCount() {\n return topics.size;\n },\n };\n}\n","import type { Unsubscribe } from \"./broker.js\";\n\n/** The SSE event type a subscription listens for when nothing else is asked for. */\nconst DEFAULT_EVENTS = [\"message\"] as const;\n\n/**\n * Names this client owns. They are wired to `onGap`, `onError` and `onOpen`, never\n * to a data callback: a `gap` delivered as data would have the caller patch forward\n * from a false baseline believing its history complete, and `error`/`open` carry a\n * bare `Event` with no `data` to parse.\n */\nconst RESERVED_EVENTS: readonly string[] = [\"gap\", \"error\", \"open\"];\n\nfunction checkEvents(events: readonly string[], where: string): readonly string[] {\n for (const name of events) {\n if (RESERVED_EVENTS.includes(name)) {\n throw new TypeError(\n `${where}: \"${name}\" is a reserved event name — it is delivered to ` +\n `on${name[0]?.toUpperCase()}${name.slice(1)}, not to a subscriber`,\n );\n }\n }\n return events;\n}\n\nexport interface PubSubClientOptions {\n /** Injected for tests, and for runtimes with a non-global EventSource. */\n EventSourceImpl?: typeof EventSource;\n /**\n * Default event names for every subscription on this client; `[\"message\"]`\n * unless given. A frame published with `event: X` is dispatched by the browser\n * ONLY as type X — it does not also fire `message` — so a name that is not\n * listed here is received by nobody.\n *\n * `gap`, `error` and `open` are RESERVED: they are this client's control\n * channels, reported through `onGap`, `onError` and `onOpen`. Listing one here\n * throws a `TypeError` rather than handing a control frame to a data callback.\n */\n events?: readonly string[];\n /**\n * Called when the server reports it could not replay the client's full\n * history. The caller should re-sync from scratch rather than assume the\n * events it did receive are complete.\n *\n * A gap is not the only way a subscription stops being trustworthy: a stream\n * that fails permanently is reported through {@link PubSubClientOptions.onError}.\n */\n onGap?: (topic: string) => void;\n /**\n * Called when the stream errors. `EventSource` retries a dropped connection on\n * its own, but a non-2xx response or a wrong content-type closes it FOR GOOD —\n * the subscription is then dead and this is the only notice of it. Check\n * `readyState === 2` (CLOSED) to tell the two apart, and resubscribe.\n */\n onError?: (topic: string, e: Event) => void;\n /** Called when the stream opens, including after `EventSource`'s own reconnect. */\n onOpen?: (topic: string) => void;\n}\n\nexport interface SubscribeOptions {\n /**\n * Event names for this subscription, overriding the client's default set.\n * `gap`, `error` and `open` are reserved; see {@link PubSubClientOptions.events}.\n */\n events?: readonly string[];\n}\n\nexport interface PubSubClient {\n subscribe(\n topic: string,\n cb: (data: unknown, e: { id: number; event?: string }) => void,\n options?: SubscribeOptions,\n ): Unsubscribe;\n close(): void;\n}\n\nexport function newPubSubClient(\n baseUrl: string,\n { EventSourceImpl, events, onGap, onError, onOpen }: PubSubClientOptions = {},\n): PubSubClient {\n const Impl = EventSourceImpl ?? globalThis.EventSource;\n if (!Impl) throw new Error(\"no EventSource available; pass options.EventSourceImpl\");\n\n const open = new Set<EventSource>();\n const base = baseUrl.replace(/\\/$/, \"\");\n const defaultEvents =\n events === undefined ? DEFAULT_EVENTS : checkEvents(events, \"newPubSubClient\");\n\n return {\n subscribe(topic, cb, options = {}) {\n // Validated before anything is opened, so a rejected call leaks no EventSource.\n const names =\n options.events === undefined ? defaultEvents : checkEvents(options.events, \"subscribe\");\n\n const source = new Impl(`${base}/${topic}`);\n open.add(source);\n\n const listeners: Array<[string, (e: Event) => void]> = [];\n const on = (type: string, fn: (e: Event) => void) => {\n listeners.push([type, fn]);\n source.addEventListener(type, fn);\n };\n\n for (const name of names) {\n on(name, (e) => {\n const m = e as MessageEvent;\n cb(JSON.parse(m.data), {\n id: Number(m.lastEventId),\n // `message` is the unnamed default; anything else names itself.\n ...(name === \"message\" ? {} : { event: name }),\n });\n });\n }\n // The gap signal is the client half of the replay contract; it never\n // reaches the subscriber, which would mistake it for a payload.\n on(\"gap\", () => onGap?.(topic));\n on(\"error\", (e) => onError?.(topic, e));\n on(\"open\", () => onOpen?.(topic));\n\n return () => {\n for (const [type, fn] of listeners) source.removeEventListener(type, fn);\n listeners.length = 0;\n source.close();\n open.delete(source);\n };\n },\n\n close() {\n for (const s of open) s.close();\n open.clear();\n },\n };\n}\n","import type { BrokerEvent } from \"./broker.js\";\n\n/**\n * JSON for the wire. Total by construction: `JSON.stringify` returns `undefined`\n * rather than a string for `undefined`, a function or a symbol, and throws on a\n * BigInt or a circular object. Any of those is framed as `null` — the event still\n * reaches the client with its id and name, which is what an empty signal is for.\n */\nfunction serialize(data: unknown): string {\n try {\n return JSON.stringify(data) ?? \"null\";\n } catch {\n return \"null\";\n }\n}\n\n/**\n * Format one WHATWG Server-Sent Event frame, terminated by the blank line that\n * ends an event.\n *\n * `data` is always JSON-serialized, strings included (a string payload arrives\n * back through `JSON.parse` as the same string, never half-decoded). JSON escapes\n * newlines, so the body is a single line in practice; every line is prefixed\n * anyway, because an unframed newline would end the event and truncate it.\n *\n * CR and LF are stripped from the event name: it is caller-supplied, and a\n * newline there would inject whole frames onto the wire.\n */\nexport function formatSseEvent(e: BrokerEvent): string {\n const lines = serialize(e.data)\n .split(\"\\n\")\n .map((l) => `data: ${l}\\n`)\n .join(\"\");\n const name = e.event === undefined ? \"\" : `event: ${e.event.replace(/[\\r\\n]/g, \"\")}\\n`;\n return `id: ${e.id}\\n${name}${lines}\\n`;\n}\n","import { type Broker, type BrokerOptions, newBroker, type Unsubscribe } from \"./broker.js\";\nimport { formatSseEvent } from \"./sse.js\";\n\nexport type FetchHandler = (request: Request) => Promise<Response>;\n\nexport interface PubSubOptions extends BrokerOptions {\n /**\n * When given, only these topic names are served; anything else answers 404.\n * Without it any request path allocates a topic, so an open endpoint should\n * set this.\n */\n topics?: readonly string[];\n /**\n * Interval in ms for `: ping` comment frames, which the SSE parser ignores but\n * which keep a half-open connection visible to proxies. 0 (the default) means\n * no heartbeat: a connection that dies silently is then only noticed when the\n * client's own reconnect timer fires.\n */\n heartbeatMs?: number;\n}\n\nexport interface PubSub {\n handler: FetchHandler;\n publish(topic: string, data: unknown, event?: string): void;\n subscriberCount(topic: string): number;\n /** How many topics currently hold state; a topic with no subscribers and no retained events is dropped. */\n topicCount(): number;\n}\n\n/** The topic is the final non-empty path segment: /_events/build -> \"build\". */\nfunction topicOf(request: Request): string {\n const segments = new URL(request.url).pathname.split(\"/\").filter(Boolean);\n return segments.at(-1) ?? \"\";\n}\n\nexport interface SseSourceOptions {\n /** Replay events after this id; undefined for a fresh subscription. */\n replayFrom?: number;\n /** Send a `gap` before anything else — the client's Last-Event-ID was unusable. */\n forceGap?: boolean;\n /** See {@link PubSubOptions.heartbeatMs}. */\n heartbeatMs?: number;\n}\n\n/**\n * The `ReadableStream` source behind one subscription.\n *\n * Exported so tests can drive it with a controller whose `enqueue` fails, which\n * is the one path a real stream cannot be pushed down from outside.\n */\nexport function createSseSource(\n broker: Broker,\n topic: string,\n { replayFrom, forceGap = false, heartbeatMs = 0 }: SseSourceOptions = {},\n): UnderlyingSource<Uint8Array> {\n const encoder = new TextEncoder();\n let unsubscribe: Unsubscribe | undefined;\n let heartbeat: ReturnType<typeof setInterval> | undefined;\n let broken = false;\n\n return {\n start(controller) {\n /** Tear the subscription down and end the stream, once. */\n const stop = () => {\n if (broken) return;\n broken = true;\n if (heartbeat !== undefined) clearInterval(heartbeat);\n unsubscribe?.();\n unsubscribe = undefined;\n try {\n controller.close();\n } catch {\n // Already closed or errored; nothing left to do.\n }\n };\n\n const write = (frame: () => string) => {\n if (broken) return;\n try {\n // Formatting happens inside the guard: it is given caller data, and a\n // throw here used to escape this catch entirely.\n controller.enqueue(encoder.encode(frame()));\n } catch {\n // The client went away, or the frame could not be produced.\n stop();\n }\n };\n\n // Subscribe first, so the teardown above can release it even if the very\n // first replay frame fails.\n unsubscribe = broker.subscribe(topic, (e) => write(() => formatSseEvent(e)));\n\n if (forceGap || replayFrom !== undefined) {\n const from = replayFrom ?? 0;\n const { events, complete } =\n replayFrom === undefined\n ? { events: [], complete: false }\n : broker.replay(topic, replayFrom);\n // Tell the client its history is incomplete rather than handing it a\n // partial replay that looks whole; it can reload instead of patching.\n if (!complete) write(() => formatSseEvent({ id: from, event: \"gap\", data: { from } }));\n for (const e of events) write(() => formatSseEvent(e));\n }\n\n if (heartbeatMs > 0) {\n heartbeat = setInterval(() => write(() => \": ping\\n\\n\"), heartbeatMs);\n }\n },\n\n cancel() {\n if (heartbeat !== undefined) clearInterval(heartbeat);\n broken = true;\n unsubscribe?.();\n unsubscribe = undefined;\n },\n };\n}\n\nexport function newPubSub(options: PubSubOptions = {}): PubSub {\n const { topics, heartbeatMs, ...brokerOptions } = options;\n const broker = newBroker(brokerOptions);\n const allowed = topics === undefined ? undefined : new Set(topics);\n\n const handler: FetchHandler = async (request) => {\n if (request.method !== \"GET\") {\n return new Response(\"method not allowed\", { status: 405, headers: { allow: \"GET\" } });\n }\n\n const topic = topicOf(request);\n if (topic === \"\" || (allowed !== undefined && !allowed.has(topic))) {\n return new Response(\"no such topic\", { status: 404 });\n }\n\n const header = request.headers.get(\"last-event-id\");\n const parsed = header === null ? Number.NaN : Number(header);\n const usable = header !== null && Number.isSafeInteger(parsed) && parsed >= 0;\n // An id we cannot read is not a fresh client: it had history and lost it, so\n // it gets a gap rather than silence.\n const forceGap = header !== null && !usable;\n\n const stream = new ReadableStream<Uint8Array>(\n createSseSource(broker, topic, {\n ...(usable ? { replayFrom: parsed } : {}),\n forceGap,\n ...(heartbeatMs === undefined ? {} : { heartbeatMs }),\n }),\n );\n\n return new Response(stream, {\n status: 200,\n headers: {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-cache\",\n connection: \"keep-alive\",\n \"x-accel-buffering\": \"no\", // defeat proxy buffering that would stall delivery\n },\n });\n };\n\n return {\n handler,\n publish: (topic, data, event) => void broker.publish(topic, data, event),\n subscriberCount: (topic) => broker.subscriberCount(topic),\n topicCount: () => broker.topicCount(),\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/broker.ts","../src/client.ts","../src/sse.ts","../src/handler.ts"],"sourcesContent":["export type BrokerEvent = { id: number; event?: string; data: unknown };\nexport type Subscriber = (e: BrokerEvent) => void;\nexport type Unsubscribe = () => void;\n\nexport interface Broker {\n /**\n * Deliver `data` to every subscriber of `topic` and retain it for replay.\n *\n * Delivery is synchronous and each subscriber is isolated: one that throws is\n * reported to `onSubscriberError` and cannot starve the others or the caller.\n *\n * Publishing from inside a subscriber (a reentrant publish) is NOT supported:\n * the inner event is delivered to completion before the outer one finishes, so\n * subscribers observe ids out of order and a client's Last-Event-ID can move\n * backwards. Publish from a fresh task instead.\n */\n publish(topic: string, data: unknown, event?: string): BrokerEvent;\n /** Register `fn` for future events on `topic`. Use {@link Broker.replay} for history. */\n subscribe(topic: string, fn: Subscriber): Unsubscribe;\n /**\n * Events retained after `afterId`, plus whether that is the whole story.\n * `complete: false` means the caller's history cannot be reconstructed — either\n * the events it wants were evicted, or its `afterId` is above anything this\n * broker has issued (a client left over from a previous process).\n */\n replay(topic: string, afterId: number): { events: BrokerEvent[]; complete: boolean };\n subscriberCount(topic: string): number;\n /** How many topics currently hold state. Topics with no subscribers and no retained events are dropped. */\n topicCount(): number;\n}\n\nexport interface BrokerOptions {\n /** How many recent events to retain per topic for Last-Event-ID replay. */\n bufferSize?: number;\n /**\n * Called when a subscriber throws. Delivery continues either way; without this\n * the failure would be invisible.\n */\n onSubscriberError?: (error: unknown, e: BrokerEvent) => void;\n}\n\ntype TopicState = {\n nextId: number;\n /** Bounded ring of recent events, oldest first. */\n buffer: BrokerEvent[];\n subscribers: Set<Subscriber>;\n};\n\nexport function newBroker({ bufferSize = 64, onSubscriberError }: BrokerOptions = {}): Broker {\n const topics = new Map<string, TopicState>();\n\n const state = (topic: string): TopicState => {\n let s = topics.get(topic);\n if (!s) {\n s = { nextId: 1, buffer: [], subscribers: new Set() };\n topics.set(topic, s);\n }\n return s;\n };\n\n /** Drop a topic that holds nothing, so request-driven names cannot accumulate. */\n const prune = (topic: string, s: TopicState): void => {\n if (topics.get(topic) === s && s.subscribers.size === 0 && s.buffer.length === 0) {\n topics.delete(topic);\n }\n };\n\n const replay: Broker[\"replay\"] = (topic, afterId) => {\n const s = topics.get(topic);\n // An absent topic has issued nothing: only a caller with no history is current.\n if (!s) return { events: [], complete: afterId <= 0 };\n const events = s.buffer.filter((e) => e.id > afterId);\n const latest = s.nextId - 1;\n // An empty ring retains nothing, so nothing before `nextId` can be reconstructed.\n const oldest = s.buffer[0]?.id ?? s.nextId;\n // Incomplete below, when the requested point predates the oldest retained event,\n // and above, when it names an id this broker never issued.\n return { events, complete: afterId >= oldest - 1 && afterId <= latest };\n };\n\n return {\n publish(topic, data, event) {\n const s = state(topic);\n const e: BrokerEvent = { id: s.nextId++, data, ...(event === undefined ? {} : { event }) };\n s.buffer.push(e);\n if (s.buffer.length > bufferSize) s.buffer.shift();\n // Copy before iterating: a subscriber may unsubscribe during delivery.\n for (const fn of [...s.subscribers]) {\n try {\n fn(e);\n } catch (error) {\n try {\n onSubscriberError?.(error, e);\n } catch {\n // A reporter that throws must not starve the remaining subscribers either.\n }\n }\n }\n return e;\n },\n\n subscribe(topic, fn) {\n const s = state(topic);\n s.subscribers.add(fn);\n return () => {\n s.subscribers.delete(fn);\n prune(topic, s);\n };\n },\n\n replay,\n\n subscriberCount(topic) {\n return topics.get(topic)?.subscribers.size ?? 0;\n },\n\n topicCount() {\n return topics.size;\n },\n };\n}\n","import type { Unsubscribe } from \"./broker.js\";\n\n/** The SSE event type a subscription listens for when nothing else is asked for. */\nconst DEFAULT_EVENTS = [\"message\"] as const;\n\n/**\n * Names this client owns. They are wired to `onGap`, `onError` and `onOpen`, never\n * to a data callback: a `gap` delivered as data would have the caller patch forward\n * from a false baseline believing its history complete, and `error`/`open` carry a\n * bare `Event` with no `data` to parse.\n */\nconst RESERVED_EVENTS: readonly string[] = [\"gap\", \"error\", \"open\"];\n\nfunction checkEvents(events: readonly string[], where: string): readonly string[] {\n for (const name of events) {\n if (RESERVED_EVENTS.includes(name)) {\n throw new TypeError(\n `${where}: \"${name}\" is a reserved event name — it is delivered to ` +\n `on${name[0]?.toUpperCase()}${name.slice(1)}, not to a subscriber`,\n );\n }\n }\n return events;\n}\n\nexport interface PubSubClientOptions {\n /** Injected for tests, and for runtimes with a non-global EventSource. */\n EventSourceImpl?: typeof EventSource;\n /**\n * Default event names for every subscription on this client; `[\"message\"]`\n * unless given. A frame published with `event: X` is dispatched by the browser\n * ONLY as type X — it does not also fire `message` — so a name that is not\n * listed here is received by nobody.\n *\n * `gap`, `error` and `open` are RESERVED: they are this client's control\n * channels, reported through `onGap`, `onError` and `onOpen`. Listing one here\n * throws a `TypeError` rather than handing a control frame to a data callback.\n */\n events?: readonly string[];\n /**\n * Called when the server reports it could not replay the client's full\n * history. The caller should re-sync from scratch rather than assume the\n * events it did receive are complete.\n *\n * A gap is not the only way a subscription stops being trustworthy: a stream\n * that fails permanently is reported through {@link PubSubClientOptions.onError}.\n */\n onGap?: (topic: string) => void;\n /**\n * Called when the stream errors. `EventSource` retries a dropped connection on\n * its own, but a non-2xx response or a wrong content-type closes it FOR GOOD —\n * the subscription is then dead and this is the only notice of it. Check\n * `readyState === 2` (CLOSED) to tell the two apart, and resubscribe.\n */\n onError?: (topic: string, e: Event) => void;\n /** Called when the stream opens, including after `EventSource`'s own reconnect. */\n onOpen?: (topic: string) => void;\n}\n\nexport interface SubscribeOptions {\n /**\n * Event names for this subscription, overriding the client's default set.\n * `gap`, `error` and `open` are reserved; see {@link PubSubClientOptions.events}.\n */\n events?: readonly string[];\n}\n\nexport interface PubSubClient {\n subscribe(\n topic: string,\n cb: (data: unknown, e: { id: number; event?: string }) => void,\n options?: SubscribeOptions,\n ): Unsubscribe;\n close(): void;\n}\n\nexport function newPubSubClient(\n baseUrl: string,\n { EventSourceImpl, events, onGap, onError, onOpen }: PubSubClientOptions = {},\n): PubSubClient {\n const Impl = EventSourceImpl ?? globalThis.EventSource;\n if (!Impl) throw new Error(\"no EventSource available; pass options.EventSourceImpl\");\n\n const open = new Set<EventSource>();\n const base = baseUrl.replace(/\\/$/, \"\");\n const defaultEvents =\n events === undefined ? DEFAULT_EVENTS : checkEvents(events, \"newPubSubClient\");\n\n return {\n subscribe(topic, cb, options = {}) {\n // Validated before anything is opened, so a rejected call leaks no EventSource.\n const names =\n options.events === undefined ? defaultEvents : checkEvents(options.events, \"subscribe\");\n\n const source = new Impl(`${base}/${topic}`);\n open.add(source);\n\n const listeners: Array<[string, (e: Event) => void]> = [];\n const on = (type: string, fn: (e: Event) => void) => {\n listeners.push([type, fn]);\n source.addEventListener(type, fn);\n };\n\n for (const name of names) {\n on(name, (e) => {\n const m = e as MessageEvent;\n cb(JSON.parse(m.data), {\n id: Number(m.lastEventId),\n // `message` is the unnamed default; anything else names itself.\n ...(name === \"message\" ? {} : { event: name }),\n });\n });\n }\n // The gap signal is the client half of the replay contract; it never\n // reaches the subscriber, which would mistake it for a payload.\n on(\"gap\", () => onGap?.(topic));\n on(\"error\", (e) => onError?.(topic, e));\n on(\"open\", () => onOpen?.(topic));\n\n return () => {\n for (const [type, fn] of listeners) source.removeEventListener(type, fn);\n listeners.length = 0;\n source.close();\n open.delete(source);\n };\n },\n\n close() {\n for (const s of open) s.close();\n open.clear();\n },\n };\n}\n","import type { BrokerEvent } from \"./broker.js\";\n\n/**\n * JSON for the wire. Total by construction: `JSON.stringify` returns `undefined`\n * rather than a string for `undefined`, a function or a symbol, and throws on a\n * BigInt or a circular object. Any of those is framed as `null` — the event still\n * reaches the client with its id and name, which is what an empty signal is for.\n */\nfunction serialize(data: unknown): string {\n try {\n return JSON.stringify(data) ?? \"null\";\n } catch {\n return \"null\";\n }\n}\n\n/**\n * Format one WHATWG Server-Sent Event frame, terminated by the blank line that\n * ends an event.\n *\n * `data` is always JSON-serialized, strings included (a string payload arrives\n * back through `JSON.parse` as the same string, never half-decoded). JSON escapes\n * newlines, so the body is a single line in practice; every line is prefixed\n * anyway, because an unframed newline would end the event and truncate it.\n *\n * CR and LF are stripped from the event name: it is caller-supplied, and a\n * newline there would inject whole frames onto the wire.\n */\nexport function formatSseEvent(e: BrokerEvent): string {\n const lines = serialize(e.data)\n .split(\"\\n\")\n .map((l) => `data: ${l}\\n`)\n .join(\"\");\n const name = e.event === undefined ? \"\" : `event: ${e.event.replace(/[\\r\\n]/g, \"\")}\\n`;\n return `id: ${e.id}\\n${name}${lines}\\n`;\n}\n","import { type Broker, type BrokerOptions, newBroker, type Unsubscribe } from \"./broker.js\";\nimport { formatSseEvent } from \"./sse.js\";\n\n/**\n * A handler: sync or async, as a Service Worker's or a Durable Object's `fetch`. The same type as\n * `FetchHandler` in `@statewalker/webrun-http-streams` (a type test there keeps them identical),\n * declared here so this package keeps zero runtime dependencies.\n */\nexport type FetchHandler = (request: Request) => Response | Promise<Response>;\n\nexport interface PubSubOptions extends BrokerOptions {\n /**\n * When given, only these topic names are served; anything else answers 404.\n * Without it any request path allocates a topic, so an open endpoint should\n * set this.\n */\n topics?: readonly string[];\n /**\n * Interval in ms for `: ping` comment frames, which the SSE parser ignores but\n * which keep a half-open connection visible to proxies. 0 (the default) means\n * no heartbeat: a connection that dies silently is then only noticed when the\n * client's own reconnect timer fires.\n */\n heartbeatMs?: number;\n}\n\nexport interface PubSub {\n handler: FetchHandler;\n publish(topic: string, data: unknown, event?: string): void;\n subscriberCount(topic: string): number;\n /** How many topics currently hold state; a topic with no subscribers and no retained events is dropped. */\n topicCount(): number;\n}\n\n/** The topic is the final non-empty path segment: /_events/build -> \"build\". */\nfunction topicOf(request: Request): string {\n const segments = new URL(request.url).pathname.split(\"/\").filter(Boolean);\n return segments.at(-1) ?? \"\";\n}\n\nexport interface SseSourceOptions {\n /** Replay events after this id; undefined for a fresh subscription. */\n replayFrom?: number;\n /** Send a `gap` before anything else — the client's Last-Event-ID was unusable. */\n forceGap?: boolean;\n /** See {@link PubSubOptions.heartbeatMs}. */\n heartbeatMs?: number;\n}\n\n/**\n * The `ReadableStream` source behind one subscription.\n *\n * Exported so tests can drive it with a controller whose `enqueue` fails, which\n * is the one path a real stream cannot be pushed down from outside.\n */\nexport function createSseSource(\n broker: Broker,\n topic: string,\n { replayFrom, forceGap = false, heartbeatMs = 0 }: SseSourceOptions = {},\n): UnderlyingSource<Uint8Array> {\n const encoder = new TextEncoder();\n let unsubscribe: Unsubscribe | undefined;\n let heartbeat: ReturnType<typeof setInterval> | undefined;\n let broken = false;\n\n return {\n start(controller) {\n /** Tear the subscription down and end the stream, once. */\n const stop = () => {\n if (broken) return;\n broken = true;\n if (heartbeat !== undefined) clearInterval(heartbeat);\n unsubscribe?.();\n unsubscribe = undefined;\n try {\n controller.close();\n } catch {\n // Already closed or errored; nothing left to do.\n }\n };\n\n const write = (frame: () => string) => {\n if (broken) return;\n try {\n // Formatting happens inside the guard: it is given caller data, and a\n // throw here used to escape this catch entirely.\n controller.enqueue(encoder.encode(frame()));\n } catch {\n // The client went away, or the frame could not be produced.\n stop();\n }\n };\n\n // Subscribe first, so the teardown above can release it even if the very\n // first replay frame fails.\n unsubscribe = broker.subscribe(topic, (e) => write(() => formatSseEvent(e)));\n\n if (forceGap || replayFrom !== undefined) {\n const from = replayFrom ?? 0;\n const { events, complete } =\n replayFrom === undefined\n ? { events: [], complete: false }\n : broker.replay(topic, replayFrom);\n // Tell the client its history is incomplete rather than handing it a\n // partial replay that looks whole; it can reload instead of patching.\n if (!complete) write(() => formatSseEvent({ id: from, event: \"gap\", data: { from } }));\n for (const e of events) write(() => formatSseEvent(e));\n }\n\n if (heartbeatMs > 0) {\n heartbeat = setInterval(() => write(() => \": ping\\n\\n\"), heartbeatMs);\n }\n },\n\n cancel() {\n if (heartbeat !== undefined) clearInterval(heartbeat);\n broken = true;\n unsubscribe?.();\n unsubscribe = undefined;\n },\n };\n}\n\nexport function newPubSub(options: PubSubOptions = {}): PubSub {\n const { topics, heartbeatMs, ...brokerOptions } = options;\n const broker = newBroker(brokerOptions);\n const allowed = topics === undefined ? undefined : new Set(topics);\n\n const handler: FetchHandler = async (request) => {\n if (request.method !== \"GET\") {\n return new Response(\"method not allowed\", { status: 405, headers: { allow: \"GET\" } });\n }\n\n const topic = topicOf(request);\n if (topic === \"\" || (allowed !== undefined && !allowed.has(topic))) {\n return new Response(\"no such topic\", { status: 404 });\n }\n\n const header = request.headers.get(\"last-event-id\");\n const parsed = header === null ? Number.NaN : Number(header);\n const usable = header !== null && Number.isSafeInteger(parsed) && parsed >= 0;\n // An id we cannot read is not a fresh client: it had history and lost it, so\n // it gets a gap rather than silence.\n const forceGap = header !== null && !usable;\n\n const stream = new ReadableStream<Uint8Array>(\n createSseSource(broker, topic, {\n ...(usable ? { replayFrom: parsed } : {}),\n forceGap,\n ...(heartbeatMs === undefined ? {} : { heartbeatMs }),\n }),\n );\n\n return new Response(stream, {\n status: 200,\n headers: {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-cache\",\n connection: \"keep-alive\",\n \"x-accel-buffering\": \"no\", // defeat proxy buffering that would stall delivery\n },\n });\n };\n\n return {\n handler,\n publish: (topic, data, event) => void broker.publish(topic, data, event),\n subscriberCount: (topic) => broker.subscriberCount(topic),\n topicCount: () => broker.topicCount(),\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-http-events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Generic publish/subscribe over Server-Sent Events: a standard FetchHandler and a matching client.",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/statewalker/webrun-wire.git",
|
|
10
|
+
"directory": "packages/webrun-http-events"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/statewalker/webrun-wire/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/statewalker/webrun-wire/tree/main/packages/webrun-http-events#readme",
|
|
7
16
|
"license": "MIT",
|
|
8
17
|
"exports": {
|
|
9
18
|
".": {
|
|
@@ -21,8 +30,8 @@
|
|
|
21
30
|
"playwright": "^1.63.0",
|
|
22
31
|
"tsdown": "^0.23.0",
|
|
23
32
|
"typescript": "^7.0.2",
|
|
24
|
-
"vitest": "^5.0.
|
|
25
|
-
"@statewalker/webrun-http-browser": "^0.
|
|
33
|
+
"vitest": "^5.0.5",
|
|
34
|
+
"@statewalker/webrun-http-browser": "^0.13.0"
|
|
26
35
|
},
|
|
27
36
|
"sideEffects": false,
|
|
28
37
|
"publishConfig": {
|
package/src/handler.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { type Broker, type BrokerOptions, newBroker, type Unsubscribe } from "./broker.js";
|
|
2
2
|
import { formatSseEvent } from "./sse.js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* A handler: sync or async, as a Service Worker's or a Durable Object's `fetch`. The same type as
|
|
6
|
+
* `FetchHandler` in `@statewalker/webrun-http-streams` (a type test there keeps them identical),
|
|
7
|
+
* declared here so this package keeps zero runtime dependencies.
|
|
8
|
+
*/
|
|
9
|
+
export type FetchHandler = (request: Request) => Response | Promise<Response>;
|
|
5
10
|
|
|
6
11
|
export interface PubSubOptions extends BrokerOptions {
|
|
7
12
|
/**
|