@dokebi/sse 0.4.1 → 0.4.3
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 +31 -17
- package/README.md +3 -2
- package/dist/browser/sse-client.js +88 -87
- package/dist/client/index.js +24 -5
- package/dist/client/react.js +3 -1
- package/dist/errors.d.ts +6 -1
- package/dist/errors.js +10 -1
- package/dist/gateway.d.ts +11 -2
- package/dist/gateway.js +243 -35
- package/dist/options.d.ts +30 -0
- package/dist/options.js +20 -6
- package/dist/plugin.js +34 -6
- package/dist/retry.d.ts +1 -0
- package/dist/retry.js +25 -0
- package/dist/transport.js +6 -3
- package/dist/types.d.ts +33 -4
- package/options.schema.json +67 -0
- package/package.json +11 -11
package/AI.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# AI guide — @dokebi/sse
|
|
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
|
|
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`
|
|
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
|
|
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 :
|
|
9
|
-
if (typeof
|
|
10
|
-
let
|
|
11
|
-
if (
|
|
12
|
-
let
|
|
13
|
-
function
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
for (let t of [...
|
|
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
|
|
20
|
-
if (!i.onError && !
|
|
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 [...
|
|
23
|
+
for (let t of [...p]) t(e);
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
function
|
|
27
|
-
let t = globalThis.location?.href, n = new URL(
|
|
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),
|
|
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
|
|
31
|
+
return E = n.pathname, n.href;
|
|
32
32
|
}
|
|
33
|
-
function
|
|
33
|
+
function A(e, t) {
|
|
34
34
|
let n = t.startsWith("E:");
|
|
35
|
-
if (
|
|
35
|
+
if (T) try {
|
|
36
36
|
if (!n) throw Error("plain data on a sealed stream");
|
|
37
|
-
return
|
|
37
|
+
return T.unsealFrame(t, E ?? "/");
|
|
38
38
|
} catch (t) {
|
|
39
|
-
|
|
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
|
-
|
|
47
|
+
O({
|
|
48
48
|
code: "SEAL_BRIDGE_MISSING",
|
|
49
49
|
event: e
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
|
-
function
|
|
52
|
+
function j(e) {
|
|
53
53
|
return (t) => {
|
|
54
|
-
t.lastEventId && (
|
|
55
|
-
let n =
|
|
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
|
-
|
|
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 [...
|
|
72
|
+
for (let t of [...u.get(e) ?? []]) try {
|
|
73
73
|
t(r, i);
|
|
74
74
|
} catch (t) {
|
|
75
|
-
|
|
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
|
|
84
|
-
|
|
83
|
+
function M() {
|
|
84
|
+
y !== void 0 && clearTimeout(y), y = void 0;
|
|
85
85
|
}
|
|
86
|
-
function
|
|
87
|
-
|
|
86
|
+
function N() {
|
|
87
|
+
C++, m &&= (m.onopen = null, m.onerror = null, m.close(), void 0);
|
|
88
88
|
}
|
|
89
|
-
function
|
|
90
|
-
if (
|
|
89
|
+
function P() {
|
|
90
|
+
if (M(), N(), b || x) return;
|
|
91
91
|
if (!a) {
|
|
92
|
-
|
|
92
|
+
b = !0, D("closed"), O({ code: "NO_EVENTSOURCE" });
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
|
-
let o = new a(
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
99
|
+
m === o && F(o);
|
|
100
100
|
}, o.addEventListener(n, (e) => {
|
|
101
|
-
|
|
101
|
+
_ = r(A(n, e.data) ?? "") ?? _;
|
|
102
102
|
}), o.addEventListener(e, (t) => {
|
|
103
|
-
|
|
103
|
+
m === o && A(e, t.data) !== void 0 && R();
|
|
104
104
|
}), o.addEventListener(t, (e) => {
|
|
105
|
-
|
|
105
|
+
m === o && (_ = r(A(t, e.data) ?? "") ?? _, N(), D("reconnecting"), L());
|
|
106
106
|
});
|
|
107
|
-
for (let [e, t] of
|
|
107
|
+
for (let [e, t] of d) o.addEventListener(e, t);
|
|
108
108
|
}
|
|
109
|
-
function
|
|
109
|
+
function F(e) {
|
|
110
110
|
if (e.readyState !== 2) {
|
|
111
|
-
|
|
111
|
+
D("reconnecting");
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
114
|
-
if (
|
|
115
|
-
|
|
114
|
+
if (N(), !o) {
|
|
115
|
+
b = !0, D("closed"), O({ code: "CONNECTION_FAILED" });
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
|
-
|
|
118
|
+
D("reconnecting");
|
|
119
119
|
let t = globalThis.fetch;
|
|
120
|
-
i.probe === !1 || !t ? (
|
|
120
|
+
i.probe === !1 || !t ? (O({ code: "CONNECTION_FAILED" }), L()) : I(t, C);
|
|
121
121
|
}
|
|
122
|
-
async function
|
|
123
|
-
let
|
|
122
|
+
async function I(e, t) {
|
|
123
|
+
let n;
|
|
124
124
|
try {
|
|
125
|
-
|
|
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
|
-
|
|
130
|
+
if (b || x || t !== C) return;
|
|
131
|
+
O({
|
|
131
132
|
code: "CONNECTION_FAILED",
|
|
132
133
|
error: e
|
|
133
|
-
}),
|
|
134
|
+
}), L();
|
|
134
135
|
return;
|
|
135
136
|
}
|
|
136
|
-
|
|
137
|
-
code:
|
|
138
|
-
status:
|
|
139
|
-
})) : (
|
|
140
|
-
code:
|
|
141
|
-
status:
|
|
142
|
-
}),
|
|
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
|
|
145
|
-
if (
|
|
145
|
+
function L() {
|
|
146
|
+
if (M(), b || x) return;
|
|
146
147
|
if (!o) {
|
|
147
|
-
|
|
148
|
+
b = !0, D("closed");
|
|
148
149
|
return;
|
|
149
150
|
}
|
|
150
|
-
let e = Math.max(
|
|
151
|
-
|
|
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
|
|
154
|
-
|
|
154
|
+
function R() {
|
|
155
|
+
b = !0, N(), M(), D("closed"), i.onReload ? i.onReload() : globalThis.location?.reload();
|
|
155
156
|
}
|
|
156
|
-
let
|
|
157
|
-
e.persisted && !
|
|
158
|
-
},
|
|
159
|
-
e.persisted && !
|
|
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
|
|
162
|
+
return z.addEventListener?.("pagehide", B), z.addEventListener?.("pageshow", V), P(), {
|
|
162
163
|
get status() {
|
|
163
|
-
return
|
|
164
|
+
return h;
|
|
164
165
|
},
|
|
165
166
|
get lastEventId() {
|
|
166
|
-
return
|
|
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 =
|
|
171
|
+
let n = u.get(e);
|
|
171
172
|
if (!n) {
|
|
172
|
-
n = /* @__PURE__ */ new Set(),
|
|
173
|
-
let t =
|
|
174
|
-
|
|
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 =
|
|
179
|
+
let t = u.get(e);
|
|
179
180
|
if (!t?.delete(r) || t.size) return;
|
|
180
|
-
|
|
181
|
-
let n =
|
|
182
|
-
|
|
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
|
|
187
|
+
return f.add(e), () => f.delete(e);
|
|
187
188
|
},
|
|
188
189
|
onError(e) {
|
|
189
|
-
return
|
|
190
|
+
return p.add(e), () => p.delete(e);
|
|
190
191
|
},
|
|
191
192
|
close() {
|
|
192
|
-
|
|
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 =
|
|
196
|
-
|
|
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
|
}
|
package/dist/client/index.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
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;
|
package/dist/client/react.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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>;
|