@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.
- package/dist/actions/CLAUDE.md +23 -6
- package/dist/actions/connection_closer.d.ts +3 -1
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/register_action_ws.d.ts +46 -0
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +108 -14
- package/dist/actions/rpc_client.d.ts.map +1 -1
- package/dist/actions/transports.d.ts +2 -1
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +2 -1
- package/dist/actions/transports_ws_backend.d.ts +38 -4
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +69 -4
- package/dist/auth/audit_log_routes.d.ts +7 -3
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +50 -26
- package/dist/auth/scope_kind_schema.d.ts.map +1 -1
- package/dist/auth/scope_kind_schema.js +1 -3
- package/dist/db/db_pg.js +1 -1
- package/dist/http/CLAUDE.md +1 -1
- package/dist/http/jsonrpc_errors.d.ts.map +1 -1
- package/dist/http/jsonrpc_errors.js +7 -5
- package/dist/realtime/sse.d.ts +14 -1
- package/dist/realtime/sse.d.ts.map +1 -1
- package/dist/realtime/sse.js +77 -1
- package/dist/realtime/sse_auth_guard.js +1 -1
- package/dist/realtime/subscriber_registry.d.ts +44 -8
- package/dist/realtime/subscriber_registry.d.ts.map +1 -1
- package/dist/realtime/subscriber_registry.js +64 -10
- package/dist/runtime/mock.js +1 -1
- package/dist/server/app_server.d.ts +4 -1
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/testing/CLAUDE.md +21 -6
- package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
- package/dist/testing/cross_backend/account_lifecycle.js +0 -3
- package/dist/testing/cross_backend/action_manifest.d.ts +0 -4
- package/dist/testing/cross_backend/action_manifest.d.ts.map +1 -1
- package/dist/testing/cross_backend/action_manifest.js +0 -4
- package/dist/testing/cross_backend/actor_lookup.d.ts.map +1 -1
- package/dist/testing/cross_backend/actor_lookup.js +0 -3
- package/dist/testing/cross_backend/actor_search.d.ts.map +1 -1
- package/dist/testing/cross_backend/actor_search.js +0 -2
- package/dist/testing/cross_backend/app_settings.d.ts.map +1 -1
- package/dist/testing/cross_backend/app_settings.js +0 -2
- package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size.js +0 -3
- package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.js +0 -2
- package/dist/testing/cross_backend/cell_cross_helpers.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_cross_helpers.js +0 -3
- package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_crud.js +0 -3
- package/dist/testing/cross_backend/cell_gated_create.d.ts +0 -2
- package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_gated_create.js +0 -4
- package/dist/testing/cross_backend/cell_grant_role.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_grant_role.js +0 -2
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_relations.js +0 -3
- package/dist/testing/cross_backend/cookie_attributes.d.ts.map +1 -1
- package/dist/testing/cross_backend/cookie_attributes.js +0 -3
- package/dist/testing/cross_backend/credential_header_robustness.d.ts.map +1 -1
- package/dist/testing/cross_backend/credential_header_robustness.js +0 -2
- package/dist/testing/cross_backend/default_spine_surface.d.ts +0 -7
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
- package/dist/testing/cross_backend/fact_serving.js +0 -3
- package/dist/testing/cross_backend/full_spine_mount.d.ts +0 -5
- package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
- package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
- package/dist/testing/cross_backend/identity_parity.js +0 -2
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +0 -3
- package/dist/testing/cross_backend/origin.d.ts.map +1 -1
- package/dist/testing/cross_backend/origin.js +0 -3
- package/dist/testing/cross_backend/peer_ping_ws.js +4 -4
- package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
- package/dist/testing/cross_backend/query_shape.js +0 -3
- package/dist/testing/cross_backend/ready.d.ts.map +1 -1
- package/dist/testing/cross_backend/ready.js +0 -3
- package/dist/testing/cross_backend/role_grant_offer_enumeration.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_offer_enumeration.js +0 -3
- package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_participation.js +0 -3
- package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
- package/dist/testing/cross_backend/session_cap.js +0 -3
- package/dist/testing/cross_backend/test_cell_gated_create_authorize.d.ts.map +1 -1
- package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +0 -3
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +0 -3
- package/dist/testing/cross_backend/testing_server_bun.js +1 -1
- package/dist/testing/cross_backend/token_lifetime.d.ts.map +1 -1
- package/dist/testing/cross_backend/token_lifetime.js +0 -4
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/token_scope_surface.js +0 -3
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +1 -1
- package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_action_rate_limit.js +0 -3
- package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_connection_cap.js +0 -3
- package/dist/testing/cross_backend/ws_inbound_dispatch.d.ts +20 -0
- package/dist/testing/cross_backend/ws_inbound_dispatch.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_inbound_dispatch.js +177 -0
- package/dist/testing/transports/sse_frame_reader.d.ts.map +1 -1
- package/dist/testing/transports/sse_frame_reader.js +4 -1
- package/dist/ui/Datatable.svelte +2 -2
- package/dist/ui/ui_format.d.ts.map +1 -1
- package/dist/ui/ui_format.js +2 -0
- 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,
|
|
310
|
-
* controller, and closes its underlying `WSContext`
|
|
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.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
108
|
-
//
|
|
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
|
-
|
|
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,
|
|
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(
|
|
40
|
+
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|
41
41
|
};
|
|
42
42
|
/**
|
|
43
43
|
* Create a transaction implementation for a `pg.Pool`.
|
package/dist/http/CLAUDE.md
CHANGED
|
@@ -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) —
|
|
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,
|
|
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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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 (
|
|
246
|
-
[-32009]: 429, // queue_overflow (
|
|
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
|
package/dist/realtime/sse.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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"}
|
package/dist/realtime/sse.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
148
|
-
*
|
|
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
|
|
199
|
-
* pending,
|
|
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;
|
|
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"}
|