@fuzdev/fuz_app 0.128.0 → 0.130.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -1
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +105 -14
- 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 +37 -4
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +68 -4
- package/dist/auth/CLAUDE.md +1 -1
- package/dist/auth/actor_lookup_action_specs.d.ts +2 -2
- package/dist/auth/actor_lookup_action_specs.js +2 -2
- package/dist/auth/audit_emitter.d.ts +14 -7
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +6 -8
- 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 +48 -26
- package/dist/auth/bearer_auth.d.ts +3 -2
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +3 -2
- package/dist/auth/request_context.d.ts +1 -1
- package/dist/auth/request_context.js +1 -1
- package/dist/auth/token_scope.d.ts +4 -2
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +10 -2
- package/dist/http/CLAUDE.md +4 -5
- package/dist/http/auth_shape.d.ts +3 -2
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +3 -2
- package/dist/http/common_routes.d.ts +1 -14
- package/dist/http/common_routes.d.ts.map +1 -1
- package/dist/http/common_routes.js +1 -19
- package/dist/http/ip_canonical.d.ts +1 -1
- package/dist/http/ip_canonical.js +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.d.ts +3 -1
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +4 -2
- 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/server/app_server.d.ts +122 -8
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +136 -91
- package/dist/server/surface_route.d.ts +32 -0
- package/dist/server/surface_route.d.ts.map +1 -0
- package/dist/server/surface_route.js +46 -0
- package/dist/testing/CLAUDE.md +33 -12
- package/dist/testing/app_server.d.ts +39 -4
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +84 -68
- package/dist/testing/audit_completeness.js +1 -1
- package/dist/testing/audit_drift_guard.d.ts +1 -1
- package/dist/testing/cross_backend/default_spine_surface.d.ts +2 -2
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +5 -2
- package/dist/testing/cross_backend/identity_parity.js +1 -1
- package/dist/testing/cross_backend/in_process_setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/in_process_setup.js +4 -1
- package/dist/testing/cross_backend/method_coverage.d.ts +1 -1
- package/dist/testing/cross_backend/method_coverage.d.ts.map +1 -1
- package/dist/testing/cross_backend/method_coverage.js +2 -2
- package/dist/testing/cross_backend/peer_ping_ws.d.ts +1 -1
- package/dist/testing/cross_backend/peer_ping_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/peer_ping_ws.js +4 -4
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +1 -1
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.d.ts +4 -4
- package/dist/testing/cross_backend/setup.js +1 -1
- package/dist/testing/cross_backend/sse_round_trip.d.ts +1 -1
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +24 -14
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +40 -32
- package/dist/testing/cross_backend/testing_server_bun.d.ts +1 -1
- package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_bun.js +80 -64
- package/dist/testing/cross_backend/testing_server_core.d.ts +10 -4
- package/dist/testing/cross_backend/testing_server_core.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_deno.d.ts +1 -1
- package/dist/testing/cross_backend/testing_server_deno.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_deno.js +1 -1
- package/dist/testing/cross_backend/testing_server_node.d.ts +1 -1
- package/dist/testing/cross_backend/testing_server_node.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_server_node.js +2 -2
- 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/cross_backend/ws_round_trip.d.ts +1 -1
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +11 -9
- package/dist/testing/stubs.d.ts +45 -7
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +60 -31
- package/dist/testing/ws_round_trip.d.ts +1 -3
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/ui/AdminSurface.svelte +3 -1
- package/dist/ui/AdminSurface.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +3 -1
- package/dist/ui/async_slot.svelte.d.ts +4 -4
- package/dist/ui/async_slot.svelte.js +4 -4
- package/dist/ui/keyed_async_slot.svelte.d.ts +4 -4
- package/dist/ui/keyed_async_slot.svelte.js +3 -3
- package/package.json +1 -1
|
@@ -71,7 +71,7 @@ export const load_expected_schema = (url) => {
|
|
|
71
71
|
* else `503 {error}` (`schema_drift` when columns are missing, `db_unreachable`
|
|
72
72
|
* when the introspection query throws). The detailed drift goes to the server
|
|
73
73
|
* log only — the public body stays a minimal code so the endpoint doesn't leak
|
|
74
|
-
* schema structure (mirrors why `/api/surface` is
|
|
74
|
+
* schema structure (mirrors why `/api/surface` is admin-only). A deploy poll
|
|
75
75
|
* treats `503` as a failed release and rolls back, turning a silent
|
|
76
76
|
* schema-drift auth outage into a loud blocked deploy. See `db/schema_ready.ts`
|
|
77
77
|
* for the column-presence rationale and `auth/migrations.ts` for the
|
|
@@ -129,21 +129,3 @@ export const create_server_status_route_spec = (options) => ({
|
|
|
129
129
|
input: z.null(),
|
|
130
130
|
output: z.looseObject({ version: z.string(), uptime_ms: z.number() })
|
|
131
131
|
});
|
|
132
|
-
/**
|
|
133
|
-
* Create an authenticated route spec that serves the `AppSurface` as JSON.
|
|
134
|
-
*
|
|
135
|
-
* Surface data reveals API structure (routes, auth, schemas), so this
|
|
136
|
-
* requires authentication like the server status route.
|
|
137
|
-
*/
|
|
138
|
-
export const create_surface_route_spec = (options) => ({
|
|
139
|
-
method: 'GET',
|
|
140
|
-
path: '/api/surface',
|
|
141
|
-
auth: { account: 'required', actor: 'none' },
|
|
142
|
-
handler: (c) => c.json(options.surface),
|
|
143
|
-
description: 'Application surface (routes, middleware, schemas)',
|
|
144
|
-
input: z.null(),
|
|
145
|
-
output: z.looseObject({
|
|
146
|
-
routes: z.array(z.looseObject({})),
|
|
147
|
-
middleware: z.array(z.looseObject({}))
|
|
148
|
-
})
|
|
149
|
-
});
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* IPv6 forms (`::ffff:127.0.0.1` vs `0:0:0:0:0:ffff:7f00:1` vs the
|
|
13
13
|
* bare `127.0.0.1`) — three keys for one address.
|
|
14
14
|
*
|
|
15
|
-
* Canonicalization runs through
|
|
15
|
+
* Canonicalization runs through `canonicalize_ip` which:
|
|
16
16
|
*
|
|
17
17
|
* 1. Lowercases and char-set filters (`IP_LITERAL_CHARS`) — non-IP
|
|
18
18
|
* strings (`'unknown'`, `'attacker:controlled'`, `'::1\n'`) pass
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* IPv6 forms (`::ffff:127.0.0.1` vs `0:0:0:0:0:ffff:7f00:1` vs the
|
|
13
13
|
* bare `127.0.0.1`) — three keys for one address.
|
|
14
14
|
*
|
|
15
|
-
* Canonicalization runs through
|
|
15
|
+
* Canonicalization runs through `canonicalize_ip` which:
|
|
16
16
|
*
|
|
17
17
|
* 1. Lowercases and char-set filters (`IP_LITERAL_CHARS`) — non-IP
|
|
18
18
|
* strings (`'unknown'`, `'attacker:controlled'`, `'::1\n'`) pass
|
|
@@ -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,CAMzC,CAAC;AAEvC;;;;;;;;;GASG;AACH,eAAO,MAAM,iCAAiC,GAAI,MAAM,gBAAgB,KAAG,oBACQ,CAAC;AAEpF;;;;GAIG;AACH,eAAO,MAAM,iCAAiC,GAAI,QAAQ,MAAM,KAAG,gBACa,CAAC;AAEjF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAMjF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,GAAI,MAAM,gBAAgB,KAAG,gBACL,CAAC"}
|
|
@@ -61,9 +61,11 @@ export const JSONRPC_ERROR_CODES = Object.freeze({
|
|
|
61
61
|
service_unavailable: -32007,
|
|
62
62
|
timeout: -32008,
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
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
|
/**
|
|
@@ -118,7 +118,9 @@ export declare const AUDIT_LOG_SSE_MAX_PER_SCOPE = 10;
|
|
|
118
118
|
* connection closer.
|
|
119
119
|
*
|
|
120
120
|
* Combines `SubscriberRegistry`, `create_sse_auth_guard`, and the broadcast
|
|
121
|
-
* call into a single object, and adds the registry to `connection_closer
|
|
121
|
+
* call into a single object, and adds the registry to `connection_closer` for
|
|
122
|
+
* good — the remover `add` returns is dropped, so a caller that must take the
|
|
123
|
+
* registry out again passes `null` and adds it itself.
|
|
122
124
|
* The result satisfies `AuditLogRouteOptions['stream']`
|
|
123
125
|
* and provides the `on_audit_event` listener for the audit emitter.
|
|
124
126
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,EAIN,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AACxF,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAE3D,2DAA2D;AAC3D,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAE7C;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,4BAA4B,GAAI,CAAC,EAC7C,UAAU,kBAAkB,CAAC,CAAC,CAAC,KAC7B,gBAKD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,EACtC,UAAU,kBAAkB,CAAC,CAAC,CAAC,EAC/B,eAAe,MAAM,GAAG,IAAI,EAC5B,KAAK,MAAM,KACT,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CA4DjC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B;;;;;OAKG;IACH,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;IAC9C,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAC;IACZ,yJAAyJ;IACzJ,cAAc,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;CAC/C;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,SAAS,CAOlD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAE9C
|
|
1
|
+
{"version":3,"file":"sse_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,EAIN,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AACxF,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAE3D,2DAA2D;AAC3D,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAE7C;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,4BAA4B,GAAI,CAAC,EAC7C,UAAU,kBAAkB,CAAC,CAAC,CAAC,KAC7B,gBAKD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,EACtC,UAAU,kBAAkB,CAAC,CAAC,CAAC,EAC/B,eAAe,MAAM,GAAG,IAAI,EAC5B,KAAK,MAAM,KACT,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CA4DjC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B;;;;;OAKG;IACH,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;IAC9C,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAC;IACZ,yJAAyJ;IACzJ,cAAc,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;CAC/C;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,SAAS,CAOlD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,eAAO,MAAM,oBAAoB,GAAI,SAAS;IAC7C,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;;OASG;IACH,iBAAiB,EAAE,cAAc,GAAG,IAAI,CAAC;IACzC;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B,KAAG,WAgBH,CAAC"}
|
|
@@ -164,7 +164,9 @@ export const AUDIT_LOG_SSE_MAX_PER_SCOPE = 10;
|
|
|
164
164
|
* connection closer.
|
|
165
165
|
*
|
|
166
166
|
* Combines `SubscriberRegistry`, `create_sse_auth_guard`, and the broadcast
|
|
167
|
-
* call into a single object, and adds the registry to `connection_closer
|
|
167
|
+
* call into a single object, and adds the registry to `connection_closer` for
|
|
168
|
+
* good — the remover `add` returns is dropped, so a caller that must take the
|
|
169
|
+
* registry out again passes `null` and adds it itself.
|
|
168
170
|
* The result satisfies `AuditLogRouteOptions['stream']`
|
|
169
171
|
* and provides the `on_audit_event` listener for the audit emitter.
|
|
170
172
|
*
|
|
@@ -203,7 +205,7 @@ export const AUDIT_LOG_SSE_MAX_PER_SCOPE = 10;
|
|
|
203
205
|
export const create_audit_log_sse = (options) => {
|
|
204
206
|
const role = options.role ?? 'admin';
|
|
205
207
|
const max_per_scope = options.max_per_scope === undefined ? AUDIT_LOG_SSE_MAX_PER_SCOPE : options.max_per_scope;
|
|
206
|
-
const registry = new SubscriberRegistry({ max_per_scope });
|
|
208
|
+
const registry = new SubscriberRegistry({ max_per_scope, log: options.log });
|
|
207
209
|
const guard = create_sse_auth_guard(registry, role, options.log);
|
|
208
210
|
options.connection_closer?.add(create_sse_connection_closer(registry));
|
|
209
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"}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*
|
|
23
23
|
* @module
|
|
24
24
|
*/
|
|
25
|
+
import { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
25
26
|
/**
|
|
26
27
|
* Generic subscriber registry with channel-based filtering and identity-keyed disconnection.
|
|
27
28
|
*
|
|
@@ -52,6 +53,16 @@
|
|
|
52
53
|
* behind it. The twin of the Rust `fuz_realtime` `SseRegistry`
|
|
53
54
|
* (`subscribe_pending` / `admit` / `subscribe`).
|
|
54
55
|
*
|
|
56
|
+
* ## Shutdown
|
|
57
|
+
*
|
|
58
|
+
* `close_all` — the close `AppServer.close` runs — closes every stream,
|
|
59
|
+
* pending or admitted, and leaves the registry **closing** for good: every
|
|
60
|
+
* later registration is born closed. `subscribe_pending` registers nothing,
|
|
61
|
+
* so `admit` refuses the handle and the route's stream ends after its connect
|
|
62
|
+
* comment; `subscribe` closes the stream it is given at once. A stream still
|
|
63
|
+
* opening when the server shuts down therefore ends rather than outliving it.
|
|
64
|
+
* The flag is set in the same synchronous step that closes the live streams.
|
|
65
|
+
*
|
|
55
66
|
* @example
|
|
56
67
|
* ```ts
|
|
57
68
|
* const registry = new SubscriberRegistry<SseNotification>();
|
|
@@ -91,8 +102,10 @@
|
|
|
91
102
|
* return unauthorized();
|
|
92
103
|
* }
|
|
93
104
|
* const {response, stream} = create_sse_response(c, log);
|
|
94
|
-
*
|
|
95
|
-
*
|
|
105
|
+
* // first: a client that already left closes the stream, its listener runs at
|
|
106
|
+
* // once, and `admit` then refuses the removed registration
|
|
107
|
+
* stream.on_close(pending.unsubscribe);
|
|
108
|
+
* if (!registry.admit(pending, stream)) stream.close(); // the body is the connect comment alone
|
|
96
109
|
* return response;
|
|
97
110
|
* ```
|
|
98
111
|
*/
|
|
@@ -103,8 +116,13 @@ export class SubscriberRegistry {
|
|
|
103
116
|
// another registry names nothing here
|
|
104
117
|
#pending = new WeakMap();
|
|
105
118
|
#max_per_scope;
|
|
119
|
+
#log;
|
|
120
|
+
// set once by `close_all`, never cleared — every later registration is born
|
|
121
|
+
// closed (class doc, "Shutdown")
|
|
122
|
+
#closing = false;
|
|
106
123
|
constructor(options) {
|
|
107
124
|
this.#max_per_scope = options?.max_per_scope ?? null;
|
|
125
|
+
this.#log = options?.log === undefined ? new Logger('[sse]') : options.log;
|
|
108
126
|
}
|
|
109
127
|
/**
|
|
110
128
|
* Number of open — **admitted** — subscribers. A registration still pending
|
|
@@ -130,6 +148,15 @@ export class SubscriberRegistry {
|
|
|
130
148
|
}
|
|
131
149
|
return count;
|
|
132
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether `close_all` has run — the registry is shutting down and every
|
|
153
|
+
* registration from now on is born closed (class doc, "Shutdown"). The
|
|
154
|
+
* audit stream route reads it to skip the re-reads for a registration
|
|
155
|
+
* admission will refuse anyway.
|
|
156
|
+
*/
|
|
157
|
+
get closing() {
|
|
158
|
+
return this.#closing;
|
|
159
|
+
}
|
|
133
160
|
/**
|
|
134
161
|
* Add a subscriber, admitted at once — the one-step form of
|
|
135
162
|
* `subscribe_pending` + `admit`.
|
|
@@ -139,12 +166,20 @@ export class SubscriberRegistry {
|
|
|
139
166
|
* nothing, and the stream would open on a dead credential (class doc,
|
|
140
167
|
* "Two-phase registration").
|
|
141
168
|
*
|
|
169
|
+
* Once the registry is closing, the subscriber is born closed: `stream` is
|
|
170
|
+
* closed at once, nothing is registered, and the returned function is a
|
|
171
|
+
* no-op.
|
|
172
|
+
*
|
|
142
173
|
* @param stream - SSE stream to send data to
|
|
143
174
|
* @param options - channel filter and identity slots (`scope` + `groups`)
|
|
144
175
|
* @returns unsubscribe function
|
|
145
176
|
* @mutates registry - adds the new subscriber; closes oldest matching subscribers when `max_per_scope` is exceeded
|
|
146
177
|
*/
|
|
147
178
|
subscribe(stream, options) {
|
|
179
|
+
if (this.#closing) {
|
|
180
|
+
stream.close();
|
|
181
|
+
return () => { };
|
|
182
|
+
}
|
|
148
183
|
const subscriber = this.#create_entry(options);
|
|
149
184
|
this.#admit_entry(subscriber, stream);
|
|
150
185
|
this.#subscribers.add(subscriber);
|
|
@@ -161,13 +196,17 @@ export class SubscriberRegistry {
|
|
|
161
196
|
* broadcast, is not counted, and evicts nothing. A caller that then refuses
|
|
162
197
|
* the request calls the returned handle's `unsubscribe`.
|
|
163
198
|
*
|
|
199
|
+
* Once the registry is closing, the registration is born closed: nothing
|
|
200
|
+
* is registered, and `admit` refuses the handle.
|
|
201
|
+
*
|
|
164
202
|
* @param options - channel filter and identity slots (`scope` + `groups`)
|
|
165
203
|
* @returns the pending handle `admit` takes
|
|
166
|
-
* @mutates registry - adds the un-admitted registration
|
|
204
|
+
* @mutates registry - adds the un-admitted registration, unless the registry is closing
|
|
167
205
|
*/
|
|
168
206
|
subscribe_pending(options) {
|
|
169
207
|
const subscriber = this.#create_entry(options);
|
|
170
|
-
this.#
|
|
208
|
+
if (!this.#closing)
|
|
209
|
+
this.#subscribers.add(subscriber);
|
|
171
210
|
const pending = {
|
|
172
211
|
unsubscribe: () => {
|
|
173
212
|
this.#subscribers.delete(subscriber);
|
|
@@ -180,9 +219,9 @@ export class SubscriberRegistry {
|
|
|
180
219
|
* Admit a pending subscription, attaching the stream it delivers to — phase
|
|
181
220
|
* two (class doc).
|
|
182
221
|
*
|
|
183
|
-
* If the registration is gone — a revocation
|
|
184
|
-
* pending,
|
|
185
|
-
* is refused. Otherwise the per-scope cap is enforced as in `subscribe`,
|
|
222
|
+
* If the registration is gone — a revocation or the shutdown closed it
|
|
223
|
+
* while it was pending, it was born closed, or it was unsubscribed —
|
|
224
|
+
* nothing is evicted and the subscription is refused. Otherwise the per-scope cap is enforced as in `subscribe`,
|
|
186
225
|
* counting only admitted subscribers, and the stream starts receiving
|
|
187
226
|
* `broadcast` data. Admitting twice is refused the second time.
|
|
188
227
|
*
|
|
@@ -240,16 +279,20 @@ export class SubscriberRegistry {
|
|
|
240
279
|
}
|
|
241
280
|
/**
|
|
242
281
|
* Force-close every subscriber, pending or admitted — the shutdown close
|
|
243
|
-
* `AppServer.close` runs through the registry's `ConnectionCloser
|
|
282
|
+
* `AppServer.close` runs through the registry's `ConnectionCloser` — and
|
|
283
|
+
* leave the registry closing, for good: every later registration is born
|
|
284
|
+
* closed (class doc, "Shutdown"). A second call closes nothing.
|
|
244
285
|
*
|
|
245
286
|
* A stream whose `close` throws does not stop the loop: every subscriber is
|
|
246
287
|
* removed and closed, and the first error is thrown afterward.
|
|
247
288
|
*
|
|
248
289
|
* @returns the number of subscribers closed
|
|
249
|
-
* @mutates registry - removes every subscriber and closes its stream
|
|
290
|
+
* @mutates registry - sets the registry closing, removes every subscriber, and closes its stream
|
|
250
291
|
* @throws the first error a stream's `close` threw, after every subscriber was attempted
|
|
251
292
|
*/
|
|
252
293
|
close_all() {
|
|
294
|
+
// before any close, so a registration a close listener triggers is born closed too
|
|
295
|
+
this.#closing = true;
|
|
253
296
|
return this.#close_where(() => true);
|
|
254
297
|
}
|
|
255
298
|
#close_where(predicate) {
|
|
@@ -304,6 +347,11 @@ export class SubscriberRegistry {
|
|
|
304
347
|
* Close `scope`'s oldest **admitted** subscribers until one more fits under
|
|
305
348
|
* `max`. Pending registrations are neither counted nor closed — a
|
|
306
349
|
* subscription that may yet be refused must not cost a live one its slot.
|
|
350
|
+
*
|
|
351
|
+
* Every victim is removed, then closed in its own `try`: a stream whose
|
|
352
|
+
* `close` throws is still unregistered, the rest are still closed, and the
|
|
353
|
+
* error is logged — it belongs to another subscriber, so it must not fail
|
|
354
|
+
* the admission that evicted it.
|
|
307
355
|
*/
|
|
308
356
|
#enforce_scope_limit(scope, max) {
|
|
309
357
|
// admitted subscribers with this scope, in registration order (the
|
|
@@ -318,8 +366,14 @@ export class SubscriberRegistry {
|
|
|
318
366
|
const overflow = matching.length - (max - 1);
|
|
319
367
|
for (let i = 0; i < overflow; i++) {
|
|
320
368
|
const [victim, stream] = matching[i];
|
|
321
|
-
stream
|
|
369
|
+
// removed first, so a stream whose close throws is unregistered too
|
|
322
370
|
this.#subscribers.delete(victim);
|
|
371
|
+
try {
|
|
372
|
+
stream.close();
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
this.#log?.error('error closing a stream evicted by the cap:', error);
|
|
376
|
+
}
|
|
323
377
|
}
|
|
324
378
|
}
|
|
325
379
|
}
|