@fuzdev/fuz_app 0.129.0 → 0.131.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 (110) 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 +108 -14
  7. package/dist/actions/rpc_client.d.ts.map +1 -1
  8. package/dist/actions/transports.d.ts +2 -1
  9. package/dist/actions/transports.d.ts.map +1 -1
  10. package/dist/actions/transports.js +2 -1
  11. package/dist/actions/transports_ws_backend.d.ts +38 -4
  12. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  13. package/dist/actions/transports_ws_backend.js +69 -4
  14. package/dist/auth/audit_log_routes.d.ts +7 -3
  15. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  16. package/dist/auth/audit_log_routes.js +50 -26
  17. package/dist/auth/scope_kind_schema.d.ts.map +1 -1
  18. package/dist/auth/scope_kind_schema.js +1 -3
  19. package/dist/db/db_pg.js +1 -1
  20. package/dist/http/CLAUDE.md +1 -1
  21. package/dist/http/jsonrpc_errors.d.ts.map +1 -1
  22. package/dist/http/jsonrpc_errors.js +7 -5
  23. package/dist/realtime/sse.d.ts +14 -1
  24. package/dist/realtime/sse.d.ts.map +1 -1
  25. package/dist/realtime/sse.js +77 -1
  26. package/dist/realtime/sse_auth_guard.js +1 -1
  27. package/dist/realtime/subscriber_registry.d.ts +44 -8
  28. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  29. package/dist/realtime/subscriber_registry.js +64 -10
  30. package/dist/runtime/mock.js +1 -1
  31. package/dist/server/app_server.d.ts +4 -1
  32. package/dist/server/app_server.d.ts.map +1 -1
  33. package/dist/testing/CLAUDE.md +21 -6
  34. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  35. package/dist/testing/cross_backend/account_lifecycle.js +0 -3
  36. package/dist/testing/cross_backend/action_manifest.d.ts +0 -4
  37. package/dist/testing/cross_backend/action_manifest.d.ts.map +1 -1
  38. package/dist/testing/cross_backend/action_manifest.js +0 -4
  39. package/dist/testing/cross_backend/actor_lookup.d.ts.map +1 -1
  40. package/dist/testing/cross_backend/actor_lookup.js +0 -3
  41. package/dist/testing/cross_backend/actor_search.d.ts.map +1 -1
  42. package/dist/testing/cross_backend/actor_search.js +0 -2
  43. package/dist/testing/cross_backend/app_settings.d.ts.map +1 -1
  44. package/dist/testing/cross_backend/app_settings.js +0 -2
  45. package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
  46. package/dist/testing/cross_backend/body_size.js +0 -3
  47. package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
  48. package/dist/testing/cross_backend/body_size_smuggling.js +0 -2
  49. package/dist/testing/cross_backend/cell_cross_helpers.d.ts.map +1 -1
  50. package/dist/testing/cross_backend/cell_cross_helpers.js +0 -3
  51. package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
  52. package/dist/testing/cross_backend/cell_crud.js +0 -3
  53. package/dist/testing/cross_backend/cell_gated_create.d.ts +0 -2
  54. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  55. package/dist/testing/cross_backend/cell_gated_create.js +0 -4
  56. package/dist/testing/cross_backend/cell_grant_role.d.ts.map +1 -1
  57. package/dist/testing/cross_backend/cell_grant_role.js +0 -2
  58. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  59. package/dist/testing/cross_backend/cell_relations.js +0 -3
  60. package/dist/testing/cross_backend/cookie_attributes.d.ts.map +1 -1
  61. package/dist/testing/cross_backend/cookie_attributes.js +0 -3
  62. package/dist/testing/cross_backend/credential_header_robustness.d.ts.map +1 -1
  63. package/dist/testing/cross_backend/credential_header_robustness.js +0 -2
  64. package/dist/testing/cross_backend/default_spine_surface.d.ts +0 -7
  65. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  66. package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
  67. package/dist/testing/cross_backend/fact_serving.js +0 -3
  68. package/dist/testing/cross_backend/full_spine_mount.d.ts +0 -5
  69. package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
  70. package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
  71. package/dist/testing/cross_backend/identity_parity.js +0 -2
  72. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  73. package/dist/testing/cross_backend/login_security.js +0 -3
  74. package/dist/testing/cross_backend/origin.d.ts.map +1 -1
  75. package/dist/testing/cross_backend/origin.js +0 -3
  76. package/dist/testing/cross_backend/peer_ping_ws.js +4 -4
  77. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  78. package/dist/testing/cross_backend/query_shape.js +0 -3
  79. package/dist/testing/cross_backend/ready.d.ts.map +1 -1
  80. package/dist/testing/cross_backend/ready.js +0 -3
  81. package/dist/testing/cross_backend/role_grant_offer_enumeration.d.ts.map +1 -1
  82. package/dist/testing/cross_backend/role_grant_offer_enumeration.js +0 -3
  83. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  84. package/dist/testing/cross_backend/role_grant_participation.js +0 -3
  85. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
  86. package/dist/testing/cross_backend/session_cap.js +0 -3
  87. package/dist/testing/cross_backend/test_cell_gated_create_authorize.d.ts.map +1 -1
  88. package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +0 -3
  89. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  90. package/dist/testing/cross_backend/testing_backdoor.js +0 -3
  91. package/dist/testing/cross_backend/testing_server_bun.js +1 -1
  92. package/dist/testing/cross_backend/token_lifetime.d.ts.map +1 -1
  93. package/dist/testing/cross_backend/token_lifetime.js +0 -4
  94. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  95. package/dist/testing/cross_backend/token_scope_surface.js +0 -3
  96. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +1 -1
  97. package/dist/testing/cross_backend/ts_spine_backend_config.js +1 -1
  98. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
  99. package/dist/testing/cross_backend/ws_action_rate_limit.js +0 -3
  100. package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -1
  101. package/dist/testing/cross_backend/ws_connection_cap.js +0 -3
  102. package/dist/testing/cross_backend/ws_inbound_dispatch.d.ts +20 -0
  103. package/dist/testing/cross_backend/ws_inbound_dispatch.d.ts.map +1 -0
  104. package/dist/testing/cross_backend/ws_inbound_dispatch.js +177 -0
  105. package/dist/testing/transports/sse_frame_reader.d.ts.map +1 -1
  106. package/dist/testing/transports/sse_frame_reader.js +4 -1
  107. package/dist/ui/Datatable.svelte +2 -2
  108. package/dist/ui/ui_format.d.ts.map +1 -1
  109. package/dist/ui/ui_format.js +2 -0
  110. package/package.json +19 -19
@@ -44,6 +44,22 @@
44
44
  * uses the two-phase form. The twin of the Rust `fuz_realtime`
45
45
  * `ConnectionRegistry`.
46
46
  *
47
+ * ## Shutdown
48
+ *
49
+ * `close_all_sockets` — the close `AppServer.close` runs — closes every
50
+ * connection, pending or admitted, with `WS_CLOSE_GOING_AWAY`, and leaves the
51
+ * transport **closing**: from then on every registration is born closed. A
52
+ * `register_pending` or `add_connection` inserts nothing — it aborts the
53
+ * caller's controller and closes the socket with `WS_CLOSE_GOING_AWAY` at once
54
+ * — so an upgrade still in flight when the server shuts down is closed rather
55
+ * than outliving it. An upgrade registered before the close-all is closed by
56
+ * it; one registered after is born closed; none is admitted. The flag is set
57
+ * in the same synchronous step that closes the live connections, so an `admit`
58
+ * that finds its entry gone and reads `is_closing()` sees the shutdown — a
59
+ * shutdown is never read as a revocation (a revocation the shutdown overtook
60
+ * reads as the shutdown). Closing is permanent: a closed transport hosts no
61
+ * further connections.
62
+ *
47
63
  * @module
48
64
  */
49
65
  import { to_error_message } from '@fuzdev/fuz_util/error.ts';
@@ -78,6 +94,9 @@ export class BackendWebsocketTransport {
78
94
  // "oldest"). Adding a new identity scope (e.g. `device_id`) means adding a
79
95
  // field to `ConnectionIdentity`, not a parallel map.
80
96
  #connections = new Map();
97
+ // set once by `close_all_sockets`, never cleared — every later
98
+ // registration is born closed (module doc, "Shutdown")
99
+ #closing = false;
81
100
  // Server→client request correlation (ActionPeer). The transport owns the
82
101
  // sockets + the send; the registry owns the pending map, id allocation,
83
102
  // deadlines, and the per-connection in-flight cap (see `peer_request.ts`).
@@ -109,6 +128,11 @@ export class BackendWebsocketTransport {
109
128
  * The caller owns the entry until it is admitted: every refusal path calls
110
129
  * `remove_connection` with the returned id.
111
130
  *
131
+ * Once the transport is closing (`close_all_sockets`) the registration is
132
+ * born closed: nothing is inserted, `abort_controller` is aborted, and `ws`
133
+ * is closed with `WS_CLOSE_GOING_AWAY` before this returns. The id names
134
+ * nothing, so `admit` refuses it (module doc, "Shutdown").
135
+ *
112
136
  * @param ws - the socket to close on revocation
113
137
  * @param token_hash - blake3 session token hash, or `null` for non-session credentials
114
138
  * @param account_id - the authenticated account
@@ -116,9 +140,12 @@ export class BackendWebsocketTransport {
116
140
  * @param abort_controller - the socket-scoped controller to abort when the
117
141
  * transport closes this connection (revocation or cap eviction)
118
142
  * @returns the freshly assigned `connection_id` (branded `Uuid`)
119
- * @mutates this - inserts an un-admitted entry into `#connections`
143
+ * @mutates this - inserts an un-admitted entry into `#connections`, unless
144
+ * the transport is closing
120
145
  */
121
146
  register_pending(ws, token_hash, account_id, api_token_id = null, abort_controller = null) {
147
+ if (this.#closing)
148
+ return this.#close_born_closed(ws, abort_controller);
122
149
  return this.#insert(ws, token_hash, account_id, api_token_id, abort_controller, false);
123
150
  }
124
151
  /**
@@ -174,7 +201,8 @@ export class BackendWebsocketTransport {
174
201
  * inserted. A removed connection gets no further broadcast or peer request,
175
202
  * stops counting toward the cap at once, and has its `abort_controller`
176
203
  * aborted; its socket is closed when the close handshake completes. The new
177
- * connection is always admitted.
204
+ * connection is always admitted — unless the transport is closing: then it
205
+ * is born closed, as in `register_pending`, and evicts nothing.
178
206
  *
179
207
  * @returns the freshly assigned `connection_id` (branded `Uuid`)
180
208
  * @mutates this - inserts an admitted entry into `#connections`; past the
@@ -182,6 +210,8 @@ export class BackendWebsocketTransport {
182
210
  * their `WSContext`
183
211
  */
184
212
  add_connection(ws, token_hash, account_id, api_token_id = null, abort_controller = null) {
213
+ if (this.#closing)
214
+ return this.#close_born_closed(ws, abort_controller);
185
215
  if (this.#max_connections_per_account !== null) {
186
216
  this.#evict_oldest_for_account(account_id, this.#max_connections_per_account);
187
217
  }
@@ -305,14 +335,49 @@ export class BackendWebsocketTransport {
305
335
  * revocation does, so its heartbeat timer stops and nothing more dispatches
306
336
  * on it. A socket whose `close` throws does not stop the loop.
307
337
  *
338
+ * Leaves the transport closing, for good: every later registration is born
339
+ * closed (module doc, "Shutdown"). A second call closes nothing.
340
+ *
308
341
  * @returns the number of sockets closed
309
- * @mutates this - removes every connection, aborts its registered
310
- * controller, and closes its underlying `WSContext` with `WS_CLOSE_GOING_AWAY`
342
+ * @mutates this - sets the transport closing, removes every connection,
343
+ * aborts its registered controller, and closes its underlying `WSContext`
344
+ * with `WS_CLOSE_GOING_AWAY`
311
345
  * @throws the first error a socket's `close` threw, after every connection was attempted
312
346
  */
313
347
  close_all_sockets() {
348
+ // before any close, in the same synchronous step: an `admit` that finds
349
+ // its entry gone reads the shutdown, never a revocation — and set even
350
+ // when a socket's close throws
351
+ this.#closing = true;
314
352
  return this.#close_where(() => true, WS_CLOSE_GOING_AWAY, WS_CLOSE_GOING_AWAY_REASON);
315
353
  }
354
+ /**
355
+ * Whether `close_all_sockets` has run — the transport is shutting down and
356
+ * every registration from now on is born closed (module doc, "Shutdown").
357
+ * For telemetry and logs: `register_action_ws` reads it to say why an
358
+ * upgrade was refused.
359
+ */
360
+ is_closing() {
361
+ return this.#closing;
362
+ }
363
+ /**
364
+ * Close a registration that arrived after `close_all_sockets`, without
365
+ * inserting it. A socket whose `close` throws is logged, not thrown: the
366
+ * registration is refused either way, and its caller sees the aborted
367
+ * controller.
368
+ *
369
+ * @returns a fresh id that names no entry
370
+ */
371
+ #close_born_closed(ws, abort_controller) {
372
+ abort_controller?.abort();
373
+ try {
374
+ ws.close(WS_CLOSE_GOING_AWAY, WS_CLOSE_GOING_AWAY_REASON);
375
+ }
376
+ catch (error) {
377
+ this.#log?.error('error closing a socket registered during shutdown:', error);
378
+ }
379
+ return create_uuid();
380
+ }
316
381
  #cleanup_connection(connection_id) {
317
382
  this.#connections.delete(connection_id);
318
383
  // Wake any handler still awaiting a reply on this socket — the peer is
@@ -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,CAoG5F,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,54 @@ 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
+ log.info('audit stream: re-read ended by the shutdown', ctx.account.id, error);
121
+ return null;
122
+ }
123
+ throw error;
124
+ });
125
+ if (refusal)
126
+ return refusal;
105
127
  }
106
128
  // 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
129
+ // registration a revocation or the shutdown closed while it was
130
+ // pending, or one born closed, is not admitted: the stream is
131
+ // closed at once, so the body is the
109
132
  // connect comment and nothing else, and the client reconnects
110
- // into the gates.
133
+ // into the gates. The close listener goes on first: a client that
134
+ // left during the re-reads gets a stream that is already closed,
135
+ // whose listener runs at once and removes the registration, so
136
+ // `admit` refuses it rather than evicting a live stream for it.
111
137
  const { response, stream } = create_sse_response(c, log);
138
+ stream.on_close(pending.unsubscribe);
112
139
  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);
140
+ if (!admitted) {
141
+ log.info('audit stream: closed before admission', ctx.account.id);
118
142
  stream.close();
119
143
  }
120
144
  return response;
@@ -1 +1 @@
1
- {"version":3,"file":"scope_kind_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/scope_kind_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAgC,CAAC;AAEnE,oDAAoD;AACpD,eAAO,MAAM,aAAa,aAKxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,gFAAgF;AAChF,MAAM,WAAW,qBAAqB;IACrC;;;;;OAKG;IACH,SAAS,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7B;;;OAGG;IACH,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;CAChD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,wBAAwB,GACpC,gBAAgB,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,KAC3C,qBA0BF,CAAC"}
1
+ {"version":3,"file":"scope_kind_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/scope_kind_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAgC,CAAC;AAEnE,oDAAoD;AACpD,eAAO,MAAM,aAAa,aAKxB,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,gFAAgF;AAChF,MAAM,WAAW,qBAAqB;IACrC;;;;;OAKG;IACH,SAAS,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7B;;;OAGG;IACH,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;CAChD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,wBAAwB,GACpC,gBAAgB,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,KAC3C,qBAwBF,CAAC"}
@@ -83,9 +83,7 @@ export const create_scope_kind_schema = (consumer_kinds) => {
83
83
  }
84
84
  seen.add(name);
85
85
  }
86
- const ScopeKind = names.length === 0
87
- ? z.never()
88
- : z.enum(names);
86
+ const ScopeKind = names.length === 0 ? z.never() : z.enum(names);
89
87
  const scope_kinds = new Map();
90
88
  for (const name of names) {
91
89
  scope_kinds.set(name, consumer_kinds[name]);
package/dist/db/db_pg.js CHANGED
@@ -37,7 +37,7 @@ import { Db, no_nested_transaction } from './db.js';
37
37
  */
38
38
  export const register_pg_type_parsers = async () => {
39
39
  const { types } = await import('pg');
40
- types.setTypeParser(20, (val) => Number(val));
40
+ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
41
41
  };
42
42
  /**
43
43
  * Create a transaction implementation for a `pg.Pool`.
@@ -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,CAM7E,CAAC;AAEH;;;;;;;;;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"}