@dokebi/sse 0.0.0-stage → 0.4.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/AI.md ADDED
@@ -0,0 +1,148 @@
1
+ # AI guide — @dokebi/sse 0.4.0
2
+
3
+ Server-sent events (SSE) for @dokebi/web apps. The server writes events to authenticated streams and, with `eventBus`, sends them through @dokebi/event-bus to the streams of other workers and servers too. The browser client is `./client` of the same package (plus `./client/vue`, `./client/react`, `./client/bundle`). At most once (no ACK, no resending, no retention). Node >=24.0.0, core ^0.4.0, @dokebi/web (required), @dokebi/event-bus (only with `eventBus`), @dokebi/health (check `sse` when present). The decision is [D-0060](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0060-sse.md); the human-oriented description is the [README](README.md).
4
+
5
+ ## 1. Install
6
+
7
+ ```sh
8
+ npx dokebi add @dokebi/sse
9
+ ```
10
+
11
+ > `npx dokebi` is the @dokebi/cli installed in the project (`dokebi init` adds it to devDependencies). The npm package `dokebi` is not this CLI, so never use `npx -y dokebi`. Without an installed CLI use `npx -y @dokebi/cli …`.
12
+
13
+ - With `eventBus` the CLI requires dokebi-event-bus (install module `requires`). Define the bus in `dokebi-event-bus.buses.<name>`. For several workers or servers use `driver: nats`.
14
+ - For a single process, leave out `eventBus` (publish then reaches only this process's streams).
15
+
16
+ ## 2. Options `options.dokebi-sse`
17
+
18
+ | Key | Type | Default | Description |
19
+ |---|---|---|---|
20
+ | `eventBus` | string | none | event-bus bus name that carries publications to other processes (named `bus` before 0.4.0, D-0071) |
21
+ | `bus` | string | — | deprecated name of `eventBus`: warns `OPTION_DEPRECATED`; setting both is `INVALID_OPTIONS` |
22
+ | `busEvent` | string | `dokebi.sse` | the bus event name |
23
+ | `allowInCluster` | boolean | `false` | allow starting in a cluster worker without a NATS bus (only for a single worker) |
24
+ | `retryMs` | integer | 3000 | the first `retry:` sent (browser reconnect delay); also the client backoff's minimum |
25
+ | `heartbeatMs` | integer | 15000 | interval of the `: ping` comment. Keep it shorter than the proxy idle limit |
26
+ | `maxConnections` | integer | 1024 | streams per process (worker). Beyond it 503 `CAPACITY` |
27
+ | `maxBufferedBytes` | integer | 1048576 | bytes waiting to be sent on one stream. Beyond it the stream is cut with warn `SLOW_CONSUMER` |
28
+ | `maxRooms` | integer | 32 | rooms per stream |
29
+ | `maxEventBytes` | integer | 65536 | bytes of an event's data (JSON). Keep it below the bus `maxPayloadBytes` |
30
+ | `shutdownTimeoutMs` | integer | 2000 | how long drain waits for streams to end after `dokebi-restart` (named `closeTimeoutMs` before 0.4.0) |
31
+ | `closeTimeoutMs` | integer | — | deprecated name of `shutdownTimeoutMs` (D-0071) |
32
+
33
+ **Cluster check (`LOCAL_IN_CLUSTER`)**: in a node:cluster worker, the start fails with an error log (with `fix`) when there is no `eventBus` or its driver is not `nats`. The event-bus API does not report the driver, so **the app setting `dokebi-event-bus.buses.<eventBus>.driver` is read (and the old name `transport`)**. When the event-bus options were passed only in code, the driver is unknown (`unknown`) and the start is refused. Move them to the configuration file, or, if there really is only one worker, set `allowInCluster: true`.
34
+
35
+ ## 3. Server API
36
+
37
+ ```ts snippet
38
+ import { createPlugin, dependency, type SseApi } from '@dokebi/sse';
39
+ const sse = context.getDependency(dependency); // created from this package when missing
40
+ sse.route({
41
+ path: '/events', // GET only (no HEAD)
42
+ constraints: { dokebiApp: 'shop' }, // optional: @dokebi/app domain routing
43
+ origins: ['https://admin.example.com'], // optional: allow other origins (CORS + credentials); '*' without credentials
44
+ version: APP_VERSION, // optional: D-0033
45
+ authenticate: (request) => ({ tenant, member, rooms: ['r1'] }), // undefined → 401
46
+ onOpen: (stream) => { stream.join(`user:${stream.member}`); stream.send('snapshot', data); },
47
+ onClose: (stream, reason) => {}, // CLIENT_CLOSED, SERVER_CLOSED, SLOW_CONSUMER, DRAIN, ON_OPEN_FAILED, WRITE_FAILED
48
+ });
49
+ const { id, local } = await sse.publish({ tenant, member, room }, 'order.changed', { id: 42 }); // member and room are optional
50
+ sse.connections({ tenant, room }); // the count on this worker
51
+ ```
52
+
53
+ - `route` works only in setup (`FROZEN` afterwards). `authenticate` and `onOpen` run on every reconnect.
54
+ - Targets: `{tenant}` everyone, `{tenant, member}` all tabs of one person, `{tenant, room}` a room, `{tenant, room, member}` that person in the room. Strings are 1 to 256 characters.
55
+ - Prefix targets (D-0061): with `tenantPrefix` instead of `tenant` (exactly one of them, otherwise `INVALID_TARGET`), every tenant whose name starts with that prefix (with the same `member` and `room` rules). Bus messages carry the same target, and each process scans its own tenant index once. @dokebi/app's namespaces `<ns>:` use it.
56
+ - Event names: `^[A-Za-z][A-Za-z0-9._:-]{0,63}$`, not starting with `dokebi-` (reserved for the plugin). data is one line of JSON (`undefined` becomes `null`); functions, BigInt and cycles are `INVALID_DATA`.
57
+ - `publish` order: check target, name and data → when the bus is not `ready`, `BUS_UNAVAILABLE` (nothing is sent anywhere, error log) → write to this process's streams → publish on the bus. A failed bus publish is `BUS_PUBLISH_FAILED` (`cause`, `id`, `local`; this process already received it, other places are unknown; the error log has the full error). Whether to send again is the caller's choice (duplicates possible).
58
+ - The sending process skips its own bus messages (`origin`): with a local or a NATS bus, that process's streams receive each event once.
59
+ - `stream.send` writes to one connection only and returns true when written (false when closed or cut for being slow). Events get an `id` that increases in order within this process.
60
+ - Rooms are per connection. After a reconnect they are empty, so join in `authenticate`'s `rooms` or in `onOpen`.
61
+ - `parseOptions` (the options parser) and the API types (`SseApi`, `SseRoute`, `SseStream`, …) are exported too.
62
+
63
+ ## 3a. Mounting on @dokebi/app (D-0061)
64
+
65
+ ```ts snippet
66
+ export default defineApp({
67
+ events: (app) => [{
68
+ path: '/events',
69
+ authenticate: async (request) => {
70
+ const user = await userOf(request);
71
+ return user && { tenant: user.shop, member: user.id }; // namespace?: one of the app's list (default the first)
72
+ },
73
+ onOpen: (stream) => stream.send('hello', { tenant: stream.tenant }), // 'front:shop-1'
74
+ }],
75
+ async start(app) {
76
+ await app.realtime().publish({ tenant: 'shop-1' }, 'order.changed', { id: 42 });
77
+ },
78
+ });
79
+ ```
80
+
81
+ - Streams attach only to the app's domains (constraint `dokebiApp`), with the same module prefix rules as sockets (the module `events` hook, @dokebi/module 0.1.3). The tenant is `<namespace>:<tenant>` (the default namespace is the app name). An authenticated `namespace` outside the app's list gets 401 and warn `NAMESPACE_FORBIDDEN`. app removes `namespace`, so it is not in `stream.identity`.
82
+ - Realtime contribution point `dokebi-web:realtime` (D-0063 §5, `realtime` of `@dokebi/web/points`): in setup this plugin contributes `{ kind: 'sse', mount(route, scope), handle(namespace) }`. `mount` adds `scope.path` and `scope.constraints`, turns the `authenticate` result into `scope.identity` and registers the route; `handle(ns).publish({ tenant?, member?, room? }, event, data)` sends with the `<ns>:` prefix. Until @dokebi/app collects the point it is unused and nothing changes.
83
+ - The cluster worker check (`LOCAL_IN_CLUSTER`) uses core's `context.runtime.role === 'worker'` (it never reads `node:cluster` itself).
84
+ - When an app has `events` but this package is missing, the start fails (`has event streams; add @dokebi/sse`), and `app.realtime().publish` throws `REALTIME_UNAVAILABLE`.
85
+
86
+ ## 4. Connection lifetime
87
+
88
+ - Response: 200 `text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`, `Connection: keep-alive`. The first lines are `retry: <retryMs>` and `event: dokebi-open` (data `{"retryMs":…}`). The Fastify response is hijacked and written directly, so no compression hook applies (dokebi-web does not compress).
89
+ - Refusals: another origin (not listed) 403 `ORIGIN_FORBIDDEN`; before start or while stopping 503 (dokebi-web `STARTING`, `DRAINING`; this plugin `CLOSING`); full 503 `CAPACITY` (`Retry-After: 5`); no authentication 401. EventSource cannot see these statuses (the client below tells them apart with a probe).
90
+ - `?dokebiProbe=1`: checks only origin, capacity and authentication and answers 204 (no stream is opened). The browser client uses it to find out why a connection failed.
91
+ - Version (D-0033): when the route's `version` differs from the query `dokebiVersion` (or the `x-dokebi-version` header), it sends `retry: 60000` + `event: dokebi-reload` (data `{"version":…}`) and ends.
92
+ - The `Last-Event-ID` header (when the browser reconnects by itself) or the query `lastEventId` (when the client reopens itself) becomes `stream.lastEventId`. The server keeps no events.
93
+ - Heartbeat: `: ping` every `heartbeatMs`. Slow receivers: when the waiting bytes plus a new event exceed `maxBufferedBytes`, that stream is cut (`SLOW_CONSUMER`; other streams are unaffected).
94
+ - Drain (stop, rolling restart): refuse new streams → unsubscribe from the bus → send `event: dokebi-restart` (data `{"retryMs":…}`) to every stream → end them → cut the remaining connections after `shutdownTimeoutMs` (or the core drain deadline) → wait for onClose. It drains before dokebi-web.
95
+ - Health: with dokebi-health it registers the non-required check `sse`. When the bus is not `ready` the check is `failed` (`BUS_UNAVAILABLE`, warn log), and `ready` again when it returns (info log). It never drops readiness.
96
+
97
+ ## 5. Browser client
98
+
99
+ ```ts snippet
100
+ import { connectEvents } from '@dokebi/sse/client';
101
+ const events = connectEvents({
102
+ path: '/events', // or url: 'https://…'
103
+ query: { tab: 'orders' },
104
+ version: APP_VERSION, // the dokebiVersion query
105
+ withCredentials: false, // send cookies to another origin (that origin must be in the server's origins)
106
+ onReload: () => location.reload(), // the default
107
+ reconnect: { minMs: 1000, maxMs: 30000 }, // false stays closed after a failure
108
+ });
109
+ const off = events.on<Order>('order.changed', (order, { id }) => {});
110
+ events.onStatus((status) => {}); // connecting · open · reconnecting · paused · closed
111
+ events.onError((error) => {}); // { code: UNAUTHORIZED, FORBIDDEN, UNAVAILABLE, CONNECTION_FAILED, BAD_DATA, HANDLER_FAILED, NO_EVENTSOURCE, status? }
112
+ events.close(); events.reconnect();
113
+ ```
114
+
115
+ - Importing touches neither window nor EventSource (SSR safe). It opens when you call `connectEvents`.
116
+ - A listener is added to the EventSource only on the first `on` per event name. data is read as JSON (a failure is `BAD_DATA`).
117
+ - `dokebi-reload` → `onReload` (default `location.reload()`); it does not reconnect.
118
+ - `dokebi-restart` → close, then reopen after backoff with jitter (starting at `max(minMs, server retry)`, doubling up to `maxMs`, 50–100% of that value).
119
+ - When EventSource reconnects by itself (the stream ended, network): left alone (`reconnecting`; the browser sends `Last-Event-ID`).
120
+ - When EventSource gave up (HTTP error, wrong format, readyState CLOSED): close and ask why with `?dokebiProbe=1`. 401 or 403 → `closed` (permanent); anything else reopens after backoff. When reopening itself it sends the last id as the `lastEventId` query (it knows only the ids of events it listens to).
121
+ - bfcache: closes on `pagehide` (persisted) (`paused`) and reopens on `pageshow` (persisted).
122
+ - Vue `useEvents(options)` → `{ status, client, on, close, reconnect }` (connects on mount, closes at the end of the scope; outside a component it connects at once), `useEvent(events, name, handler)`.
123
+ - React `useEvents(options)` → `{ status, client }` (the same url, path, query, version and withCredentials share one EventSource with reference counting; it closes on the tick after the last user is gone; the callbacks are those of whoever opened it first), `useEvent(name, handler, events?)` (without `events`, the nearest `<EventsProvider options>`).
124
+ - Bundle: `@dokebi/sse/client/bundle` = `dist/browser/sse-client.js` (an ES module without dependencies). Serve it as a static file and use it from `<script type="module">` or an import map.
125
+
126
+ ## 5a. Seal hosts (D-0062)
127
+
128
+ On streams of a seal host (@dokebi/app `domains.<host>.seal: true`) or of a `seal: true` web route, **the data of every event** (including `dokebi-open`, `dokebi-reload` and `dokebi-restart`) is sealed with that stream's path (`E:<ts>:<b64>`, dokebi-web `sealOf`). Event names and ids stay plain. Plain streams share one buffer; only sealed streams seal per receiver. A failed seal ends that stream (`SEAL_ENCODE_FAILED`; never sent in plain text). Browser: `connectEvents({ ..., seal: currentSealBridge() })` (`@dokebi/seal/http`; undefined on plain pages, so the same code works). With a bridge, plain data is refused as `SEAL_FAILED`; without one, sealed data is refused as `SEAL_BRIDGE_MISSING` (neither reaches the handler).
129
+
130
+ ## 6. Several workers and servers
131
+
132
+ - No sticky sessions needed. A NATS bus is required. `maxConnections` is per worker.
133
+ - Order: per publisher order is kept; there is no global order. During a NATS outage publish fails with `BUS_UNAVAILABLE`/`BUS_PUBLISH_FAILED` and that event is lost.
134
+ - Rolling restart: streams of the stopping worker get `dokebi-restart`, and clients reconnect to other workers with jitter.
135
+ - HTTP/1.1 browsers allow 6 connections per origin (one stream per tab) → with many tabs use HTTP/2 (TLS) at the proxy. Proxy idle limit > `heartbeatMs`, and turn response buffering off.
136
+
137
+ ## 7. Logs (D-0047)
138
+
139
+ debug: route registration, stream open and close (reason, ms, open count), refusal reasons, version refusals, room join and leave, publish (id, local), bus deliveries received, drain begin and end. warn: `SLOW_CONSUMER`, `BUS_UNAVAILABLE` (state change). error (the full error; repeats once per 10 seconds with `repeated`): `BUS_PUBLISH_FAILED`, `BUS_UNAVAILABLE` (publish refused), `BUS_MESSAGE_INVALID`, `SSE_ON_OPEN_FAILED`, `SSE_ON_CLOSE_FAILED`, `STREAM_WRITE_FAILED`, `BUS_UNSUBSCRIBE_FAILED`, `SSE_LOCAL_IN_CLUSTER`. A bad `authenticate` result (`INVALID_IDENTITY`) is logged by dokebi-web as 500 with `WEB_REQUEST_FAILED`.
140
+
141
+ ## 8. Errors
142
+
143
+ `SseError` (branch on `code`; never `instanceof`).
144
+
145
+ <!-- docs-check: complete-error-codes SseErrorCode -->
146
+ Configuration and registration: `INVALID_OPTIONS`, `INVALID_ROUTE` (`path`, `origins` and so on), `FROZEN` (`route` after setup), `LOCAL_IN_CLUSTER` (§2; `SSE_LOCAL_IN_CLUSTER` before 0.4.0, D-0071). Connections: `INVALID_IDENTITY` (a bad `authenticate` result, 500). Sending: `INVALID_TARGET`, `INVALID_EVENT` (event name), `INVALID_DATA`, `EVENT_TOO_LARGE` (`maxEventBytes`), `INVALID_ROOM`, `ROOM_LIMIT` (`maxRooms`), `BUS_UNAVAILABLE`, `BUS_PUBLISH_FAILED` (`cause`, `id`, `local`, §3), `CLOSED` (`publish` after the app stopped).
147
+
148
+ `SseError` is a `DokebiError` (D-0063): `plugin: 'dokebi-sse'` and a `fix` that says how to repair it. `INVALID_OPTIONS` carries per-path problems in `details.issues` (no values). The `defineOptions` declaration in `src/options.ts` is the parser, and the build writes `options.schema.json` from it (D-0063 §3; never edit it by hand).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dokebi contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,88 @@
1
- # Temporary Holding Version
1
+ # @dokebi/sse
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Server-sent events (SSE, `text/event-stream`) for Dokebi web apps (@dokebi/web). It opens a stream per authenticated user and sends events chosen by tenant, member and room. Given a NATS bus of @dokebi/event-bus, events also reach the streams of other workers and servers. The browser client (`@dokebi/sse/client`, Vue and React hooks, a bundle usable without a build) is in the same package. Node >=24.0.0, @dokebi/core ^0.4.0. The decision record is [D-0060](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0060-sse.md).
4
+
5
+ The guarantee is **at most once** (no ACK, no resending, no retention; the same as [D-0057](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0057-websocket-broadcast-without-ack.md)). Keep state that must not be missed in a DB and use events as "it changed, read it again" notices.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npx dokebi add @dokebi/sse
11
+ ```
12
+
13
+ > `npx dokebi` is the @dokebi/cli installed in the project (`dokebi init` adds it to devDependencies). The npm package `dokebi` is not this CLI, so never use `npx -y dokebi`. Without an installed CLI use `npx -y @dokebi/cli …`.
14
+
15
+ A single process needs no settings. With several workers or servers a NATS bus is required:
16
+
17
+ ```yaml
18
+ options:
19
+ dokebi-nats:
20
+ connections: { main: { servers: [nats://127.0.0.1:4222] } }
21
+ dokebi-event-bus:
22
+ buses: { sse: { driver: nats, connection: main } }
23
+ dokebi-sse:
24
+ eventBus: sse
25
+ ```
26
+
27
+ Before 0.4.0 the key was `bus` (and `closeTimeoutMs` is now `shutdownTimeoutMs`); the old names still work with an `OPTION_DEPRECATED` warning ([D-0071](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0071-option-vocabulary.md)).
28
+
29
+ ## Server
30
+
31
+ ```ts snippet
32
+ const events = context.get(sse); // or context.getDependency(dependency)
33
+ events.route({
34
+ path: '/events',
35
+ version: APP_VERSION, // a different one gets dokebi-reload (D-0033)
36
+ authenticate: async (request) => { // undefined → 401
37
+ const user = await request.session.get('user');
38
+ return user ? { tenant: user.shop, member: user.id, rooms: [`shop:${user.shop}`] } : undefined;
39
+ },
40
+ onOpen: (stream) => stream.send('snapshot', { unread: 3 }),
41
+ });
42
+
43
+ await events.publish({ tenant: 'shop-1' }, 'order.changed', { id: 42 }); // the whole tenant
44
+ await events.publish({ tenant: 'shop-1', member: 'u7' }, 'notice', { text: 'Hello' }); // one person
45
+ await events.publish({ tenant: 'shop-1', room: 'order:42' }, 'order.changed', { id: 42 }); // a room
46
+ await events.publish({ tenantPrefix: 'front:' }, 'notice', { text: 'Maintenance' }); // every tenant with the prefix
47
+ ```
48
+
49
+ | API | Meaning |
50
+ |---|---|
51
+ | `route({ path, constraints?, origins?, version?, authenticate, onOpen?, onClose? })` | setup only. A GET stream route |
52
+ | `publish(target, event, data)` | `{ id, local }`. To matching streams of every process (the sending process once). The target is `{ tenant }` or `{ tenantPrefix }` (exactly one) plus `member?` and `room?` |
53
+ | `connections(filter?)` | the number of open streams of this process (worker). `{ tenant?, member?, room?, path? }` |
54
+ | `stream.send(event, data)` · `join(room)` · `leave(room)` · `close()` | one connection only |
55
+
56
+ **With @dokebi/app (D-0061):** an app's `events: (app) => [route, …]` attaches streams only to the app's domains (route constraint `dokebiApp`). The stream tenant is `<namespace>:<tenant>`; when `authenticate` returns `namespace`, it is chosen from the app's namespaces (outside the list: 401). Server code publishes to that space with `app.realtime(namespace).publish({ tenant?, member?, room? }, event, data)` (without a tenant it is `tenantPrefix: '<namespace>:'`). See "Realtime namespaces" in the @dokebi/app README.
57
+
58
+ Branch on error `code`s: `INVALID_EVENT` (naming rule, `dokebi-` prefix), `INVALID_DATA`, `EVENT_TOO_LARGE`, `INVALID_TARGET`, `ROOM_LIMIT`, `BUS_UNAVAILABLE` (sent nowhere), `BUS_PUBLISH_FAILED` (sent to this process, other places unknown; with `id` and `local`).
59
+
60
+ ## Browser
61
+
62
+ ```ts snippet
63
+ import { connectEvents } from '@dokebi/sse/client';
64
+ const events = connectEvents({ path: '/events', version: APP_VERSION });
65
+ const off = events.on('order.changed', (order) => refresh(order.id)); // data parsed as JSON
66
+ events.onStatus((status) => badge(status)); // connecting · open · reconnecting · paused · closed
67
+ ```
68
+
69
+ - Vue: `import { useEvents, useEvent } from '@dokebi/sse/client/vue'`. Opens on mount and closes when the scope ends (never opens during SSR).
70
+ - React: `import { useEvents, useEvent, EventsProvider } from '@dokebi/sse/client/react'`. Hooks with the same settings share one EventSource (StrictMode safe).
71
+ - Pages without a build: serve `@dokebi/sse/client/bundle` (`dist/browser/sse-client.js`, a single ES module without dependencies) as a static file and use `<script type="module">import { connectEvents } from '/sse-client.js'</script>`.
72
+ - With the standard EventSource only: `es.addEventListener('dokebi-reload', () => location.reload())`, and reopen a little later after `dokebi-restart`.
73
+
74
+ ## Several workers and servers
75
+
76
+ - No sticky sessions needed. Whatever worker a client connects to, it receives the same events through the NATS bus.
77
+ - With several workers, `eventBus` (driver `nats`) is required. Without it a cluster worker does not start (`LOCAL_IN_CLUSTER`; with a deliberate single worker set `allowInCluster: true`).
78
+ - `maxConnections` is per worker.
79
+ - Rooms belong to their connection and are gone after a reconnect: join again in `authenticate`'s `rooms` or in `onOpen`.
80
+ - Order: what one publisher sends arrives in order; there is no global order across publishers. When NATS is down, `publish` fails with a coded error and that event is lost.
81
+ - Browsers allow 6 connections per origin over HTTP/1.1. With many tabs use HTTP/2 (TLS) at the proxy.
82
+ - The proxy idle limit must be longer than `heartbeatMs` (default 15 seconds). nginx honours `X-Accel-Buffering: no`.
83
+
84
+ Detailed settings and behaviour are in [AI.md](AI.md) and the [design notes](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/plugins/dokebi-sse/README.md); a runnable example is [examples/27-sse](https://git.nyx-zone.com/dokebi/framework/-/blob/main/examples/27-sse/README.md).
85
+
86
+ ## Seal hosts (D-0062)
87
+
88
+ On a seal host the data of every event is sealed. The browser opens it with `connectEvents({ path, seal: currentSealBridge() })` (`@dokebi/seal/http`). See [AI.md](AI.md) §5a and [D-0062](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0062-http-seal.md).
@@ -0,0 +1,201 @@
1
+ //#region client/index.ts
2
+ var e = "dokebi-reload", t = "dokebi-restart", n = "dokebi-open";
3
+ function r(e) {
4
+ let t = /^\{"retryMs":(\d{1,9})\}$/.exec(e);
5
+ return t ? Number(t[1]) : void 0;
6
+ }
7
+ function i(i) {
8
+ let a = i.EventSource ?? globalThis.EventSource, o = i.reconnect !== !1, s = i.reconnect === !1 ? 0 : Math.max(0, i.reconnect?.minMs ?? 1e3), c = i.reconnect === !1 ? 0 : Math.max(s, i.reconnect?.maxMs ?? 3e4), l = /* @__PURE__ */ new Map(), u = /* @__PURE__ */ new Map(), d = /* @__PURE__ */ new Set(), f = /* @__PURE__ */ new Set(), p, m = "connecting", h, g = 0, _ = 0, v, y = !1, b = !1, x = !1, S = i.url ?? i.path;
9
+ if (typeof S != "string" || !S) throw TypeError("connectEvents needs url or path");
10
+ let C = i.seal;
11
+ if (C !== void 0 && typeof C?.unsealFrame != "function") throw TypeError("connectEvents seal needs unsealFrame(frame, url)");
12
+ let w;
13
+ function T(e) {
14
+ if (m !== e) {
15
+ m = e, i.onStatus?.(e);
16
+ for (let t of [...d]) t(e);
17
+ }
18
+ }
19
+ function E(e) {
20
+ if (!i.onError && !f.size) console.warn(`[dokebi-sse] ${e.code}`, e.error ?? e.status ?? "");
21
+ else {
22
+ i.onError?.(e);
23
+ for (let t of [...f]) t(e);
24
+ }
25
+ }
26
+ function D(e) {
27
+ let t = globalThis.location?.href, n = new URL(S, t);
28
+ for (let [e, t] of Object.entries(i.query ?? {})) t != null && n.searchParams.set(e, String(t));
29
+ i.version !== void 0 && n.searchParams.set("dokebiVersion", i.version), x && h !== void 0 && n.searchParams.set("lastEventId", h);
30
+ for (let [t, r] of Object.entries(e ?? {})) n.searchParams.set(t, r);
31
+ return w = n.pathname, n.href;
32
+ }
33
+ function O(e, t) {
34
+ let n = t.startsWith("E:");
35
+ if (C) try {
36
+ if (!n) throw Error("plain data on a sealed stream");
37
+ return C.unsealFrame(t, w ?? "/");
38
+ } catch (t) {
39
+ E({
40
+ code: "SEAL_FAILED",
41
+ event: e,
42
+ error: t
43
+ });
44
+ return;
45
+ }
46
+ if (!n) return t;
47
+ E({
48
+ code: "SEAL_BRIDGE_MISSING",
49
+ event: e
50
+ });
51
+ }
52
+ function k(e) {
53
+ return (t) => {
54
+ t.lastEventId && (h = t.lastEventId);
55
+ let n = O(e, t.data);
56
+ if (n === void 0) return;
57
+ let r;
58
+ try {
59
+ r = JSON.parse(n);
60
+ } catch (t) {
61
+ E({
62
+ code: "BAD_DATA",
63
+ event: e,
64
+ error: t
65
+ });
66
+ return;
67
+ }
68
+ let i = {
69
+ event: e,
70
+ id: t.lastEventId || void 0
71
+ };
72
+ for (let t of [...l.get(e) ?? []]) try {
73
+ t(r, i);
74
+ } catch (t) {
75
+ E({
76
+ code: "HANDLER_FAILED",
77
+ event: e,
78
+ error: t
79
+ });
80
+ }
81
+ };
82
+ }
83
+ function A() {
84
+ v !== void 0 && clearTimeout(v), v = void 0;
85
+ }
86
+ function j() {
87
+ p &&= (p.onopen = null, p.onerror = null, p.close(), void 0);
88
+ }
89
+ function M() {
90
+ if (A(), y || b) return;
91
+ if (!a) {
92
+ y = !0, T("closed"), E({ code: "NO_EVENTSOURCE" });
93
+ return;
94
+ }
95
+ let o = new a(D(), i.withCredentials ? { withCredentials: !0 } : void 0);
96
+ p = o, o.onopen = () => {
97
+ p === o && (_ = 0, T("open"));
98
+ }, o.onerror = () => {
99
+ p === o && N(o);
100
+ }, o.addEventListener(n, (e) => {
101
+ g = r(O(n, e.data) ?? "") ?? g;
102
+ }), o.addEventListener(e, (t) => {
103
+ p === o && O(e, t.data) !== void 0 && I();
104
+ }), o.addEventListener(t, (e) => {
105
+ p === o && (g = r(O(t, e.data) ?? "") ?? g, j(), T("reconnecting"), F());
106
+ });
107
+ for (let [e, t] of u) o.addEventListener(e, t);
108
+ }
109
+ function N(e) {
110
+ if (e.readyState !== 2) {
111
+ T("reconnecting");
112
+ return;
113
+ }
114
+ if (j(), !o) {
115
+ y = !0, T("closed"), E({ code: "CONNECTION_FAILED" });
116
+ return;
117
+ }
118
+ T("reconnecting");
119
+ let t = globalThis.fetch;
120
+ i.probe === !1 || !t ? (E({ code: "CONNECTION_FAILED" }), F()) : P(t);
121
+ }
122
+ async function P(e) {
123
+ let t;
124
+ try {
125
+ t = (await e(D({ dokebiProbe: "1" }), {
126
+ credentials: i.withCredentials ? "include" : "same-origin",
127
+ cache: "no-store"
128
+ })).status;
129
+ } catch (e) {
130
+ E({
131
+ code: "CONNECTION_FAILED",
132
+ error: e
133
+ }), F();
134
+ return;
135
+ }
136
+ y || b || (t === 401 || t === 403 ? (y = !0, A(), T("closed"), E({
137
+ code: t === 401 ? "UNAUTHORIZED" : "FORBIDDEN",
138
+ status: t
139
+ })) : (E({
140
+ code: t === 503 ? "UNAVAILABLE" : "CONNECTION_FAILED",
141
+ status: t
142
+ }), F()));
143
+ }
144
+ function F() {
145
+ if (A(), y || b) return;
146
+ if (!o) {
147
+ y = !0, T("closed");
148
+ return;
149
+ }
150
+ let e = Math.max(s, g), t = Math.min(Math.max(c, e), e * 2 ** _);
151
+ _ = Math.min(_ + 1, 20), x = !0, v = setTimeout(M, Math.round(t / 2 + Math.random() * t / 2));
152
+ }
153
+ function I() {
154
+ y = !0, j(), A(), T("closed"), i.onReload ? i.onReload() : globalThis.location?.reload();
155
+ }
156
+ let L = globalThis, R = (e) => {
157
+ e.persisted && !y && (b = !0, j(), A(), T("paused"));
158
+ }, z = (e) => {
159
+ e.persisted && !y && b && (b = !1, _ = 0, x = !0, T("connecting"), M());
160
+ };
161
+ return L.addEventListener?.("pagehide", R), L.addEventListener?.("pageshow", z), M(), {
162
+ get status() {
163
+ return m;
164
+ },
165
+ get lastEventId() {
166
+ return h;
167
+ },
168
+ on(e, t) {
169
+ if (typeof e != "string" || !e || typeof t != "function") throw TypeError("on(event, handler) needs an event name and a function");
170
+ let n = l.get(e);
171
+ if (!n) {
172
+ n = /* @__PURE__ */ new Set(), l.set(e, n);
173
+ let t = k(e);
174
+ u.set(e, t), p?.addEventListener(e, t);
175
+ }
176
+ let r = t;
177
+ return n.add(r), () => {
178
+ let t = l.get(e);
179
+ if (!t?.delete(r) || t.size) return;
180
+ l.delete(e);
181
+ let n = u.get(e);
182
+ u.delete(e), n && p?.removeEventListener(e, n);
183
+ };
184
+ },
185
+ onStatus(e) {
186
+ return d.add(e), () => d.delete(e);
187
+ },
188
+ onError(e) {
189
+ return f.add(e), () => f.delete(e);
190
+ },
191
+ close() {
192
+ y = !0, b = !1, j(), A(), L.removeEventListener?.("pagehide", R), L.removeEventListener?.("pageshow", z), T("closed");
193
+ },
194
+ reconnect() {
195
+ let e = y;
196
+ y = !1, b = !1, _ = 0, x = p !== void 0 || x, j(), e && (L.addEventListener?.("pagehide", R), L.addEventListener?.("pageshow", z)), T("connecting"), M();
197
+ }
198
+ };
199
+ }
200
+ //#endregion
201
+ export { i as connectEvents };
@@ -0,0 +1,90 @@
1
+ export type EventsStatus =
2
+ /** Opening the first time, or after reconnect(). */
3
+ 'connecting' | 'open'
4
+ /** Lost; the browser or this client is opening it again. */
5
+ | 'reconnecting'
6
+ /** The page went into the back/forward cache; reopened when it is shown again. */
7
+ | 'paused'
8
+ /** close(), dokebi-reload, 401/403, or a failure with reconnect: false. */
9
+ | 'closed';
10
+ export type EventsErrorCode = 'NO_EVENTSOURCE' | 'CONNECTION_FAILED' | 'UNAVAILABLE' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'BAD_DATA' | 'HANDLER_FAILED'
11
+ /** Sealed data arrived but no `seal` bridge was given (D-0062). */
12
+ | 'SEAL_BRIDGE_MISSING'
13
+ /** With a `seal` bridge: data that is plain or does not open. Never handed on as plain. */
14
+ | 'SEAL_FAILED';
15
+ export interface EventsError {
16
+ readonly code: EventsErrorCode;
17
+ /** The HTTP status the probe saw, when it saw one. */
18
+ readonly status?: number;
19
+ /** The event name, for BAD_DATA, HANDLER_FAILED and the SEAL_ codes. */
20
+ readonly event?: string;
21
+ readonly error?: unknown;
22
+ }
23
+ export interface EventMeta {
24
+ readonly event: string;
25
+ /** The event id (the server's `id:`), or undefined for events without one. */
26
+ readonly id: string | undefined;
27
+ }
28
+ export type EventHandler<T = unknown> = (data: T, meta: EventMeta) => void;
29
+ /** The part of the standard EventSource this client uses (a test may pass its own). */
30
+ export interface EventSourceLike {
31
+ readonly readyState: number;
32
+ onopen: ((event: unknown) => unknown) | null;
33
+ onerror: ((event: unknown) => unknown) | null;
34
+ addEventListener(type: string, listener: (event: MessageEventLike) => void): void;
35
+ removeEventListener(type: string, listener: (event: MessageEventLike) => void): void;
36
+ close(): void;
37
+ }
38
+ export interface MessageEventLike {
39
+ readonly data: string;
40
+ readonly lastEventId: string;
41
+ }
42
+ export type EventSourceConstructor = new (url: string, init?: {
43
+ withCredentials?: boolean;
44
+ }) => EventSourceLike;
45
+ export interface EventsOptions {
46
+ /** An absolute or page-relative stream URL. Use this or `path`. */
47
+ readonly url?: string;
48
+ /** A path on the page's origin, for example `/events`. */
49
+ readonly path?: string;
50
+ readonly query?: Readonly<Record<string, string | number | boolean | null | undefined>>;
51
+ /** Sent as `dokebiVersion`; a server whose route has another version answers dokebi-reload. */
52
+ readonly version?: string;
53
+ /** Sends cookies on a cross-origin stream (the server must list the page's origin). */
54
+ readonly withCredentials?: boolean;
55
+ /** On `dokebi-reload` (the page's code is out of date). Default: `location.reload()`. */
56
+ readonly onReload?: () => void;
57
+ /** Backoff of this client's own reopens; false leaves a failed stream closed. */
58
+ readonly reconnect?: {
59
+ readonly minMs?: number;
60
+ readonly maxMs?: number;
61
+ } | false;
62
+ /** Ask the server why a stream was refused (default true where fetch exists). */
63
+ readonly probe?: boolean;
64
+ /** Another EventSource implementation (default: the browser's). */
65
+ readonly EventSource?: EventSourceConstructor;
66
+ readonly onStatus?: (status: EventsStatus) => void;
67
+ readonly onError?: (error: EventsError) => void;
68
+ /**
69
+ * The seal bridge of a seal host (D-0062): opens each event's sealed `data` with the stream
70
+ * path. `currentSealBridge()` of `@dokebi/seal/http` gives it on sealed pages only.
71
+ */
72
+ readonly seal?: {
73
+ unsealFrame(frame: string, url: string): string;
74
+ };
75
+ }
76
+ export interface EventsClient {
77
+ readonly status: EventsStatus;
78
+ /** The last event id seen on an event this client listens to. */
79
+ readonly lastEventId: string | undefined;
80
+ /** Listens to one event name; `data` is the parsed JSON. Returns a function that stops. */
81
+ on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
82
+ onStatus(handler: (status: EventsStatus) => void): () => void;
83
+ onError(handler: (error: EventsError) => void): () => void;
84
+ /** Closes the stream; nothing reopens it except reconnect(). */
85
+ close(): void;
86
+ /** Closes and opens the stream now (also after close()). */
87
+ reconnect(): void;
88
+ }
89
+ /** Opens an event stream (browser only). */
90
+ export declare function connectEvents(options: EventsOptions): EventsClient;