@statewalker/webrun-http-events 0.1.0 → 0.1.1

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.
Files changed (2) hide show
  1. package/README.md +154 -39
  2. package/package.json +11 -2
package/README.md CHANGED
@@ -1,16 +1,51 @@
1
1
  # @statewalker/webrun-http-events
2
2
 
3
- Generic publish/subscribe over [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html):
4
- a standard `FetchHandler` on the server side and a matching `EventSource` client on the page side.
3
+ ## What it is
5
4
 
6
- No runtime dependencies, no DOM APIs on the server half (it runs in a Worker, a ServiceWorker or
7
- Node), and no vocabulary of its own — topics and event names are yours.
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
- npm install @statewalker/webrun-http-events
26
+ pnpm add @statewalker/webrun-http-events
11
27
  ```
12
28
 
13
- ## Server
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(options)`:
28
-
29
- | option | meaning |
30
- | --- | --- |
31
- | `topics` | Allow-list of topic names. Without it, any request path allocates a topic — set it on a public endpoint. |
32
- | `bufferSize` | Events retained per topic for `Last-Event-ID` replay (default 64). |
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` to anything but `GET` and `404` to an unknown or empty topic.
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
- ## Client
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 names" below
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
- ### Event names
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
- A frame published with a name (`publish(topic, data, "rebuilt")`) is dispatched by `EventSource`
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
- `gap`, `error` and `open` are **reserved**: they are the client's own control channels, reported
62
- through `onGap`, `onError` and `onOpen`. Listing one as a data event throws a `TypeError` — a gap
63
- delivered as data is exactly the failure the gap contract exists to prevent.
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
- ### Gaps
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
- Every event carries an id. A reconnecting `EventSource` sends `Last-Event-ID`, and the server
68
- replays what it still retains. When it cannot reconstruct the client's history — the events were
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
- ### Permanent failure
154
+ ### A permanent failure is reported once
74
155
 
75
- `EventSource` reconnects on its own after a dropped connection, but a non-2xx response or a wrong
76
- content-type closes it for good. That is reported through `onError` (check `readyState === 2`); a
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
- ## Notes
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
- - Reentrant publishing — calling `publish` from inside a subscriber — is not supported: the inner
82
- event is delivered to completion first, so subscribers observe ids out of order.
83
- - A payload JSON cannot represent (`undefined`, a function, a symbol, a BigInt, a circular object)
84
- is framed as `null`; the event still reaches the client with its id and name.
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/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-http-events",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
  ".": {
@@ -22,7 +31,7 @@
22
31
  "tsdown": "^0.23.0",
23
32
  "typescript": "^7.0.2",
24
33
  "vitest": "^5.0.3",
25
- "@statewalker/webrun-http-browser": "^0.6.3"
34
+ "@statewalker/webrun-http-browser": "^0.6.4"
26
35
  },
27
36
  "sideEffects": false,
28
37
  "publishConfig": {