@dokebi/sse 0.4.3 → 0.4.5
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 +2 -2
- package/dist/gateway.js +20 -2
- package/dist/plugin.js +1 -1
- package/dist/wire.d.ts +0 -1
- package/dist/wire.js +3 -1
- package/package.json +6 -6
package/AI.md
CHANGED
|
@@ -94,7 +94,7 @@ export default defineApp({
|
|
|
94
94
|
|
|
95
95
|
## 4. Connection lifetime
|
|
96
96
|
|
|
97
|
-
- Response: 200 `text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no
|
|
97
|
+
- Response: 200 `text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`; no `Connection` of its own, so Node answers the request's (a proxy that sends `Connection: close` upstream gets the socket closed after the stream, never reused). 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).
|
|
98
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
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.)
|
|
100
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.
|
|
@@ -135,7 +135,7 @@ events.close(); events.reconnect();
|
|
|
135
135
|
|
|
136
136
|
## 5a. Seal hosts (D-0062)
|
|
137
137
|
|
|
138
|
-
On streams of a seal host (@dokebi/app `domains.<host>.seal: true`) or of a `seal: true` web route, **the data of every event** (including `dokebi-open`, `dokebi-reload` and `dokebi-restart`) is sealed with that stream's path (`E:<ts>:<b64>`, dokebi-web `sealOf`). Event names and ids stay plain. Plain streams share one buffer; only sealed streams seal per receiver. A failed seal ends that stream (`SEAL_ENCODE_FAILED`; never sent in plain text). Browser: `connectEvents({ ..., seal: currentSealBridge() })` (`@dokebi/seal/http`; undefined on plain pages, so the same code works). With a bridge, plain data is refused as `SEAL_FAILED`; without one, sealed data is refused as `SEAL_BRIDGE_MISSING` (neither reaches the handler).
|
|
138
|
+
On streams of a seal host (@dokebi/app `domains.<host>.seal: true`) or of a `seal: true` web route, **the data of every event** (including `dokebi-open`, `dokebi-reload` and `dokebi-restart`) is sealed with that stream's path (`E:<ts>:<b64>`, dokebi-web `sealOf`). Event names and ids stay plain. Plain streams share one buffer; only sealed streams seal per receiver. A failed seal ends that stream (`SEAL_ENCODE_FAILED`; never sent in plain text); when it is the first frame (`dokebi-open`, `dokebi-reload`) that cannot be sealed, the request is refused with 500 `SEAL_FAILED` before the stream head. Browser: `connectEvents({ ..., seal: currentSealBridge() })` (`@dokebi/seal/http`; undefined on plain pages, so the same code works). With a bridge, plain data is refused as `SEAL_FAILED`; without one, sealed data is refused as `SEAL_BRIDGE_MISSING` (neither reaches the handler).
|
|
139
139
|
|
|
140
140
|
## 6. Several workers and servers
|
|
141
141
|
|
package/dist/gateway.js
CHANGED
|
@@ -341,6 +341,18 @@ export class Gateway {
|
|
|
341
341
|
// D-0062: a stream of a seal host seals the data of every event, its first ones included.
|
|
342
342
|
const seal = this.web.sealOf(request);
|
|
343
343
|
const data = (json) => (seal ? seal.encode(json) : json);
|
|
344
|
+
// The first frame is made before the reply is hijacked: a seal that fails here still has a
|
|
345
|
+
// reply to refuse with. Made after the head (audit R9-g11-2), the exception left a hijacked
|
|
346
|
+
// response that was never ended: no stream entry, no ping, nothing drain could end.
|
|
347
|
+
const first = (event, value) => {
|
|
348
|
+
try {
|
|
349
|
+
return frame(undefined, event, data(JSON.stringify(value)));
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
this.#throttle.log('error', 'SSE first frame seal failed; stream refused', 'SEAL_ENCODE_FAILED', error, { path: route.path, routeId: registered.id });
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
344
356
|
if (route.version !== undefined) {
|
|
345
357
|
const sent = single(query.dokebiVersion) ?? single(request.headers['x-dokebi-version']);
|
|
346
358
|
if (sent !== route.version) {
|
|
@@ -349,12 +361,18 @@ export class Gateway {
|
|
|
349
361
|
expected: route.version,
|
|
350
362
|
received: typeof sent === 'string' ? sent.slice(0, 64) : null,
|
|
351
363
|
});
|
|
364
|
+
const reload = first(RELOAD_EVENT, { version: route.version });
|
|
365
|
+
if (reload === undefined)
|
|
366
|
+
return refuse(500, { error: 'SEAL_FAILED' });
|
|
352
367
|
reply.hijack();
|
|
353
368
|
res.writeHead(200, headers);
|
|
354
|
-
res.end(`retry: ${this.options.reloadRetryMs}\n${
|
|
369
|
+
res.end(`retry: ${this.options.reloadRetryMs}\n${reload}`);
|
|
355
370
|
return;
|
|
356
371
|
}
|
|
357
372
|
}
|
|
373
|
+
const opening = first(OPEN_EVENT, { retryMs: this.options.retryMs });
|
|
374
|
+
if (opening === undefined)
|
|
375
|
+
return refuse(500, { error: 'SEAL_FAILED' });
|
|
358
376
|
const header = single(request.headers['last-event-id']);
|
|
359
377
|
const asked = header ?? single(query.lastEventId);
|
|
360
378
|
const lastEventId = typeof asked === 'string' && asked.length <= MAX_TEXT && isLine(asked) ? asked : undefined;
|
|
@@ -366,7 +384,7 @@ export class Gateway {
|
|
|
366
384
|
res.writeHead(200, headers);
|
|
367
385
|
// The reconnect hint first, and the same value as data for clients that manage reconnects
|
|
368
386
|
// themselves (EventSource does not expose `retry:`).
|
|
369
|
-
res.write(`retry: ${this.options.retryMs}\n${
|
|
387
|
+
res.write(`retry: ${this.options.retryMs}\n${opening}`);
|
|
370
388
|
const entry = this.#add(res, identity, route, rooms, lastEventId, seal);
|
|
371
389
|
res.once('close', () => this.#closed(entry));
|
|
372
390
|
if (identity.authorization) {
|
package/dist/plugin.js
CHANGED
|
@@ -109,7 +109,7 @@ class SseRuntime {
|
|
|
109
109
|
/** Factory input: `{ app, options? }`. Options default to `app.config['dokebi-sse']`. */
|
|
110
110
|
export const createPlugin = definePlugin({
|
|
111
111
|
name: 'dokebi-sse',
|
|
112
|
-
version: '0.4.
|
|
112
|
+
version: '0.4.5',
|
|
113
113
|
core: '^0.4.2',
|
|
114
114
|
// The declaration (not parseOptions) so core logs OPTION_DEPRECATED for old keys (bus, closeTimeoutMs).
|
|
115
115
|
options: declaration,
|
package/dist/wire.d.ts
CHANGED
package/dist/wire.js
CHANGED
|
@@ -40,5 +40,7 @@ export const HEADERS = Object.freeze({
|
|
|
40
40
|
'cache-control': 'no-cache, no-transform',
|
|
41
41
|
// nginx: do not buffer this response.
|
|
42
42
|
'x-accel-buffering': 'no',
|
|
43
|
-
connection
|
|
43
|
+
// No `connection`: Node answers the request's own (keep-alive, or close when the proxy asked to
|
|
44
|
+
// close). A forced keep-alive kept a socket the proxy had closed for itself, and the request it
|
|
45
|
+
// sent next on it was refused by Node's parser with a bare 400 (HPE_CLOSED_CONNECTION).
|
|
44
46
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dokebi/sse",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Server-sent events for @dokebi/web: authenticated streams, rooms, cross-worker fan-out over @dokebi/event-bus, and a browser client",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dokebi",
|
|
@@ -68,12 +68,12 @@
|
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
|
-
"@dokebi/cluster": "0.4.
|
|
72
|
-
"@dokebi/config": "0.4.
|
|
73
|
-
"@dokebi/event-bus": "0.4.
|
|
71
|
+
"@dokebi/cluster": "0.4.2",
|
|
72
|
+
"@dokebi/config": "0.4.6",
|
|
73
|
+
"@dokebi/event-bus": "0.4.3",
|
|
74
74
|
"@dokebi/health": "0.4.1",
|
|
75
|
-
"@dokebi/nats": "0.4.
|
|
76
|
-
"@dokebi/web": "0.4.
|
|
75
|
+
"@dokebi/nats": "0.4.2",
|
|
76
|
+
"@dokebi/web": "0.4.7",
|
|
77
77
|
"react": "19.3.0",
|
|
78
78
|
"react-test-renderer": "19.3.0",
|
|
79
79
|
"vite": "8.3.4",
|