@fuzdev/fuz_app 0.129.0 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/actions/CLAUDE.md +23 -6
  2. package/dist/actions/connection_closer.d.ts +3 -1
  3. package/dist/actions/connection_closer.d.ts.map +1 -1
  4. package/dist/actions/register_action_ws.d.ts +46 -0
  5. package/dist/actions/register_action_ws.d.ts.map +1 -1
  6. package/dist/actions/register_action_ws.js +105 -14
  7. package/dist/actions/transports.d.ts +2 -1
  8. package/dist/actions/transports.d.ts.map +1 -1
  9. package/dist/actions/transports.js +2 -1
  10. package/dist/actions/transports_ws_backend.d.ts +37 -4
  11. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  12. package/dist/actions/transports_ws_backend.js +68 -4
  13. package/dist/auth/audit_log_routes.d.ts +7 -3
  14. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  15. package/dist/auth/audit_log_routes.js +48 -26
  16. package/dist/http/CLAUDE.md +1 -1
  17. package/dist/http/jsonrpc_errors.d.ts.map +1 -1
  18. package/dist/http/jsonrpc_errors.js +7 -5
  19. package/dist/realtime/sse.d.ts +14 -1
  20. package/dist/realtime/sse.d.ts.map +1 -1
  21. package/dist/realtime/sse.js +77 -1
  22. package/dist/realtime/sse_auth_guard.js +1 -1
  23. package/dist/realtime/subscriber_registry.d.ts +44 -8
  24. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  25. package/dist/realtime/subscriber_registry.js +64 -10
  26. package/dist/server/app_server.d.ts +4 -1
  27. package/dist/server/app_server.d.ts.map +1 -1
  28. package/dist/testing/CLAUDE.md +16 -0
  29. package/dist/testing/cross_backend/peer_ping_ws.js +4 -4
  30. package/dist/testing/cross_backend/ws_inbound_dispatch.d.ts +20 -0
  31. package/dist/testing/cross_backend/ws_inbound_dispatch.d.ts.map +1 -0
  32. package/dist/testing/cross_backend/ws_inbound_dispatch.js +177 -0
  33. package/package.json +1 -1
@@ -56,9 +56,13 @@ export interface AuditLogRouteOptions {
56
56
  * nothing else — to an `EventSource`, a stream closed an instant after it
57
57
  * opened (the client's reconnect is answered by the gates, with the precise
58
58
  * status). A pending registration receives no audit row, counts toward no cap,
59
- * and is removed when a re-read refuses the request. The twin of the Rust
60
- * spine's `audit_stream_router`, which registers before its role read rather
61
- * than repeating it.
59
+ * and is removed when a re-read refuses the request. Once the server is
60
+ * shutting down (`SubscriberRegistry` class doc, "Shutdown") the registration
61
+ * is born closed, the re-reads are skipped, and the answer is that same
62
+ * connect comment alone — as it is when the shutdown closes a registration
63
+ * whose re-read is in flight, even if the re-read then fails. The twin of the
64
+ * Rust spine's `audit_stream_router`, which registers before its role read
65
+ * rather than repeating it.
62
66
  *
63
67
  * @param options - optional stream wiring + role override
64
68
  * @returns the SSE route spec (when `options.stream` is provided) or an empty array
@@ -1 +1 @@
1
- {"version":3,"file":"audit_log_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_log_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAUvD,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAU7E,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACpC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE;QACR,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;QAC9C,GAAG,EAAE,MAAM,CAAC;KACZ,CAAC;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,4BAA4B,GAAI,UAAU,oBAAoB,KAAG,KAAK,CAAC,SAAS,CAgF5F,CAAC"}
1
+ {"version":3,"file":"audit_log_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_log_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAUvD,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAU7E,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACpC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE;QACR,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;QAC9C,GAAG,EAAE,MAAM,CAAC;KACZ,CAAC;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,4BAA4B,GAAI,UAAU,oBAAoB,KAAG,KAAK,CAAC,SAAS,CAiG5F,CAAC"}
@@ -42,9 +42,13 @@ import { AUDIT_LOG_CHANNEL } from '../realtime/sse_auth_guard.js';
42
42
  * nothing else — to an `EventSource`, a stream closed an instant after it
43
43
  * opened (the client's reconnect is answered by the gates, with the precise
44
44
  * status). A pending registration receives no audit row, counts toward no cap,
45
- * and is removed when a re-read refuses the request. The twin of the Rust
46
- * spine's `audit_stream_router`, which registers before its role read rather
47
- * than repeating it.
45
+ * and is removed when a re-read refuses the request. Once the server is
46
+ * shutting down (`SubscriberRegistry` class doc, "Shutdown") the registration
47
+ * is born closed, the re-reads are skipped, and the answer is that same
48
+ * connect comment alone — as it is when the shutdown closes a registration
49
+ * whose re-read is in flight, even if the re-read then fails. The twin of the
50
+ * Rust spine's `audit_stream_router`, which registers before its role read
51
+ * rather than repeating it.
48
52
  *
49
53
  * @param options - optional stream wiring + role override
50
54
  * @returns the SSE route spec (when `options.stream` is provided) or an empty array
@@ -87,34 +91,52 @@ export const create_audit_log_route_specs = (options) => {
87
91
  // a revocation that closed before the registration is committed,
88
92
  // so these see it. A throw here fails closed — no stream opens
89
93
  // unchecked. Skipped under the test-preset escape hatch, whose
90
- // pre-baked context has no rows behind it to re-read.
91
- if (!c.get(TEST_CONTEXT_PRESET_KEY)) {
92
- const resolved = get_resolved_auth(c);
93
- const credential_is_live = resolved !== null && (await revalidate_resolved_auth(route, resolved));
94
- if (!credential_is_live) {
95
- log.info('audit stream: credential revoked during the request', ctx.account.id);
96
- return c.json({ error: ERROR_AUTHENTICATION_REQUIRED }, 401);
97
- }
98
- // Global grants only, like the `require_role` gate that ran in
99
- // the pipeline — a scoped grant must not open the instance-wide
100
- // stream.
101
- if (!has_scoped_role(await refresh_role_grants(ctx, route), required_role, null)) {
102
- log.info('audit stream: role revoked during the request', ctx.account.id);
103
- return c.json({ error: ERROR_INSUFFICIENT_PERMISSIONS, required_roles: [required_role] }, 403);
104
- }
94
+ // pre-baked context has no rows behind it to re-read, and for a
95
+ // registration born closed by the shutdown, which admission
96
+ // refuses whatever they answer.
97
+ if (!c.get(TEST_CONTEXT_PRESET_KEY) && !registry.closing) {
98
+ const reread = async () => {
99
+ const resolved = get_resolved_auth(c);
100
+ const credential_is_live = resolved !== null && (await revalidate_resolved_auth(route, resolved));
101
+ if (!credential_is_live) {
102
+ log.info('audit stream: credential revoked during the request', ctx.account.id);
103
+ return c.json({ error: ERROR_AUTHENTICATION_REQUIRED }, 401);
104
+ }
105
+ // Global grants only, like the `require_role` gate that ran in
106
+ // the pipeline — a scoped grant must not open the instance-wide
107
+ // stream.
108
+ if (!has_scoped_role(await refresh_role_grants(ctx, route), required_role, null)) {
109
+ log.info('audit stream: role revoked during the request', ctx.account.id);
110
+ return c.json({ error: ERROR_INSUFFICIENT_PERMISSIONS, required_roles: [required_role] }, 403);
111
+ }
112
+ return null;
113
+ };
114
+ const refusal = await reread().catch((error) => {
115
+ // the shutdown closed the registration and then the database
116
+ // under the re-read: the answer is the shutdown's — admission
117
+ // below refuses, since a closing registry admits nothing —
118
+ // not a failure
119
+ if (registry.closing)
120
+ return null;
121
+ throw error;
122
+ });
123
+ if (refusal)
124
+ return refusal;
105
125
  }
106
126
  // Admit — the cap's eviction happens here, after every gate. A
107
- // registration a revocation closed while it was pending is not
108
- // admitted: the stream is closed at once, so the body is the
127
+ // registration a revocation or the shutdown closed while it was
128
+ // pending, or one born closed, is not admitted: the stream is
129
+ // closed at once, so the body is the
109
130
  // connect comment and nothing else, and the client reconnects
110
- // into the gates.
131
+ // into the gates. The close listener goes on first: a client that
132
+ // left during the re-reads gets a stream that is already closed,
133
+ // whose listener runs at once and removes the registration, so
134
+ // `admit` refuses it rather than evicting a live stream for it.
111
135
  const { response, stream } = create_sse_response(c, log);
136
+ stream.on_close(pending.unsubscribe);
112
137
  admitted = registry.admit(pending, stream);
113
- if (admitted) {
114
- stream.on_close(pending.unsubscribe);
115
- }
116
- else {
117
- log.info('audit stream: closed by a revocation before admission', ctx.account.id);
138
+ if (!admitted) {
139
+ log.info('audit stream: closed before admission', ctx.account.id);
118
140
  stream.close();
119
141
  }
120
142
  return response;
@@ -383,7 +383,7 @@ own by casting `as JsonrpcErrorCode`):
383
383
  - `rate_limited` (-32006, HTTP 429) — server-side policy.
384
384
  - `service_unavailable` (-32007, HTTP 503) — upstream down / maintenance.
385
385
  - `timeout` (-32008, HTTP 504) — handler exceeded time budget.
386
- - `queue_overflow` (-32009, HTTP 429) — **client-side** backpressure (WS reconnect queue full).
386
+ - `queue_overflow` (-32009, HTTP 429) — backpressure: a buffer refused a new request because it was full — client-side (the WS reconnect queue) or server-side (a socket past `MAX_INBOUND_DISPATCHES_PER_CONNECTION` in-flight requests).
387
387
  - `request_cancelled` (-32010, HTTP 499) — caller-initiated cancellation (nginx "client closed").
388
388
 
389
389
  **`invalid_params` vs `validation_error`** — use `invalid_params` (standard
@@ -1 +1 @@
1
- {"version":3,"file":"jsonrpc_errors.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/jsonrpc_errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAEnE,OAAO,EAMN,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,MAAM,cAAc,CAAC;AAEtB,0CAA0C;AAC1C,eAAO,MAAM,qBAAqB,kBAAkB,CAAC;AAErD,sEAAsE;AACtE,MAAM,MAAM,gBAAgB,GACzB,aAAa,GACb,iBAAiB,GACjB,kBAAkB,GAClB,gBAAgB,GAChB,gBAAgB,GAChB,iBAAiB,GACjB,WAAW,GACX,WAAW,GACX,UAAU,GACV,kBAAkB,GAClB,cAAc,GACd,qBAAqB,GACrB,SAAS,GACT,gBAAgB,GAChB,mBAAmB,CAAC;AAEvB;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,EA0C1B,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,QAAQ,GAAI,CAAC,EAAE,OAAO,CAAC,KAAG,CAAC,GAAG,SAAsC,CAAC;AAElF;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,EAmG7B,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,kBAAkB,CAAC,CAAC,CAAC;AAEtF;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC5C,IAAI,EAAE,gBAAgB,CAAC;IACvB,IAAI,CAAC,EAAE,OAAO,CAAC;gBAEH,IAAI,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,YAAY;CAK3F;AAWD;;;;GAIG;AACH,eAAO,MAAM,cAAc;8CAXQ,kBAAkB;kDAAlB,kBAAkB;mDAAlB,kBAAkB;iDAAlB,kBAAkB;iDAAlB,kBAAkB;kDAAlB,kBAAkB;4CAAlB,kBAAkB;4CAAlB,kBAAkB;2CAAlB,kBAAkB;mDAAlB,kBAAkB;+CAAlB,kBAAkB;sDAAlB,kBAAkB;0CAAlB,kBAAkB;iDAAlB,kBAAkB;oDAAlB,kBAAkB;CA2BqC,CAAC;AAI3F;;;;;;;GAOG;AACH,eAAO,MAAM,iCAAiC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAkBpE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAMzC,CAAC;AAEvC;;;;;;;;;GASG;AACH,eAAO,MAAM,iCAAiC,GAAI,MAAM,gBAAgB,KAAG,oBACQ,CAAC;AAEpF;;;;GAIG;AACH,eAAO,MAAM,iCAAiC,GAAI,QAAQ,MAAM,KAAG,gBACa,CAAC;AAEjF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAMjF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,GAAI,MAAM,gBAAgB,KAAG,gBACL,CAAC"}
1
+ {"version":3,"file":"jsonrpc_errors.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/jsonrpc_errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAEnE,OAAO,EAMN,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,MAAM,cAAc,CAAC;AAEtB,0CAA0C;AAC1C,eAAO,MAAM,qBAAqB,kBAAkB,CAAC;AAErD,sEAAsE;AACtE,MAAM,MAAM,gBAAgB,GACzB,aAAa,GACb,iBAAiB,GACjB,kBAAkB,GAClB,gBAAgB,GAChB,gBAAgB,GAChB,iBAAiB,GACjB,WAAW,GACX,WAAW,GACX,UAAU,GACV,kBAAkB,GAClB,cAAc,GACd,qBAAqB,GACrB,SAAS,GACT,gBAAgB,GAChB,mBAAmB,CAAC;AAEvB;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,EA4C1B,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,QAAQ,GAAI,CAAC,EAAE,OAAO,CAAC,KAAG,CAAC,GAAG,SAAsC,CAAC;AAElF;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,EAmG7B,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,kBAAkB,CAAC,CAAC,CAAC;AAEtF;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC5C,IAAI,EAAE,gBAAgB,CAAC;IACvB,IAAI,CAAC,EAAE,OAAO,CAAC;gBAEH,IAAI,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,YAAY;CAK3F;AAWD;;;;GAIG;AACH,eAAO,MAAM,cAAc;8CAXQ,kBAAkB;kDAAlB,kBAAkB;mDAAlB,kBAAkB;iDAAlB,kBAAkB;iDAAlB,kBAAkB;kDAAlB,kBAAkB;4CAAlB,kBAAkB;4CAAlB,kBAAkB;2CAAlB,kBAAkB;mDAAlB,kBAAkB;+CAAlB,kBAAkB;sDAAlB,kBAAkB;0CAAlB,kBAAkB;iDAAlB,kBAAkB;oDAAlB,kBAAkB;CA2BqC,CAAC;AAI3F;;;;;;;GAOG;AACH,eAAO,MAAM,iCAAiC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAkBpE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAMzC,CAAC;AAEvC;;;;;;;;;GASG;AACH,eAAO,MAAM,iCAAiC,GAAI,MAAM,gBAAgB,KAAG,oBACQ,CAAC;AAEpF;;;;GAIG;AACH,eAAO,MAAM,iCAAiC,GAAI,QAAQ,MAAM,KAAG,gBACa,CAAC;AAEjF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAMjF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,GAAI,MAAM,gBAAgB,KAAG,gBACL,CAAC"}
@@ -61,9 +61,11 @@ export const JSONRPC_ERROR_CODES = Object.freeze({
61
61
  service_unavailable: -32007,
62
62
  timeout: -32008,
63
63
  /**
64
- * Client-side backpressure — an outbound buffer (e.g. `FrontendWebsocketClient`'s
65
- * disconnected request queue) refused a new request because it was full.
66
- * Distinct from `rate_limited`, which signals a server-side policy.
64
+ * Backpressure — a buffer refused a new request because it was full: on the
65
+ * client an outbound buffer (e.g. `FrontendWebsocketClient`'s disconnected
66
+ * request queue), on the server a WebSocket already running
67
+ * `MAX_INBOUND_DISPATCHES_PER_CONNECTION` requests. Distinct from
68
+ * `rate_limited`, which signals a server-side policy over time.
67
69
  */
68
70
  queue_overflow: -32009,
69
71
  /**
@@ -242,8 +244,8 @@ export const JSONRPC_ERROR_CODE_TO_HTTP_STATUS = {
242
244
  [-32004]: 409, // conflict
243
245
  [-32005]: 422, // validation_error
244
246
  // queue_overflow shares 429 with rate_limited — listed first so reverse
245
- // map wins with rate_limited (server-side) rather than client-side overflow.
246
- [-32009]: 429, // queue_overflow (client-side backpressure)
247
+ // map wins with rate_limited (a policy) rather than a full buffer.
248
+ [-32009]: 429, // queue_overflow (backpressure)
247
249
  [-32006]: 429, // rate_limited
248
250
  [-32007]: 503, // service_unavailable
249
251
  [-32008]: 504, // timeout
@@ -21,7 +21,12 @@ export interface SseStream<T = unknown> {
21
21
  comment: (text: string) => void;
22
22
  /** Close the stream. */
23
23
  close: () => void;
24
- /** Register a listener called when the stream closes (client disconnect or explicit close). */
24
+ /**
25
+ * Register a listener called when the stream closes (client disconnect or
26
+ * explicit close). Called at once when the stream has already closed — a
27
+ * client can leave before the caller registers — so a cleanup registered
28
+ * through it always runs.
29
+ */
25
30
  on_close: (fn: () => void) => void;
26
31
  }
27
32
  /**
@@ -43,6 +48,14 @@ export interface SseNotification {
43
48
  * The callback suspends via a promise that resolves on client disconnect
44
49
  * or explicit `close()`, keeping the stream alive for external sends.
45
50
  *
51
+ * The stream closes when its client disconnects, whenever that happens — also
52
+ * before the handler returns the response, which no runtime reports by
53
+ * cancelling the body. A client already gone when this is called yields a
54
+ * stream that is already closed, so register cleanup with `on_close` (which
55
+ * runs a late listener at once) before handing the stream anywhere that would
56
+ * count it — `SubscriberRegistry.admit` after `on_close(pending.unsubscribe)`
57
+ * refuses it.
58
+ *
46
59
  * Uses `hono_stream.write()` directly (not `writeSSE`) to avoid
47
60
  * Hono's HTML callback resolution — keeps the same `data: JSON\n\n` format.
48
61
  *
@@ -1 +1 @@
1
- {"version":3,"file":"sse.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAItD;;;;GAIG;AACH,MAAM,WAAW,SAAS,CAAC,CAAC,GAAG,OAAO;IACrC,mDAAmD;IACnD,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;IACxB,6CAA6C;IAC7C,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,wBAAwB;IACxB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,+FAA+F;IAC/F,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;CACnC;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC/B,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,+BAA+B;IAC/B,MAAM,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,mBAAmB,GAAI,CAAC,GAAG,OAAO,EAC9C,GAAG,OAAO,EACV,KAAK,MAAM,KACT;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAA;CAiD5C,CAAC;AAEF,gFAAgF;AAChF,MAAM,WAAW,SAAS;IACzB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC;IAClB,8DAA8D;IAC9D,WAAW,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GAAI,CAAC,SAAS,eAAe,EACrE,aAAa;IAAE,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAAE,EAC9D,aAAa,KAAK,CAAC,SAAS,CAAC,EAC7B,KAAK,MAAM,KACT;IAAE,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAmBjD,CAAC"}
1
+ {"version":3,"file":"sse.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAItD;;;;GAIG;AACH,MAAM,WAAW,SAAS,CAAC,CAAC,GAAG,OAAO;IACrC,mDAAmD;IACnD,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;IACxB,6CAA6C;IAC7C,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,wBAAwB;IACxB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;;;;OAKG;IACH,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;CACnC;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC/B,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,+BAA+B;IAC/B,MAAM,EAAE,OAAO,CAAC;CAChB;AA8DD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,mBAAmB,GAAI,CAAC,GAAG,OAAO,EAC9C,GAAG,OAAO,EACV,KAAK,MAAM,KACT;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAA;CA4D5C,CAAC;AAEF,gFAAgF;AAChF,MAAM,WAAW,SAAS;IACzB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC;IAClB,8DAA8D;IAC9D,WAAW,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GAAI,CAAC,SAAS,eAAe,EACrE,aAAa;IAAE,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAAE,EAC9D,aAAa,KAAK,CAAC,SAAS,CAAC,EAC7B,KAAK,MAAM,KACT;IAAE,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAmBjD,CAAC"}
@@ -10,6 +10,62 @@ import { streamSSE } from 'hono/streaming';
10
10
  import { z } from 'zod';
11
11
  import { DEV } from 'esm-env';
12
12
  import { SSE_CONNECTED_COMMENT } from './sse_constants.js';
13
+ const is_node_connection_stream = (value) => {
14
+ if (typeof value !== 'object' || value === null)
15
+ return false;
16
+ const candidate = value;
17
+ return typeof candidate.destroyed === 'boolean' && typeof candidate.once === 'function';
18
+ };
19
+ /**
20
+ * The Node stream that ends with the client's connection, from the
21
+ * `c.env.outgoing` that `@hono/node-server` passes a handler: over HTTP/1 the
22
+ * `ServerResponse` itself, over HTTP/2 the `Http2ServerResponse`'s `stream` —
23
+ * the response is no stream there and has no `destroyed`.
24
+ */
25
+ const to_node_connection_stream = (env) => {
26
+ if (typeof env !== 'object' || env === null)
27
+ return null;
28
+ const outgoing = env.outgoing;
29
+ if (typeof outgoing !== 'object' || outgoing === null)
30
+ return null;
31
+ if (is_node_connection_stream(outgoing))
32
+ return outgoing;
33
+ const { stream } = outgoing;
34
+ return is_node_connection_stream(stream) ? stream : null;
35
+ };
36
+ /**
37
+ * Call `on_gone` once the client of `c` has disconnected — at once if it
38
+ * already has.
39
+ *
40
+ * A stream closes on its own only when the runtime cancels the response body,
41
+ * and a runtime cancels only a body it has started to send. A client that
42
+ * leaves before the handler returns — one awaiting a credential re-read, say —
43
+ * is never sent the body, so nothing would close the stream.
44
+ *
45
+ * On `@hono/node-server` the request's `signal` cannot be trusted for this:
46
+ * the adapter creates it on first read and aborts it only if it existed when
47
+ * the connection closed, so a handler that reads it after an `await` can hold
48
+ * a signal that never fires. The Node stream behind the response says whether
49
+ * the connection is gone and emits `close` when it goes, so that is what is
50
+ * watched there — the response object (`c.env.outgoing`) over HTTP/1, its
51
+ * `Http2Stream` over HTTP/2. Elsewhere the request's `signal` is the
52
+ * disconnect — the Fetch-standard runtimes abort it when the client leaves.
53
+ */
54
+ const watch_client_disconnect = (c, on_gone) => {
55
+ const connection = to_node_connection_stream(c.env);
56
+ if (connection) {
57
+ if (connection.destroyed)
58
+ on_gone();
59
+ else
60
+ connection.once('close', on_gone);
61
+ return;
62
+ }
63
+ const { signal } = c.req.raw;
64
+ if (signal.aborted)
65
+ on_gone();
66
+ else
67
+ signal.addEventListener('abort', on_gone, { once: true });
68
+ };
13
69
  /**
14
70
  * Create an SSE response for a Hono context.
15
71
  *
@@ -18,6 +74,14 @@ import { SSE_CONNECTED_COMMENT } from './sse_constants.js';
18
74
  * The callback suspends via a promise that resolves on client disconnect
19
75
  * or explicit `close()`, keeping the stream alive for external sends.
20
76
  *
77
+ * The stream closes when its client disconnects, whenever that happens — also
78
+ * before the handler returns the response, which no runtime reports by
79
+ * cancelling the body. A client already gone when this is called yields a
80
+ * stream that is already closed, so register cleanup with `on_close` (which
81
+ * runs a late listener at once) before handing the stream anywhere that would
82
+ * count it — `SubscriberRegistry.admit` after `on_close(pending.unsubscribe)`
83
+ * refuses it.
84
+ *
21
85
  * Uses `hono_stream.write()` directly (not `writeSSE`) to avoid
22
86
  * Hono's HTML callback resolution — keeps the same `data: JSON\n\n` format.
23
87
  *
@@ -64,7 +128,16 @@ export const create_sse_response = (c, log) => {
64
128
  },
65
129
  close: do_close,
66
130
  on_close(fn) {
67
- close_listeners.push(fn);
131
+ if (!resolved) {
132
+ close_listeners.push(fn);
133
+ return;
134
+ }
135
+ try {
136
+ fn();
137
+ }
138
+ catch (e) {
139
+ log.error('on_close listener threw:', e);
140
+ }
68
141
  }
69
142
  };
70
143
  hono_stream.onAbort(do_close);
@@ -73,6 +146,9 @@ export const create_sse_response = (c, log) => {
73
146
  void hono_stream.write(SSE_CONNECTED_COMMENT);
74
147
  await promise;
75
148
  });
149
+ // after the callback above has built `sse_stream`, which runs synchronously
150
+ // up to its first `await`
151
+ watch_client_disconnect(c, do_close);
76
152
  return { response, stream: sse_stream };
77
153
  };
78
154
  /**
@@ -205,7 +205,7 @@ export const AUDIT_LOG_SSE_MAX_PER_SCOPE = 10;
205
205
  export const create_audit_log_sse = (options) => {
206
206
  const role = options.role ?? 'admin';
207
207
  const max_per_scope = options.max_per_scope === undefined ? AUDIT_LOG_SSE_MAX_PER_SCOPE : options.max_per_scope;
208
- const registry = new SubscriberRegistry({ max_per_scope });
208
+ const registry = new SubscriberRegistry({ max_per_scope, log: options.log });
209
209
  const guard = create_sse_auth_guard(registry, role, options.log);
210
210
  options.connection_closer?.add(create_sse_connection_closer(registry));
211
211
  return {
@@ -22,6 +22,7 @@
22
22
  *
23
23
  * @module
24
24
  */
25
+ import { Logger } from '@fuzdev/fuz_util/log.ts';
25
26
  import type { SseStream } from './sse.js';
26
27
  /** One admitted subscriber, as `SubscriberRegistry.broadcast` sees it. */
27
28
  export interface Subscriber<T> {
@@ -42,6 +43,13 @@ export interface SubscriberRegistryOptions {
42
43
  * `null` (default) disables the cap. `groups` identities are never capped.
43
44
  */
44
45
  max_per_scope?: number | null;
46
+ /**
47
+ * Logger for a stream whose `close` throws while the cap evicts it — the
48
+ * evicted stream is another subscriber's, so its failure is logged rather
49
+ * than failing the admission that evicted it. Unset falls back to an
50
+ * `[sse]` logger; `null` silences it.
51
+ */
52
+ log?: Logger | null;
45
53
  }
46
54
  /** Options for `SubscriberRegistry.subscribe` and `subscribe_pending`. */
47
55
  export interface SubscribeOptions {
@@ -105,6 +113,16 @@ export interface PendingSubscription {
105
113
  * behind it. The twin of the Rust `fuz_realtime` `SseRegistry`
106
114
  * (`subscribe_pending` / `admit` / `subscribe`).
107
115
  *
116
+ * ## Shutdown
117
+ *
118
+ * `close_all` — the close `AppServer.close` runs — closes every stream,
119
+ * pending or admitted, and leaves the registry **closing** for good: every
120
+ * later registration is born closed. `subscribe_pending` registers nothing,
121
+ * so `admit` refuses the handle and the route's stream ends after its connect
122
+ * comment; `subscribe` closes the stream it is given at once. A stream still
123
+ * opening when the server shuts down therefore ends rather than outliving it.
124
+ * The flag is set in the same synchronous step that closes the live streams.
125
+ *
108
126
  * @example
109
127
  * ```ts
110
128
  * const registry = new SubscriberRegistry<SseNotification>();
@@ -144,8 +162,10 @@ export interface PendingSubscription {
144
162
  * return unauthorized();
145
163
  * }
146
164
  * const {response, stream} = create_sse_response(c, log);
147
- * if (registry.admit(pending, stream)) stream.on_close(pending.unsubscribe);
148
- * else stream.close(); // closed while pending — the body is the connect comment alone
165
+ * // first: a client that already left closes the stream, its listener runs at
166
+ * // once, and `admit` then refuses the removed registration
167
+ * stream.on_close(pending.unsubscribe);
168
+ * if (!registry.admit(pending, stream)) stream.close(); // the body is the connect comment alone
149
169
  * return response;
150
170
  * ```
151
171
  */
@@ -162,6 +182,13 @@ export declare class SubscriberRegistry<T> {
162
182
  * and `admit` (or their refusal). For telemetry and tests.
163
183
  */
164
184
  get pending_count(): number;
185
+ /**
186
+ * Whether `close_all` has run — the registry is shutting down and every
187
+ * registration from now on is born closed (class doc, "Shutdown"). The
188
+ * audit stream route reads it to skip the re-reads for a registration
189
+ * admission will refuse anyway.
190
+ */
191
+ get closing(): boolean;
165
192
  /**
166
193
  * Add a subscriber, admitted at once — the one-step form of
167
194
  * `subscribe_pending` + `admit`.
@@ -171,6 +198,10 @@ export declare class SubscriberRegistry<T> {
171
198
  * nothing, and the stream would open on a dead credential (class doc,
172
199
  * "Two-phase registration").
173
200
  *
201
+ * Once the registry is closing, the subscriber is born closed: `stream` is
202
+ * closed at once, nothing is registered, and the returned function is a
203
+ * no-op.
204
+ *
174
205
  * @param stream - SSE stream to send data to
175
206
  * @param options - channel filter and identity slots (`scope` + `groups`)
176
207
  * @returns unsubscribe function
@@ -186,18 +217,21 @@ export declare class SubscriberRegistry<T> {
186
217
  * broadcast, is not counted, and evicts nothing. A caller that then refuses
187
218
  * the request calls the returned handle's `unsubscribe`.
188
219
  *
220
+ * Once the registry is closing, the registration is born closed: nothing
221
+ * is registered, and `admit` refuses the handle.
222
+ *
189
223
  * @param options - channel filter and identity slots (`scope` + `groups`)
190
224
  * @returns the pending handle `admit` takes
191
- * @mutates registry - adds the un-admitted registration
225
+ * @mutates registry - adds the un-admitted registration, unless the registry is closing
192
226
  */
193
227
  subscribe_pending(options?: SubscribeOptions): PendingSubscription;
194
228
  /**
195
229
  * Admit a pending subscription, attaching the stream it delivers to — phase
196
230
  * two (class doc).
197
231
  *
198
- * If the registration is gone — a revocation closed it while it was
199
- * pending, or it was unsubscribed — nothing is evicted and the subscription
200
- * is refused. Otherwise the per-scope cap is enforced as in `subscribe`,
232
+ * If the registration is gone — a revocation or the shutdown closed it
233
+ * while it was pending, it was born closed, or it was unsubscribed —
234
+ * nothing is evicted and the subscription is refused. Otherwise the per-scope cap is enforced as in `subscribe`,
201
235
  * counting only admitted subscribers, and the stream starts receiving
202
236
  * `broadcast` data. Admitting twice is refused the second time.
203
237
  *
@@ -238,13 +272,15 @@ export declare class SubscriberRegistry<T> {
238
272
  close_by_identity(identity: string): number;
239
273
  /**
240
274
  * Force-close every subscriber, pending or admitted — the shutdown close
241
- * `AppServer.close` runs through the registry's `ConnectionCloser`.
275
+ * `AppServer.close` runs through the registry's `ConnectionCloser` — and
276
+ * leave the registry closing, for good: every later registration is born
277
+ * closed (class doc, "Shutdown"). A second call closes nothing.
242
278
  *
243
279
  * A stream whose `close` throws does not stop the loop: every subscriber is
244
280
  * removed and closed, and the first error is thrown afterward.
245
281
  *
246
282
  * @returns the number of subscribers closed
247
- * @mutates registry - removes every subscriber and closes its stream
283
+ * @mutates registry - sets the registry closing, removes every subscriber, and closes its stream
248
284
  * @throws the first error a stream's `close` threw, after every subscriber was attempted
249
285
  */
250
286
  close_all(): number;
@@ -1 +1 @@
1
- {"version":3,"file":"subscriber_registry.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/subscriber_registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAE1C,0EAA0E;AAC1E,MAAM,WAAW,UAAU,CAAC,CAAC;IAC5B,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;IACrB,sEAAsE;IACtE,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IAC7B,mDAAmD;IACnD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,qEAAqE;IACrE,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;CAC3B;AASD,wCAAwC;AACxC,MAAM,WAAW,yBAAyB;IACzC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,0EAA0E;AAC1E,MAAM,WAAW,gBAAgB;IAChC,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACjC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,MAAM,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,mBAAmB;IACnC;;;;OAIG;IACH,WAAW,EAAE,MAAM,IAAI,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyEG;AACH,qBAAa,kBAAkB,CAAC,CAAC;;gBAQpB,OAAO,CAAC,EAAE,yBAAyB;IAI/C;;;OAGG;IACH,IAAI,KAAK,IAAI,MAAM,CAMlB;IAED;;;OAGG;IACH,IAAI,aAAa,IAAI,MAAM,CAM1B;IAED;;;;;;;;;;;;;OAaG;IACH,SAAS,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,MAAM,IAAI;IASvE;;;;;;;;;;;;OAYG;IACH,iBAAiB,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,mBAAmB;IAYlE;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,OAAO,EAAE,mBAAmB,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO;IASlE;;;;;;;OAOG;IACH,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,IAAI;IASzC;;;;;;;;;;;;;;;;;;OAkBG;IACH,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;IAM3C;;;;;;;;;;OAUG;IACH,SAAS,IAAI,MAAM;CA0EnB"}
1
+ {"version":3,"file":"subscriber_registry.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/subscriber_registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEjD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAE1C,0EAA0E;AAC1E,MAAM,WAAW,UAAU,CAAC,CAAC;IAC5B,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;IACrB,sEAAsE;IACtE,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IAC7B,mDAAmD;IACnD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,qEAAqE;IACrE,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;CAC3B;AASD,wCAAwC;AACxC,MAAM,WAAW,yBAAyB;IACzC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAED,0EAA0E;AAC1E,MAAM,WAAW,gBAAgB;IAChC,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACjC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,MAAM,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,mBAAmB;IACnC;;;;OAIG;IACH,WAAW,EAAE,MAAM,IAAI,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqFG;AACH,qBAAa,kBAAkB,CAAC,CAAC;;gBAYpB,OAAO,CAAC,EAAE,yBAAyB;IAK/C;;;OAGG;IACH,IAAI,KAAK,IAAI,MAAM,CAMlB;IAED;;;OAGG;IACH,IAAI,aAAa,IAAI,MAAM,CAM1B;IAED;;;;;OAKG;IACH,IAAI,OAAO,IAAI,OAAO,CAErB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,MAAM,IAAI;IAavE;;;;;;;;;;;;;;;OAeG;IACH,iBAAiB,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,mBAAmB;IAYlE;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,OAAO,EAAE,mBAAmB,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO;IASlE;;;;;;;OAOG;IACH,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,IAAI;IASzC;;;;;;;;;;;;;;;;;;OAkBG;IACH,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;IAM3C;;;;;;;;;;;;OAYG;IACH,SAAS,IAAI,MAAM;CAsFnB"}
@@ -22,6 +22,7 @@
22
22
  *
23
23
  * @module
24
24
  */
25
+ import { Logger } from '@fuzdev/fuz_util/log.ts';
25
26
  /**
26
27
  * Generic subscriber registry with channel-based filtering and identity-keyed disconnection.
27
28
  *
@@ -52,6 +53,16 @@
52
53
  * behind it. The twin of the Rust `fuz_realtime` `SseRegistry`
53
54
  * (`subscribe_pending` / `admit` / `subscribe`).
54
55
  *
56
+ * ## Shutdown
57
+ *
58
+ * `close_all` — the close `AppServer.close` runs — closes every stream,
59
+ * pending or admitted, and leaves the registry **closing** for good: every
60
+ * later registration is born closed. `subscribe_pending` registers nothing,
61
+ * so `admit` refuses the handle and the route's stream ends after its connect
62
+ * comment; `subscribe` closes the stream it is given at once. A stream still
63
+ * opening when the server shuts down therefore ends rather than outliving it.
64
+ * The flag is set in the same synchronous step that closes the live streams.
65
+ *
55
66
  * @example
56
67
  * ```ts
57
68
  * const registry = new SubscriberRegistry<SseNotification>();
@@ -91,8 +102,10 @@
91
102
  * return unauthorized();
92
103
  * }
93
104
  * const {response, stream} = create_sse_response(c, log);
94
- * if (registry.admit(pending, stream)) stream.on_close(pending.unsubscribe);
95
- * else stream.close(); // closed while pending — the body is the connect comment alone
105
+ * // first: a client that already left closes the stream, its listener runs at
106
+ * // once, and `admit` then refuses the removed registration
107
+ * stream.on_close(pending.unsubscribe);
108
+ * if (!registry.admit(pending, stream)) stream.close(); // the body is the connect comment alone
96
109
  * return response;
97
110
  * ```
98
111
  */
@@ -103,8 +116,13 @@ export class SubscriberRegistry {
103
116
  // another registry names nothing here
104
117
  #pending = new WeakMap();
105
118
  #max_per_scope;
119
+ #log;
120
+ // set once by `close_all`, never cleared — every later registration is born
121
+ // closed (class doc, "Shutdown")
122
+ #closing = false;
106
123
  constructor(options) {
107
124
  this.#max_per_scope = options?.max_per_scope ?? null;
125
+ this.#log = options?.log === undefined ? new Logger('[sse]') : options.log;
108
126
  }
109
127
  /**
110
128
  * Number of open — **admitted** — subscribers. A registration still pending
@@ -130,6 +148,15 @@ export class SubscriberRegistry {
130
148
  }
131
149
  return count;
132
150
  }
151
+ /**
152
+ * Whether `close_all` has run — the registry is shutting down and every
153
+ * registration from now on is born closed (class doc, "Shutdown"). The
154
+ * audit stream route reads it to skip the re-reads for a registration
155
+ * admission will refuse anyway.
156
+ */
157
+ get closing() {
158
+ return this.#closing;
159
+ }
133
160
  /**
134
161
  * Add a subscriber, admitted at once — the one-step form of
135
162
  * `subscribe_pending` + `admit`.
@@ -139,12 +166,20 @@ export class SubscriberRegistry {
139
166
  * nothing, and the stream would open on a dead credential (class doc,
140
167
  * "Two-phase registration").
141
168
  *
169
+ * Once the registry is closing, the subscriber is born closed: `stream` is
170
+ * closed at once, nothing is registered, and the returned function is a
171
+ * no-op.
172
+ *
142
173
  * @param stream - SSE stream to send data to
143
174
  * @param options - channel filter and identity slots (`scope` + `groups`)
144
175
  * @returns unsubscribe function
145
176
  * @mutates registry - adds the new subscriber; closes oldest matching subscribers when `max_per_scope` is exceeded
146
177
  */
147
178
  subscribe(stream, options) {
179
+ if (this.#closing) {
180
+ stream.close();
181
+ return () => { };
182
+ }
148
183
  const subscriber = this.#create_entry(options);
149
184
  this.#admit_entry(subscriber, stream);
150
185
  this.#subscribers.add(subscriber);
@@ -161,13 +196,17 @@ export class SubscriberRegistry {
161
196
  * broadcast, is not counted, and evicts nothing. A caller that then refuses
162
197
  * the request calls the returned handle's `unsubscribe`.
163
198
  *
199
+ * Once the registry is closing, the registration is born closed: nothing
200
+ * is registered, and `admit` refuses the handle.
201
+ *
164
202
  * @param options - channel filter and identity slots (`scope` + `groups`)
165
203
  * @returns the pending handle `admit` takes
166
- * @mutates registry - adds the un-admitted registration
204
+ * @mutates registry - adds the un-admitted registration, unless the registry is closing
167
205
  */
168
206
  subscribe_pending(options) {
169
207
  const subscriber = this.#create_entry(options);
170
- this.#subscribers.add(subscriber);
208
+ if (!this.#closing)
209
+ this.#subscribers.add(subscriber);
171
210
  const pending = {
172
211
  unsubscribe: () => {
173
212
  this.#subscribers.delete(subscriber);
@@ -180,9 +219,9 @@ export class SubscriberRegistry {
180
219
  * Admit a pending subscription, attaching the stream it delivers to — phase
181
220
  * two (class doc).
182
221
  *
183
- * If the registration is gone — a revocation closed it while it was
184
- * pending, or it was unsubscribed — nothing is evicted and the subscription
185
- * is refused. Otherwise the per-scope cap is enforced as in `subscribe`,
222
+ * If the registration is gone — a revocation or the shutdown closed it
223
+ * while it was pending, it was born closed, or it was unsubscribed —
224
+ * nothing is evicted and the subscription is refused. Otherwise the per-scope cap is enforced as in `subscribe`,
186
225
  * counting only admitted subscribers, and the stream starts receiving
187
226
  * `broadcast` data. Admitting twice is refused the second time.
188
227
  *
@@ -240,16 +279,20 @@ export class SubscriberRegistry {
240
279
  }
241
280
  /**
242
281
  * Force-close every subscriber, pending or admitted — the shutdown close
243
- * `AppServer.close` runs through the registry's `ConnectionCloser`.
282
+ * `AppServer.close` runs through the registry's `ConnectionCloser` — and
283
+ * leave the registry closing, for good: every later registration is born
284
+ * closed (class doc, "Shutdown"). A second call closes nothing.
244
285
  *
245
286
  * A stream whose `close` throws does not stop the loop: every subscriber is
246
287
  * removed and closed, and the first error is thrown afterward.
247
288
  *
248
289
  * @returns the number of subscribers closed
249
- * @mutates registry - removes every subscriber and closes its stream
290
+ * @mutates registry - sets the registry closing, removes every subscriber, and closes its stream
250
291
  * @throws the first error a stream's `close` threw, after every subscriber was attempted
251
292
  */
252
293
  close_all() {
294
+ // before any close, so a registration a close listener triggers is born closed too
295
+ this.#closing = true;
253
296
  return this.#close_where(() => true);
254
297
  }
255
298
  #close_where(predicate) {
@@ -304,6 +347,11 @@ export class SubscriberRegistry {
304
347
  * Close `scope`'s oldest **admitted** subscribers until one more fits under
305
348
  * `max`. Pending registrations are neither counted nor closed — a
306
349
  * subscription that may yet be refused must not cost a live one its slot.
350
+ *
351
+ * Every victim is removed, then closed in its own `try`: a stream whose
352
+ * `close` throws is still unregistered, the rest are still closed, and the
353
+ * error is logged — it belongs to another subscriber, so it must not fail
354
+ * the admission that evicted it.
307
355
  */
308
356
  #enforce_scope_limit(scope, max) {
309
357
  // admitted subscribers with this scope, in registration order (the
@@ -318,8 +366,14 @@ export class SubscriberRegistry {
318
366
  const overflow = matching.length - (max - 1);
319
367
  for (let i = 0; i < overflow; i++) {
320
368
  const [victim, stream] = matching[i];
321
- stream.close();
369
+ // removed first, so a stream whose close throws is unregistered too
322
370
  this.#subscribers.delete(victim);
371
+ try {
372
+ stream.close();
373
+ }
374
+ catch (error) {
375
+ this.#log?.error('error closing a stream evicted by the cap:', error);
376
+ }
323
377
  }
324
378
  }
325
379
  }
@@ -433,7 +433,10 @@ export interface AppServer {
433
433
  * audit stream — including transports supplied through
434
434
  * `WsEndpointSpec.transport` or added by hand, since they are registered
435
435
  * on the backend's closer and the backend is going away; a close that
436
- * throws is logged and the shutdown goes on
436
+ * throws is logged and the shutdown goes on. Each transport and registry
437
+ * is left closing, so an upgrade or stream that registers afterward is
438
+ * born closed — refused at once rather than outliving the server — and
439
+ * a closed transport hosts no other server
437
440
  * 3. release what assembly acquired, last acquired first: remove the
438
441
  * audit listeners `create_app_server` registered on `deps.audit` (the
439
442
  * audit-log SSE listener, the WS auth guards, each