@dokebi/sse 0.4.1 → 0.4.2

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 CHANGED
@@ -1,4 +1,4 @@
1
- # AI guide — @dokebi/sse 0.4.0
1
+ # AI guide — @dokebi/sse
2
2
 
3
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
 
@@ -21,14 +21,20 @@ npx dokebi add @dokebi/sse
21
21
  | `bus` | string | — | deprecated name of `eventBus`: warns `OPTION_DEPRECATED`; setting both is `INVALID_OPTIONS` |
22
22
  | `busEvent` | string | `dokebi.sse` | the bus event name |
23
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` |
24
+ | `retryMs` (reloadable) | integer | 3000 | the first `retry:` sent (browser reconnect delay); also the client backoff's minimum |
25
+ | `heartbeatMs` (reloadable) | integer | 15000 | interval of the `: ping` comment. Keep it shorter than the proxy idle limit |
26
+ | `maxConnections` (reloadable) | integer | 1024 | streams per process (worker). Beyond it 503 `CAPACITY` |
27
+ | `maxBufferedBytes` (reloadable) | integer | 1048576 | bytes waiting to be sent on one stream. Beyond it the stream is cut with warn `SLOW_CONSUMER` |
28
28
  | `maxRooms` | integer | 32 | rooms per stream |
29
- | `maxEventBytes` | integer | 65536 | bytes of an event's data (JSON). Keep it below the bus `maxPayloadBytes` |
29
+ | `maxEventBytes` (reloadable) | integer | 65536 | bytes of an event's data (JSON). Keep it below the bus `maxPayloadBytes`: with `eventBus`, an event whose bus message (the data JSON inside the envelope, its quotes escaped again — up to about twice the data) is over the bus `maxPayloadBytes` of `dokebi-event-bus.buses.<eventBus>` is `EVENT_TOO_LARGE` before any stream gets it (before, this process wrote it and the bus refused it: `BUS_PUBLISH_FAILED`, other processes never had it) |
30
30
  | `shutdownTimeoutMs` | integer | 2000 | how long drain waits for streams to end after `dokebi-restart` (named `closeTimeoutMs` before 0.4.0) |
31
31
  | `closeTimeoutMs` | integer | — | deprecated name of `shutdownTimeoutMs` (D-0071) |
32
+ | `busRetryAfterMs: { busy?, unavailable? }` | object | `{ busy: 1000, unavailable: 5000 }` | the `Retry-After` (ms, 1000 to 3600000, sent in whole seconds) of the retryable 503 a route answers when `publish` fails for a transient bus reason: `busy` for a bus at its pending limit, `unavailable` for a bus or NATS connection that is down, closed or draining ([D-0107](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0107-stream-and-render-bounds.md) §6). Size `unavailable` near how long your bus takes to come back |
33
+ | `endTimeoutMs` | integer | 5000 | how long a stream the server ended (`stream.close()`, a revoked authorization, drain) may take to send what it still holds; then it is destroyed with warn `STREAM_END_TIMEOUT` and its `maxConnections` slot is free ([D-0107](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0107-stream-and-render-bounds.md)). 1 to 600000. Size it above the time your slowest wanted client needs to read `maxBufferedBytes` |
34
+ | `beforeStreamTimeoutMs` | integer 100–600000 | 5000 | the longest wait for the before-hijack steps (the session save, auth cookies; below); slower refuses the stream with 500 `COMMIT_FAILED`. Size it above the slowest session store write you accept, below the client's connect timeout |
35
+ | `reloadRetryMs` | integer 100–86400000 | 60000 | the `retry:` sent with `dokebi-reload`: a client that ignores the reload reconnects this slowly |
36
+ | `busWatchIntervalMs` | integer 100–60000 | 1000 | how often the event bus state is read for the `sse` health check and its warn/info log; an outage shows within this long |
37
+ | `logRepeatWindowMs` | integer 1000–3600000 | 10000 | window of the repeated stream failure logs (`SSE_COMMIT_FAILED` and the others of this gateway): the first is logged in full, the rest of the window are counted (`repeated`, `intervalMs`) |
32
38
 
33
39
  **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
40
 
@@ -39,8 +45,9 @@ import { createPlugin, dependency, type SseApi } from '@dokebi/sse';
39
45
  const sse = context.getDependency(dependency); // created from this package when missing
40
46
  sse.route({
41
47
  path: '/events', // GET only (no HEAD)
48
+ config: { id: 'events' }, // optional: the route id (D-0140), as config.id of a web route
42
49
  constraints: { dokebiApp: 'shop' }, // optional: @dokebi/app domain routing
43
- origins: ['https://admin.example.com'], // optional: allow other origins (CORS + credentials); '*' without credentials
50
+ origins: ['https://admin.example.com'], // optional: allow other origins (CORS + credentials); '*' without credentials. @dokebi/cors does not answer streams (D-0148)
44
51
  version: APP_VERSION, // optional: D-0033
45
52
  authenticate: (request) => ({ tenant, member, rooms: ['r1'] }), // undefined → 401
46
53
  onOpen: (stream) => { stream.join(`user:${stream.member}`); stream.send('snapshot', data); },
@@ -51,12 +58,13 @@ sse.connections({ tenant, room }); // the count on this wo
51
58
  ```
52
59
 
53
60
  - `route` works only in setup (`FROZEN` afterwards). `authenticate` and `onOpen` run on every reconnect.
61
+ - Route id (D-0140): `config: { id }` becomes the Fastify config of the stream route, so `dokebi describe` lists it as `routes[].id`, `web.routes()` and the per-route hooks of dokebi-web (`web.routeHook`, an audit log's `onResponse`) see it, and a request reads it as `request.routeOptions.config.id`. The id is checked at start by dokebi-web exactly like a web route's: 1 to 64 letters, digits, `.`, `_` or `-`, starting with a letter (`INVALID_ROUTE_CONFIG`); the same id on two routes of one app (a web route, a socket or a stream) fails the start with `DUPLICATE_ROUTE_ID`. `config` takes only `id`: anything else (null, a string, another key such as `auth`) is `INVALID_ROUTE` at `route`. The refusal logs carry it as `routeId` (§7).
54
62
  - 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 (any characters). A target only ever reaches streams of its own tenant.
55
63
  - 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
64
  - 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).
65
+ - `publish` order: check target, name and data → when the bus is not `ready`, `BUS_UNAVAILABLE` (nothing is sent anywhere, error log) → with a bus, its bus message must fit the bus `maxPayloadBytes` of the configuration, else `EVENT_TOO_LARGE` (nothing sent) → 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). A transient bus failure — `BUS_UNAVAILABLE`, or `BUS_PUBLISH_FAILED` whose cause is the event bus's `OVERLOADED`, `TRANSPORT_FAILED`, `DEPENDENCY_UNAVAILABLE`, `BUS_DRAINING` or `CLOSED` (by `plugin` and `code`) — carries `status: 503` and `data.retryAfterMs` (`busRetryAfterMs`), so a route that lets it through answers 503 with `Retry-After`; any other failure (an invalid payload) answers 500 (D-0107 §6). An app that wraps the error in its own decides the status itself (keep the `cause`).
58
66
  - 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.
67
+ - `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, unless you give one: `stream.send(event, data, { id })` and `publish(target, event, data, { id })` (and `app.realtime().publish` of @dokebi/app) take an id of your own durable order, such as a message's sequence in its channel (1 to 64 of A-Z a-z 0-9 and -, else `INVALID_EVENT_ID`). Every process sends that same id, so a client that reconnects to any worker hands it back as `Last-Event-ID` (AI-M41: the plugin's own ids differ per worker and mean nothing to the database, so resume could not use them).
60
68
  - Rooms are per connection. After a reconnect they are empty, so join in `authenticate`'s `rooms` or in `onOpen`.
61
69
  - `parseOptions` (the options parser) and the API types (`SseApi`, `SseRoute`, `SseStream`, …) are exported too.
62
70
 
@@ -66,6 +74,7 @@ sse.connections({ tenant, room }); // the count on this wo
66
74
  export default defineApp({
67
75
  events: (app) => [{
68
76
  path: '/events',
77
+ config: { id: 'shop.events' }, // unique in the app with its routes and sockets
69
78
  authenticate: async (request) => {
70
79
  const user = await userOf(request);
71
80
  return user && { tenant: user.shop, member: user.id }; // namespace?: one of the app's list (default the first)
@@ -86,12 +95,13 @@ export default defineApp({
86
95
  ## 4. Connection lifetime
87
96
 
88
97
  - 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).
98
+ - 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; an `authorization.valid()` that throws 500 `AUTHORIZATION_FAILED` (error `SSE_AUTHORIZATION_FAILED`); the session save before the stream failed 500 `COMMIT_FAILED` (below). EventSource cannot see these statuses (the client below tells them apart with a probe). For a listed foreign origin (`origins`) the 401, 503 and 500 refusals carry the same CORS headers as the stream, so a page of that origin (and the client's probe) can read them; another origin's 403 carries none.
99
+ - Cookies (D-0089): the stream hijacks the Fastify reply, so **no onSend hook runs**. Just before it writes the head (after origin, capacity, `authenticate` and the probe), the stream runs the before-hijack steps that plugins register for the work they otherwise do in onSend — the @dokebi/session save (or rolling refresh) and the @dokebi/auth cookies (a remember-me restore by a `required` guard rotates the token) — and waits for them, at most `beforeStreamTimeoutMs` (5 s). Then every `Set-Cookie` on the reply (those steps, a hook, a guard) is written with the stream head; browsers apply it on the SSE 200. A step that fails or times out refuses the stream: 500 `{ error: 'COMMIT_FAILED' }`, error `SSE_COMMIT_FAILED` (the full cause). After the head the request's session is final (a change throws `CLOSED`). The steps are matched by name only (`Symbol.for('dokebi.web.beforeHijack')` on the Fastify instance): this package imports neither plugin. (The `dokebiProbe` answer below is a normal reply and runs onSend instead.)
90
100
  - `?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.
101
+ - Version (D-0033): when the route's `version` differs from the query `dokebiVersion` (or the `x-dokebi-version` header), it sends `retry: <reloadRetryMs>` (60000) + `event: dokebi-reload` (data `{"version":…}`) and ends.
102
+ - 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: to resume, publish with ids of your own order (above), and in `onOpen` read `stream.lastEventId` and send from your database what came after it, with the same ids, before the live events reach that stream.
103
+ - 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). A stream the server ends with `end()` sends its last chunk behind the bytes still waiting; when its client stopped reading, `close` would never come and the stream would keep its slot, so after `endTimeoutMs` it is destroyed (warn `STREAM_END_TIMEOUT` with `bufferedBytes`; `onClose` gets the reason it was ended with).
104
+ - 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`, `endTimeoutMs` or the core drain deadline, whichever comes first → wait for onClose. It drains before dokebi-web.
95
105
  - 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
106
 
97
107
  ## 5. Browser client
@@ -115,7 +125,7 @@ events.close(); events.reconnect();
115
125
  - Importing touches neither window nor EventSource (SSR safe). It opens when you call `connectEvents`.
116
126
  - 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
127
  - `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).
128
+ - `dokebi-restart` → close, then reopen after backoff with jitter (starting at `max(minMs, server retry)`, doubling up to `maxMs`, 50–100% of that value). `minMs` and `maxMs` are at least 100 ms (the server's smallest `retryMs`); a value that is not a finite number takes the default.
119
129
  - When EventSource reconnects by itself (the stream ended, network): left alone (`reconnecting`; the browser sends `Last-Event-ID`).
120
130
  - 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
131
  - bfcache: closes on `pagehide` (persisted) (`paused`) and reopens on `pageshow` (persisted).
@@ -136,13 +146,17 @@ On streams of a seal host (@dokebi/app `domains.<host>.seal: true`) or of a `sea
136
146
 
137
147
  ## 7. Logs (D-0047)
138
148
 
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`.
149
+ debug: route registration, stream open and close (reason, ms, open count), refusal reasons (`SSE stream unauthorized` for a 401, `SSE origin refused` for a 403, `SSE stream refused` for a 503; each with `path` and `routeId`, the route's `config.id` or null, D-0140), version refusals, room join and leave, publish (id, local), bus deliveries received, drain begin and end. `SSE stream ending` (reason, `bufferedBytes`, `endTimeoutMs`). warn: `SLOW_CONSUMER`, `STREAM_END_TIMEOUT` (an ended stream whose client stopped reading, destroyed), `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_AUTHORIZATION_FAILED`, `SEAL_ENCODE_FAILED`, `SSE_LOCAL_IN_CLUSTER`, `SSE_COMMIT_FAILED` (D-0089). A bad `authenticate` result (`INVALID_IDENTITY`) is logged by dokebi-web as 500 with `WEB_REQUEST_FAILED`.
140
150
 
141
151
  ## 8. Errors
142
152
 
143
153
  `SseError` (branch on `code`; never `instanceof`).
144
154
 
145
155
  <!-- 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).
156
+ 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_EVENT_ID` (an `options.id` that is not 1 to 64 of A-Z a-z 0-9 and -, or options that are not an object), `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
157
 
148
158
  `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).
159
+
160
+ ## Authorization lifetime (0.4.2)
161
+
162
+ An authenticated identity may carry `authorization: { valid(): boolean; subscribe(invalidated: () => void): () => void }`. Use `await request.auth.live()` with session auth, or with a Bearer token (auth 0.4.1, D-0124: an account change ends the stream within `live.maxAgeMs`; an OAuth access token uses `oauth.live(request)`), and reject a missing lease for private streams. The gateway rejects invalid initial identities, subscribes to invalidation, checks validity before every write (heartbeats included) and ends revoked streams. Closing the stream unsubscribes. A `valid`, `subscribe` or unsubscribe that throws is logged at error `SSE_AUTHORIZATION_FAILED`; a failed `subscribe` ends the stream, since it could not be revoked later. Guards stay local; they are not sent through the event bus. Existing unguarded identities retain their behavior; authentication at connect time alone does not revoke an already-open private stream. Public guest streams may use a separate identity with no private member access.
package/README.md CHANGED
@@ -32,6 +32,7 @@ Before 0.4.0 the key was `bus` (and `closeTimeoutMs` is now `shutdownTimeoutMs`)
32
32
  const events = context.get(sse); // or context.getDependency(dependency)
33
33
  events.route({
34
34
  path: '/events',
35
+ config: { id: 'events' }, // the route id (D-0140): describe, route hooks, refusal logs
35
36
  version: APP_VERSION, // a different one gets dokebi-reload (D-0033)
36
37
  authenticate: async (request) => { // undefined → 401
37
38
  const user = await request.session.get('user');
@@ -48,14 +49,14 @@ await events.publish({ tenantPrefix: 'front:' }, 'notice', { text: 'Maintenance'
48
49
 
49
50
  | API | Meaning |
50
51
  |---|---|
51
- | `route({ path, constraints?, origins?, version?, authenticate, onOpen?, onClose? })` | setup only. A GET stream route |
52
+ | `route({ path, config?, constraints?, origins?, version?, authenticate, onOpen?, onClose? })` | setup only. A GET stream route. `config: { id }` is its route id, checked like a web route's `config.id` (the same id twice in one app fails the start with `DUPLICATE_ROUTE_ID`) |
52
53
  | `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
54
  | `connections(filter?)` | the number of open streams of this process (worker). `{ tenant?, member?, room?, path? }` |
54
55
  | `stream.send(event, data)` · `join(room)` · `leave(room)` · `close()` | one connection only |
55
56
 
56
57
  **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
 
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
+ 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`). A bus that is down, draining or full makes them a retryable 503 with `Retry-After` (`busRetryAfterMs`); other bus failures stay 500 ([D-0107](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0107-stream-and-render-bounds.md) §6).
59
60
 
60
61
  ## Browser
61
62
 
@@ -5,38 +5,38 @@ function r(e) {
5
5
  return t ? Number(t[1]) : void 0;
6
6
  }
7
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);
8
+ let a = i.EventSource ?? globalThis.EventSource, o = i.reconnect !== !1, s = (e, t) => typeof e == "number" && Number.isFinite(e) ? Math.max(100, e) : t, c = i.reconnect === !1 ? 0 : s(i.reconnect?.minMs, 1e3), l = i.reconnect === !1 ? 0 : Math.max(c, s(i.reconnect?.maxMs, 3e4)), u = /* @__PURE__ */ new Map(), d = /* @__PURE__ */ new Map(), f = /* @__PURE__ */ new Set(), p = /* @__PURE__ */ new Set(), m, h = "connecting", g, _ = 0, v = 0, y, b = !1, x = !1, S = !1, C = 0, w = i.url ?? i.path;
9
+ if (typeof w != "string" || !w) throw TypeError("connectEvents needs url or path");
10
+ let T = i.seal;
11
+ if (T !== void 0 && typeof T?.unsealFrame != "function") throw TypeError("connectEvents seal needs unsealFrame(frame, url)");
12
+ let E;
13
+ function D(e) {
14
+ if (h !== e) {
15
+ h = e, i.onStatus?.(e);
16
+ for (let t of [...f]) t(e);
17
17
  }
18
18
  }
19
- function E(e) {
20
- if (!i.onError && !f.size) console.warn(`[dokebi-sse] ${e.code}`, e.error ?? e.status ?? "");
19
+ function O(e) {
20
+ if (!i.onError && !p.size) console.warn(`[dokebi-sse] ${e.code}`, e.error ?? e.status ?? "");
21
21
  else {
22
22
  i.onError?.(e);
23
- for (let t of [...f]) t(e);
23
+ for (let t of [...p]) t(e);
24
24
  }
25
25
  }
26
- function D(e) {
27
- let t = globalThis.location?.href, n = new URL(S, t);
26
+ function k(e) {
27
+ let t = globalThis.location?.href, n = new URL(w, t);
28
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);
29
+ i.version !== void 0 && n.searchParams.set("dokebiVersion", i.version), S && g !== void 0 && n.searchParams.set("lastEventId", g);
30
30
  for (let [t, r] of Object.entries(e ?? {})) n.searchParams.set(t, r);
31
- return w = n.pathname, n.href;
31
+ return E = n.pathname, n.href;
32
32
  }
33
- function O(e, t) {
33
+ function A(e, t) {
34
34
  let n = t.startsWith("E:");
35
- if (C) try {
35
+ if (T) try {
36
36
  if (!n) throw Error("plain data on a sealed stream");
37
- return C.unsealFrame(t, w ?? "/");
37
+ return T.unsealFrame(t, E ?? "/");
38
38
  } catch (t) {
39
- E({
39
+ O({
40
40
  code: "SEAL_FAILED",
41
41
  event: e,
42
42
  error: t
@@ -44,21 +44,21 @@ function i(i) {
44
44
  return;
45
45
  }
46
46
  if (!n) return t;
47
- E({
47
+ O({
48
48
  code: "SEAL_BRIDGE_MISSING",
49
49
  event: e
50
50
  });
51
51
  }
52
- function k(e) {
52
+ function j(e) {
53
53
  return (t) => {
54
- t.lastEventId && (h = t.lastEventId);
55
- let n = O(e, t.data);
54
+ t.lastEventId && (g = t.lastEventId);
55
+ let n = A(e, t.data);
56
56
  if (n === void 0) return;
57
57
  let r;
58
58
  try {
59
59
  r = JSON.parse(n);
60
60
  } catch (t) {
61
- E({
61
+ O({
62
62
  code: "BAD_DATA",
63
63
  event: e,
64
64
  error: t
@@ -69,10 +69,10 @@ function i(i) {
69
69
  event: e,
70
70
  id: t.lastEventId || void 0
71
71
  };
72
- for (let t of [...l.get(e) ?? []]) try {
72
+ for (let t of [...u.get(e) ?? []]) try {
73
73
  t(r, i);
74
74
  } catch (t) {
75
- E({
75
+ O({
76
76
  code: "HANDLER_FAILED",
77
77
  event: e,
78
78
  error: t
@@ -80,120 +80,121 @@ function i(i) {
80
80
  }
81
81
  };
82
82
  }
83
- function A() {
84
- v !== void 0 && clearTimeout(v), v = void 0;
83
+ function M() {
84
+ y !== void 0 && clearTimeout(y), y = void 0;
85
85
  }
86
- function j() {
87
- p &&= (p.onopen = null, p.onerror = null, p.close(), void 0);
86
+ function N() {
87
+ C++, m &&= (m.onopen = null, m.onerror = null, m.close(), void 0);
88
88
  }
89
- function M() {
90
- if (A(), y || b) return;
89
+ function P() {
90
+ if (M(), N(), b || x) return;
91
91
  if (!a) {
92
- y = !0, T("closed"), E({ code: "NO_EVENTSOURCE" });
92
+ b = !0, D("closed"), O({ code: "NO_EVENTSOURCE" });
93
93
  return;
94
94
  }
95
- let o = new a(D(), i.withCredentials ? { withCredentials: !0 } : void 0);
96
- p = o, o.onopen = () => {
97
- p === o && (_ = 0, T("open"));
95
+ let o = new a(k(), i.withCredentials ? { withCredentials: !0 } : void 0);
96
+ m = o, o.onopen = () => {
97
+ m === o && (v = 0, D("open"));
98
98
  }, o.onerror = () => {
99
- p === o && N(o);
99
+ m === o && F(o);
100
100
  }, o.addEventListener(n, (e) => {
101
- g = r(O(n, e.data) ?? "") ?? g;
101
+ _ = r(A(n, e.data) ?? "") ?? _;
102
102
  }), o.addEventListener(e, (t) => {
103
- p === o && O(e, t.data) !== void 0 && I();
103
+ m === o && A(e, t.data) !== void 0 && R();
104
104
  }), o.addEventListener(t, (e) => {
105
- p === o && (g = r(O(t, e.data) ?? "") ?? g, j(), T("reconnecting"), F());
105
+ m === o && (_ = r(A(t, e.data) ?? "") ?? _, N(), D("reconnecting"), L());
106
106
  });
107
- for (let [e, t] of u) o.addEventListener(e, t);
107
+ for (let [e, t] of d) o.addEventListener(e, t);
108
108
  }
109
- function N(e) {
109
+ function F(e) {
110
110
  if (e.readyState !== 2) {
111
- T("reconnecting");
111
+ D("reconnecting");
112
112
  return;
113
113
  }
114
- if (j(), !o) {
115
- y = !0, T("closed"), E({ code: "CONNECTION_FAILED" });
114
+ if (N(), !o) {
115
+ b = !0, D("closed"), O({ code: "CONNECTION_FAILED" });
116
116
  return;
117
117
  }
118
- T("reconnecting");
118
+ D("reconnecting");
119
119
  let t = globalThis.fetch;
120
- i.probe === !1 || !t ? (E({ code: "CONNECTION_FAILED" }), F()) : P(t);
120
+ i.probe === !1 || !t ? (O({ code: "CONNECTION_FAILED" }), L()) : I(t, C);
121
121
  }
122
- async function P(e) {
123
- let t;
122
+ async function I(e, t) {
123
+ let n;
124
124
  try {
125
- t = (await e(D({ dokebiProbe: "1" }), {
125
+ n = (await e(k({ dokebiProbe: "1" }), {
126
126
  credentials: i.withCredentials ? "include" : "same-origin",
127
127
  cache: "no-store"
128
128
  })).status;
129
129
  } catch (e) {
130
- E({
130
+ if (b || x || t !== C) return;
131
+ O({
131
132
  code: "CONNECTION_FAILED",
132
133
  error: e
133
- }), F();
134
+ }), L();
134
135
  return;
135
136
  }
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()));
137
+ b || x || t !== C || (n === 401 || n === 403 ? (b = !0, M(), D("closed"), O({
138
+ code: n === 401 ? "UNAUTHORIZED" : "FORBIDDEN",
139
+ status: n
140
+ })) : (O({
141
+ code: n === 503 ? "UNAVAILABLE" : "CONNECTION_FAILED",
142
+ status: n
143
+ }), L()));
143
144
  }
144
- function F() {
145
- if (A(), y || b) return;
145
+ function L() {
146
+ if (M(), b || x) return;
146
147
  if (!o) {
147
- y = !0, T("closed");
148
+ b = !0, D("closed");
148
149
  return;
149
150
  }
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));
151
+ let e = Math.max(c, _), t = Math.min(Math.max(l, e), e * 2 ** v);
152
+ v = Math.min(v + 1, 20), S = !0, y = setTimeout(P, Math.round(t / 2 + Math.random() * t / 2));
152
153
  }
153
- function I() {
154
- y = !0, j(), A(), T("closed"), i.onReload ? i.onReload() : globalThis.location?.reload();
154
+ function R() {
155
+ b = !0, N(), M(), D("closed"), i.onReload ? i.onReload() : globalThis.location?.reload();
155
156
  }
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());
157
+ let z = globalThis, B = (e) => {
158
+ e.persisted && !b && (x = !0, N(), M(), D("paused"));
159
+ }, V = (e) => {
160
+ e.persisted && !b && x && (x = !1, v = 0, S = !0, D("connecting"), P());
160
161
  };
161
- return L.addEventListener?.("pagehide", R), L.addEventListener?.("pageshow", z), M(), {
162
+ return z.addEventListener?.("pagehide", B), z.addEventListener?.("pageshow", V), P(), {
162
163
  get status() {
163
- return m;
164
+ return h;
164
165
  },
165
166
  get lastEventId() {
166
- return h;
167
+ return g;
167
168
  },
168
169
  on(e, t) {
169
170
  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
+ let n = u.get(e);
171
172
  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);
173
+ n = /* @__PURE__ */ new Set(), u.set(e, n);
174
+ let t = j(e);
175
+ d.set(e, t), m?.addEventListener(e, t);
175
176
  }
176
177
  let r = t;
177
178
  return n.add(r), () => {
178
- let t = l.get(e);
179
+ let t = u.get(e);
179
180
  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);
181
+ u.delete(e);
182
+ let n = d.get(e);
183
+ d.delete(e), n && m?.removeEventListener(e, n);
183
184
  };
184
185
  },
185
186
  onStatus(e) {
186
- return d.add(e), () => d.delete(e);
187
+ return f.add(e), () => f.delete(e);
187
188
  },
188
189
  onError(e) {
189
- return f.add(e), () => f.delete(e);
190
+ return p.add(e), () => p.delete(e);
190
191
  },
191
192
  close() {
192
- y = !0, b = !1, j(), A(), L.removeEventListener?.("pagehide", R), L.removeEventListener?.("pageshow", z), T("closed");
193
+ b = !0, x = !1, N(), M(), z.removeEventListener?.("pagehide", B), z.removeEventListener?.("pageshow", V), D("closed");
193
194
  },
194
195
  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();
196
+ let e = b;
197
+ b = !1, x = !1, v = 0, S = m !== void 0 || S, N(), e && (z.addEventListener?.("pagehide", B), z.addEventListener?.("pageshow", V)), D("connecting"), P();
197
198
  }
198
199
  };
199
200
  }
@@ -22,6 +22,8 @@
22
22
  // `@dokebi/seal/http`) as `seal`; it is undefined on a plain host, so the same page code runs on
23
23
  // both. With a bridge, plain data is refused; without one, sealed data is (fail closed).
24
24
  const CLOSED = 2;
25
+ /** The smallest reconnect delay, as the server's smallest `retryMs`. */
26
+ const RECONNECT_FLOOR_MS = 100;
25
27
  const RELOAD = 'dokebi-reload';
26
28
  const RESTART = 'dokebi-restart';
27
29
  const OPEN = 'dokebi-open';
@@ -34,8 +36,13 @@ function retryOf(text) {
34
36
  export function connectEvents(options) {
35
37
  const Source = options.EventSource ?? globalThis.EventSource;
36
38
  const reconnecting = options.reconnect !== false;
37
- const minMs = options.reconnect === false ? 0 : Math.max(0, options.reconnect?.minMs ?? 1000);
38
- const maxMs = options.reconnect === false ? 0 : Math.max(minMs, options.reconnect?.maxMs ?? 30_000);
39
+ // At least the server's smallest `retry:` (100 ms): a minMs of 0 (or NaN) reopened at once,
40
+ // over and over, against a server that refuses before it sends `retry:`.
41
+ const delayOf = (value, fallback) => typeof value === 'number' && Number.isFinite(value)
42
+ ? Math.max(RECONNECT_FLOOR_MS, value)
43
+ : fallback;
44
+ const minMs = options.reconnect === false ? 0 : delayOf(options.reconnect?.minMs, 1000);
45
+ const maxMs = options.reconnect === false ? 0 : Math.max(minMs, delayOf(options.reconnect?.maxMs, 30_000));
39
46
  const handlers = new Map();
40
47
  const dispatchers = new Map();
41
48
  const statusHandlers = new Set();
@@ -50,6 +57,12 @@ export function connectEvents(options) {
50
57
  let paused = false;
51
58
  /** The next open is this client's own (not the browser's): it carries lastEventId. */
52
59
  let manual = false;
60
+ /**
61
+ * Counts opens and drops: a probe answer belongs to the stream that failed, and is dropped when
62
+ * reconnect(), pageshow or close() came while it was in flight (it would otherwise open a second
63
+ * EventSource beside the live one, or close it on a stale 401).
64
+ */
65
+ let generation = 0;
53
66
  const base = options.url ?? options.path;
54
67
  if (typeof base !== 'string' || !base)
55
68
  throw new TypeError('connectEvents needs url or path');
@@ -140,6 +153,7 @@ export function connectEvents(options) {
140
153
  timer = undefined;
141
154
  }
142
155
  function drop() {
156
+ generation++;
143
157
  if (!source)
144
158
  return;
145
159
  source.onopen = null;
@@ -149,6 +163,8 @@ export function connectEvents(options) {
149
163
  }
150
164
  function open() {
151
165
  clearTimer();
166
+ // Never two EventSources: their listeners would each deliver every event.
167
+ drop();
152
168
  if (stopped || paused)
153
169
  return;
154
170
  if (!Source) {
@@ -210,9 +226,9 @@ export function connectEvents(options) {
210
226
  later();
211
227
  return;
212
228
  }
213
- void probe(fetcher);
229
+ void probe(fetcher, generation);
214
230
  }
215
- async function probe(fetcher) {
231
+ async function probe(fetcher, asked) {
216
232
  let answer;
217
233
  try {
218
234
  const response = await fetcher(address({ dokebiProbe: '1' }), {
@@ -222,11 +238,14 @@ export function connectEvents(options) {
222
238
  answer = response.status;
223
239
  }
224
240
  catch (error) {
241
+ // A stale probe's failure belongs to a stream that was already replaced or closed.
242
+ if (stopped || paused || asked !== generation)
243
+ return;
225
244
  report({ code: 'CONNECTION_FAILED', error });
226
245
  later();
227
246
  return;
228
247
  }
229
- if (stopped || paused)
248
+ if (stopped || paused || asked !== generation)
230
249
  return;
231
250
  if (answer === 401 || answer === 403) {
232
251
  stopped = true;
@@ -54,7 +54,9 @@ export function useEvents(options) {
54
54
  const key = keyOf(options);
55
55
  // biome-ignore lint/correctness/useExhaustiveDependencies: key is what identifies the stream; the latest callbacks are read through the ref.
56
56
  useEffect(() => {
57
- const current = latest.current;
57
+ // onStatus is followed through client.onStatus below (each user's latest callback): passed in
58
+ // the options too, the opening user's callback of its first render ran a second time per change.
59
+ const { onStatus: _onStatus, ...current } = latest.current;
58
60
  const entry = acquire({
59
61
  ...current,
60
62
  ...(current.onReload ? { onReload: () => latest.current.onReload?.() } : {}),
package/dist/errors.d.ts CHANGED
@@ -1,5 +1,5 @@
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
+ export type SseErrorCode = 'INVALID_OPTIONS' | 'INVALID_ROUTE' | 'INVALID_IDENTITY' | 'INVALID_EVENT' | 'INVALID_EVENT_ID' | 'INVALID_DATA' | 'INVALID_TARGET' | 'INVALID_ROOM' | 'EVENT_TOO_LARGE' | 'ROOM_LIMIT' | 'FROZEN' | 'CLOSED' | 'BUS_UNAVAILABLE' | 'BUS_PUBLISH_FAILED' | 'LOCAL_IN_CLUSTER';
3
3
  /** Errors of @dokebi/sse; compare `code`, not the class (another copy of the package may throw). */
4
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. */
@@ -10,5 +10,10 @@ export declare class SseError extends DokebiError<SseErrorCode> {
10
10
  readonly id?: string;
11
11
  readonly local?: number;
12
12
  readonly details?: unknown;
13
+ /**
14
+ * A transient bus failure (D-0107 §6): the error answers a retryable 503 with this
15
+ * Retry-After (`data.retryAfterMs`, options.dokebi-sse.busRetryAfterMs) instead of 500.
16
+ */
17
+ readonly retryAfterMs?: number;
13
18
  });
14
19
  }
package/dist/errors.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { DokebiError } from '@dokebi/core';
2
2
  const FIX = {
3
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).',
4
+ INVALID_ROUTE: 'Give route a path starting with / (no query), an authenticate function, and optional onOpen/onClose functions, version text, constraints object, origins (* or exact https://host origins) and config ({ id } only, D-0140).',
5
5
  INVALID_IDENTITY: 'Return { tenant, member, rooms? } from authenticate, with strings of 1 to 256 characters (or undefined to refuse with 401).',
6
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_EVENT_ID: 'Give options.id as 1 to 64 of A-Z a-z 0-9 and - (for example a message sequence, String(seq)), or leave it out to let the plugin number the event.',
7
8
  INVALID_DATA: 'Send data that JSON.stringify accepts (no BigInt, functions or cycles).',
8
9
  INVALID_TARGET: 'Target { tenant, member?, room? } or { tenantPrefix, member?, room? } with strings of 1 to 256 characters.',
9
10
  INVALID_ROOM: 'Rooms are strings of 1 to 256 characters.',
@@ -21,11 +22,19 @@ export class SseError extends DokebiError {
21
22
  id;
22
23
  local;
23
24
  constructor(code, message, options) {
25
+ const retryAfterMs = options?.retryAfterMs;
24
26
  super(code, message, {
25
27
  plugin: 'dokebi-sse',
26
28
  fix: FIX[code],
27
29
  ...(options?.cause === undefined ? {} : { cause: options.cause }),
28
30
  ...(options?.details === undefined ? {} : { details: options.details }),
31
+ ...(retryAfterMs === undefined
32
+ ? {}
33
+ : {
34
+ status: 503,
35
+ hint: 'Live updates are busy or down; try again shortly.',
36
+ data: { retryAfterMs },
37
+ }),
29
38
  });
30
39
  this.name = 'SseError';
31
40
  if (options?.id !== undefined)
package/dist/gateway.d.ts CHANGED
@@ -27,12 +27,21 @@ export declare class Gateway {
27
27
  #private;
28
28
  readonly context: PluginContext;
29
29
  readonly web: WebApi;
30
- readonly options: Options;
30
+ options: Options;
31
31
  readonly bus: SseBus | undefined;
32
+ /** The bus's maxPayloadBytes as the app configuration says now; undefined when unknown. */
33
+ readonly busPayloadBytes: () => number | undefined;
32
34
  readonly api: SseApi;
33
- constructor(context: PluginContext, web: WebApi, options: Options, bus: SseBus | undefined, health: SseHealth | undefined);
35
+ constructor(context: PluginContext, web: WebApi, options: Options, bus: SseBus | undefined, health: SseHealth | undefined,
36
+ /** The bus's maxPayloadBytes as the app configuration says now; undefined when unknown. */
37
+ busPayloadBytes?: () => number | undefined);
34
38
  /** Subscribes to the bus (setup): every process on it delivers to its own matching streams. */
35
39
  subscribe(): Promise<void>;
40
+ /**
41
+ * D-0154: reloaded stream limits (retryMs, maxConnections, maxBufferedBytes, maxEventBytes) for
42
+ * the next stream or event, and the ping interval (the timer starts again).
43
+ */
44
+ reconfigure(next: Options): void;
36
45
  start(): void;
37
46
  /** Sends `dokebi-restart` to every stream, ends them, and cuts the rest at the deadline. */
38
47
  drain(signal: AbortSignal): Promise<void>;