@dokebi/sse 0.1.0 → 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 +94 -78
- package/README.md +44 -40
- package/dist/errors.d.ts +6 -4
- package/dist/errors.js +24 -4
- package/dist/gateway.js +32 -26
- package/dist/install.d.ts +1 -1
- package/dist/install.js +7 -5
- package/dist/options.d.ts +18 -3
- package/dist/options.js +47 -59
- package/dist/plugin.js +40 -32
- package/dist/transport.d.ts +13 -0
- package/dist/transport.js +45 -0
- package/dist/types.d.ts +12 -0
- package/options.schema.json +16 -3
- package/package.json +39 -17
- package/dist/log.d.ts +0 -25
- package/dist/log.js +0 -102
package/AI.md
CHANGED
|
@@ -1,69 +1,74 @@
|
|
|
1
|
-
# AI
|
|
1
|
+
# AI guide — @dokebi/sse 0.4.0
|
|
2
2
|
|
|
3
|
-
@dokebi/web
|
|
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
4
|
|
|
5
|
-
## 1.
|
|
5
|
+
## 1. Install
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
npx dokebi add @dokebi/sse
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
- 같은 프로세스 하나만이면 `bus` 없이 쓴다(publish는 이 프로세스 스트림에만).
|
|
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 …`.
|
|
13
12
|
|
|
14
|
-
|
|
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
15
|
|
|
16
|
-
|
|
17
|
-
|---|---|---|
|
|
18
|
-
| `bus` | 없음 | event-bus 버스 이름. 다른 프로세스로 퍼뜨린다 |
|
|
19
|
-
| `busEvent` | `dokebi.sse` | 버스 이벤트 이름 |
|
|
20
|
-
| `allowInCluster` | `false` | cluster 워커에서 NATS 버스 없이 시작 허용(워커 하나일 때만) |
|
|
21
|
-
| `retryMs` | 3000 | 처음 보내는 `retry:`(브라우저 재접속 간격). 클라이언트 백오프의 최소값 |
|
|
22
|
-
| `heartbeatMs` | 15000 | `: ping` 주석 간격. 프락시 idle 제한보다 짧게 |
|
|
23
|
-
| `maxConnections` | 1024 | 프로세스(워커)당 스트림. 넘으면 503 `CAPACITY` |
|
|
24
|
-
| `maxBufferedBytes` | 1048576 | 스트림 하나에 쌓인 송신 바이트 상한. 넘으면 끊고 warn `SLOW_CONSUMER` |
|
|
25
|
-
| `maxRooms` | 32 | 스트림 하나의 방 수 |
|
|
26
|
-
| `maxEventBytes` | 65536 | 이벤트 data(JSON) 바이트 상한. 버스 `maxPayloadBytes`보다 작게 |
|
|
27
|
-
| `closeTimeoutMs` | 2000 | drain이 `dokebi-restart` 뒤 스트림 종료를 기다리는 시간 |
|
|
16
|
+
## 2. Options `options.dokebi-sse`
|
|
28
17
|
|
|
29
|
-
|
|
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) |
|
|
30
32
|
|
|
31
|
-
|
|
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`.
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
## 3. Server API
|
|
36
|
+
|
|
37
|
+
```ts snippet
|
|
34
38
|
import { createPlugin, dependency, type SseApi } from '@dokebi/sse';
|
|
35
|
-
const sse = context.getDependency(dependency); //
|
|
39
|
+
const sse = context.getDependency(dependency); // created from this package when missing
|
|
36
40
|
sse.route({
|
|
37
|
-
path: '/events', // GET
|
|
38
|
-
constraints: { dokebiApp: 'shop' }, //
|
|
39
|
-
origins: ['https://admin.example.com'], //
|
|
40
|
-
version: APP_VERSION, //
|
|
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
|
|
41
45
|
authenticate: (request) => ({ tenant, member, rooms: ['r1'] }), // undefined → 401
|
|
42
46
|
onOpen: (stream) => { stream.join(`user:${stream.member}`); stream.send('snapshot', data); },
|
|
43
|
-
onClose: (stream, reason) => {}, // CLIENT_CLOSED
|
|
47
|
+
onClose: (stream, reason) => {}, // CLIENT_CLOSED, SERVER_CLOSED, SLOW_CONSUMER, DRAIN, ON_OPEN_FAILED, WRITE_FAILED
|
|
44
48
|
});
|
|
45
|
-
const { id, local } = await sse.publish({ tenant, member
|
|
46
|
-
sse.connections({ tenant, room }); //
|
|
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
|
|
47
51
|
```
|
|
48
52
|
|
|
49
|
-
- `route
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
- `publish`
|
|
54
|
-
-
|
|
55
|
-
- `stream.send
|
|
56
|
-
-
|
|
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.
|
|
57
62
|
|
|
58
|
-
## 3a. @dokebi/app
|
|
63
|
+
## 3a. Mounting on @dokebi/app (D-0061)
|
|
59
64
|
|
|
60
|
-
```ts
|
|
65
|
+
```ts snippet
|
|
61
66
|
export default defineApp({
|
|
62
67
|
events: (app) => [{
|
|
63
68
|
path: '/events',
|
|
64
69
|
authenticate: async (request) => {
|
|
65
70
|
const user = await userOf(request);
|
|
66
|
-
return user && { tenant: user.shop, member: user.id }; // namespace?:
|
|
71
|
+
return user && { tenant: user.shop, member: user.id }; // namespace?: one of the app's list (default the first)
|
|
67
72
|
},
|
|
68
73
|
onOpen: (stream) => stream.send('hello', { tenant: stream.tenant }), // 'front:shop-1'
|
|
69
74
|
}],
|
|
@@ -73,60 +78,71 @@ export default defineApp({
|
|
|
73
78
|
});
|
|
74
79
|
```
|
|
75
80
|
|
|
76
|
-
-
|
|
77
|
-
-
|
|
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`.
|
|
78
85
|
|
|
79
|
-
## 4.
|
|
86
|
+
## 4. Connection lifetime
|
|
80
87
|
|
|
81
|
-
-
|
|
82
|
-
-
|
|
83
|
-
- `?dokebiProbe=1`:
|
|
84
|
-
-
|
|
85
|
-
- `Last-Event-ID`
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
-
|
|
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.
|
|
89
96
|
|
|
90
|
-
## 5.
|
|
97
|
+
## 5. Browser client
|
|
91
98
|
|
|
92
|
-
```ts
|
|
99
|
+
```ts snippet
|
|
93
100
|
import { connectEvents } from '@dokebi/sse/client';
|
|
94
101
|
const events = connectEvents({
|
|
95
|
-
path: '/events', //
|
|
102
|
+
path: '/events', // or url: 'https://…'
|
|
96
103
|
query: { tab: 'orders' },
|
|
97
|
-
version: APP_VERSION, // dokebiVersion
|
|
98
|
-
withCredentials: false, //
|
|
99
|
-
onReload: () => location.reload(), //
|
|
100
|
-
reconnect: { minMs: 1000, maxMs: 30000 }, // false
|
|
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
|
|
101
108
|
});
|
|
102
109
|
const off = events.on<Order>('order.changed', (order, { id }) => {});
|
|
103
110
|
events.onStatus((status) => {}); // connecting · open · reconnecting · paused · closed
|
|
104
|
-
events.onError((error) => {}); // { code: UNAUTHORIZED
|
|
111
|
+
events.onError((error) => {}); // { code: UNAUTHORIZED, FORBIDDEN, UNAVAILABLE, CONNECTION_FAILED, BAD_DATA, HANDLER_FAILED, NO_EVENTSOURCE, status? }
|
|
105
112
|
events.close(); events.reconnect();
|
|
106
113
|
```
|
|
107
114
|
|
|
108
|
-
-
|
|
109
|
-
-
|
|
110
|
-
- `dokebi-reload` → `onReload`(
|
|
111
|
-
- `dokebi-restart` →
|
|
112
|
-
- EventSource
|
|
113
|
-
- EventSource
|
|
114
|
-
- bfcache: `pagehide`(persisted)
|
|
115
|
-
- Vue `useEvents(options)` → `{ status, client, on, close, reconnect }`(
|
|
116
|
-
- React `useEvents(options)` → `{ status, client }`(
|
|
117
|
-
-
|
|
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.
|
|
118
136
|
|
|
119
|
-
##
|
|
137
|
+
## 7. Logs (D-0047)
|
|
120
138
|
|
|
121
|
-
|
|
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`.
|
|
122
140
|
|
|
123
|
-
##
|
|
141
|
+
## 8. Errors
|
|
124
142
|
|
|
125
|
-
|
|
126
|
-
- 순서: 같은 발행자 순서는 지켜지고 전체 순서는 없다. NATS 장애 중 publish는 `BUS_UNAVAILABLE`/`BUS_PUBLISH_FAILED`, 그 이벤트는 잃는다.
|
|
127
|
-
- 롤링 재시작: 내려가는 워커의 스트림은 `dokebi-restart`를 받고, 클라이언트는 지터를 두고 다른 워커로 다시 붙는다.
|
|
128
|
-
- HTTP/1.1 브라우저는 출처당 연결 6개(탭마다 스트림 하나) → 탭이 많으면 프락시에 HTTP/2(TLS). 프락시 idle 제한 > `heartbeatMs`, 응답 버퍼링 끄기.
|
|
143
|
+
`SseError` (branch on `code`; never `instanceof`).
|
|
129
144
|
|
|
130
|
-
|
|
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).
|
|
131
147
|
|
|
132
|
-
|
|
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/README.md
CHANGED
|
@@ -1,84 +1,88 @@
|
|
|
1
1
|
# @dokebi/sse
|
|
2
2
|
|
|
3
|
-
|
|
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
4
|
|
|
5
|
-
|
|
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
6
|
|
|
7
|
-
##
|
|
7
|
+
## Install
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npx dokebi add @dokebi/sse
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
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:
|
|
14
16
|
|
|
15
17
|
```yaml
|
|
16
18
|
options:
|
|
17
19
|
dokebi-nats:
|
|
18
20
|
connections: { main: { servers: [nats://127.0.0.1:4222] } }
|
|
19
21
|
dokebi-event-bus:
|
|
20
|
-
buses: { sse: {
|
|
22
|
+
buses: { sse: { driver: nats, connection: main } }
|
|
21
23
|
dokebi-sse:
|
|
22
|
-
|
|
24
|
+
eventBus: sse
|
|
23
25
|
```
|
|
24
26
|
|
|
25
|
-
|
|
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
|
|
26
30
|
|
|
27
|
-
```ts
|
|
28
|
-
const events = context.get(sse); //
|
|
31
|
+
```ts snippet
|
|
32
|
+
const events = context.get(sse); // or context.getDependency(dependency)
|
|
29
33
|
events.route({
|
|
30
34
|
path: '/events',
|
|
31
|
-
version: APP_VERSION, //
|
|
32
|
-
authenticate: async (request) => { // undefined
|
|
35
|
+
version: APP_VERSION, // a different one gets dokebi-reload (D-0033)
|
|
36
|
+
authenticate: async (request) => { // undefined → 401
|
|
33
37
|
const user = await request.session.get('user');
|
|
34
38
|
return user ? { tenant: user.shop, member: user.id, rooms: [`shop:${user.shop}`] } : undefined;
|
|
35
39
|
},
|
|
36
40
|
onOpen: (stream) => stream.send('snapshot', { unread: 3 }),
|
|
37
41
|
});
|
|
38
42
|
|
|
39
|
-
await events.publish({ tenant: 'shop-1' }, 'order.changed', { id: 42 }); // tenant
|
|
40
|
-
await events.publish({ tenant: 'shop-1', member: 'u7' }, 'notice', { text: '
|
|
41
|
-
await events.publish({ tenant: 'shop-1', room: 'order:42' }, 'order.changed', { id: 42 }); //
|
|
42
|
-
await events.publish({ tenantPrefix: 'front:' }, 'notice', { text: '
|
|
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
|
|
43
47
|
```
|
|
44
48
|
|
|
45
|
-
| API |
|
|
49
|
+
| API | Meaning |
|
|
46
50
|
|---|---|
|
|
47
|
-
| `route({ path, constraints?, origins?, version?, authenticate, onOpen?, onClose? })` | setup
|
|
48
|
-
| `publish(target, event, data)` | `{ id, local }`.
|
|
49
|
-
| `connections(filter?)` |
|
|
50
|
-
| `stream.send(event, data)` · `join(room)` · `leave(room)` · `close()` |
|
|
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 |
|
|
51
55
|
|
|
52
|
-
|
|
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.
|
|
53
57
|
|
|
54
|
-
|
|
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`).
|
|
55
59
|
|
|
56
|
-
##
|
|
60
|
+
## Browser
|
|
57
61
|
|
|
58
|
-
```ts
|
|
62
|
+
```ts snippet
|
|
59
63
|
import { connectEvents } from '@dokebi/sse/client';
|
|
60
64
|
const events = connectEvents({ path: '/events', version: APP_VERSION });
|
|
61
|
-
const off = events.on('order.changed', (order) => refresh(order.id)); // data
|
|
65
|
+
const off = events.on('order.changed', (order) => refresh(order.id)); // data parsed as JSON
|
|
62
66
|
events.onStatus((status) => badge(status)); // connecting · open · reconnecting · paused · closed
|
|
63
67
|
```
|
|
64
68
|
|
|
65
|
-
- Vue: `import { useEvents, useEvent } from '@dokebi/sse/client/vue'
|
|
66
|
-
- React: `import { useEvents, useEvent, EventsProvider } from '@dokebi/sse/client/react'
|
|
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`.
|
|
69
73
|
|
|
70
|
-
##
|
|
74
|
+
## Several workers and servers
|
|
71
75
|
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
- `maxConnections
|
|
75
|
-
-
|
|
76
|
-
-
|
|
77
|
-
-
|
|
78
|
-
-
|
|
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`.
|
|
79
83
|
|
|
80
|
-
|
|
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).
|
|
81
85
|
|
|
82
|
-
##
|
|
86
|
+
## Seal hosts (D-0062)
|
|
83
87
|
|
|
84
|
-
|
|
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).
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
import { DokebiError } from '@dokebi/core';
|
|
2
|
+
export type SseErrorCode = 'INVALID_OPTIONS' | 'INVALID_ROUTE' | 'INVALID_IDENTITY' | 'INVALID_EVENT' | 'INVALID_DATA' | 'INVALID_TARGET' | 'INVALID_ROOM' | 'EVENT_TOO_LARGE' | 'ROOM_LIMIT' | 'FROZEN' | 'CLOSED' | 'BUS_UNAVAILABLE' | 'BUS_PUBLISH_FAILED' | 'LOCAL_IN_CLUSTER';
|
|
2
3
|
/** Errors of @dokebi/sse; compare `code`, not the class (another copy of the package may throw). */
|
|
3
|
-
export declare class SseError extends
|
|
4
|
-
readonly code: SseErrorCode;
|
|
4
|
+
export declare class SseError extends DokebiError<SseErrorCode> {
|
|
5
5
|
/** BUS_PUBLISH_FAILED: the event id and how many streams of this process already got it. */
|
|
6
6
|
readonly id?: string;
|
|
7
7
|
readonly local?: number;
|
|
8
|
-
constructor(code: SseErrorCode, message: string, options?:
|
|
8
|
+
constructor(code: SseErrorCode, message: string, options?: {
|
|
9
|
+
readonly cause?: unknown;
|
|
9
10
|
readonly id?: string;
|
|
10
11
|
readonly local?: number;
|
|
12
|
+
readonly details?: unknown;
|
|
11
13
|
});
|
|
12
14
|
}
|
package/dist/errors.js
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
|
+
import { DokebiError } from '@dokebi/core';
|
|
2
|
+
const FIX = {
|
|
3
|
+
INVALID_OPTIONS: "Correct options.dokebi-sse in dokebi.yaml; details.issues names the paths and the plugin's options.schema.json lists what is allowed.",
|
|
4
|
+
INVALID_ROUTE: 'Give route a path starting with / (no query), an authenticate function, and optional onOpen/onClose functions, version text, constraints object and origins (* or exact https://host origins).',
|
|
5
|
+
INVALID_IDENTITY: 'Return { tenant, member, rooms? } from authenticate, with strings of 1 to 256 characters (or undefined to refuse with 401).',
|
|
6
|
+
INVALID_EVENT: 'Name events with a letter first and up to 64 of A-Z a-z 0-9 . _ : -, not starting with dokebi-.',
|
|
7
|
+
INVALID_DATA: 'Send data that JSON.stringify accepts (no BigInt, functions or cycles).',
|
|
8
|
+
INVALID_TARGET: 'Target { tenant, member?, room? } or { tenantPrefix, member?, room? } with strings of 1 to 256 characters.',
|
|
9
|
+
INVALID_ROOM: 'Rooms are strings of 1 to 256 characters.',
|
|
10
|
+
EVENT_TOO_LARGE: 'Send less data per event, or raise options.dokebi-sse.maxEventBytes (keep it below the bus maxPayloadBytes).',
|
|
11
|
+
ROOM_LIMIT: 'Join fewer rooms per stream, or raise options.dokebi-sse.maxRooms.',
|
|
12
|
+
FROZEN: 'Register SSE routes during setup (a plugin, module or app setup), not after start.',
|
|
13
|
+
CLOSED: 'Publish only while the application runs; this SSE plugin has been closed.',
|
|
14
|
+
BUS_UNAVAILABLE: 'The event bus is not connected; retry after it recovers (warn BUS_UNAVAILABLE and info "SSE bus available again" in the log).',
|
|
15
|
+
BUS_PUBLISH_FAILED: 'This process delivered the event (local), the bus did not take it: retry, or check the bus connection; the cause has its error.',
|
|
16
|
+
LOCAL_IN_CLUSTER: 'Set options.dokebi-sse.eventBus to a dokebi-event-bus bus with driver nats, or allowInCluster: true for a deliberate single-worker cluster.',
|
|
17
|
+
};
|
|
1
18
|
/** Errors of @dokebi/sse; compare `code`, not the class (another copy of the package may throw). */
|
|
2
|
-
export class SseError extends
|
|
3
|
-
code;
|
|
19
|
+
export class SseError extends DokebiError {
|
|
4
20
|
/** BUS_PUBLISH_FAILED: the event id and how many streams of this process already got it. */
|
|
5
21
|
id;
|
|
6
22
|
local;
|
|
7
23
|
constructor(code, message, options) {
|
|
8
|
-
super(message,
|
|
9
|
-
|
|
24
|
+
super(code, message, {
|
|
25
|
+
plugin: 'dokebi-sse',
|
|
26
|
+
fix: FIX[code],
|
|
27
|
+
...(options?.cause === undefined ? {} : { cause: options.cause }),
|
|
28
|
+
...(options?.details === undefined ? {} : { details: options.details }),
|
|
29
|
+
});
|
|
10
30
|
this.name = 'SseError';
|
|
11
31
|
if (options?.id !== undefined)
|
|
12
32
|
this.id = options.id;
|