@fuzdev/fuz_app 0.133.0 → 0.135.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 +91 -9
- package/dist/actions/register_action_ws.d.ts +23 -3
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +57 -37
- package/dist/actions/transports.d.ts +23 -0
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +23 -0
- package/dist/actions/transports_ws_backend.d.ts +132 -9
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +236 -33
- package/dist/actions/ws_endpoint_spec.d.ts +9 -0
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/actions/ws_outbound_writer.d.ts +206 -0
- package/dist/actions/ws_outbound_writer.d.ts.map +1 -0
- package/dist/actions/ws_outbound_writer.js +431 -0
- package/dist/auth/CLAUDE.md +2 -2
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +3 -1
- package/dist/auth/cell_actions.js +1 -1
- package/dist/auth/role_schema.d.ts +14 -3
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +14 -2
- package/dist/db/CLAUDE.md +10 -7
- package/dist/db/create_db.d.ts +8 -6
- package/dist/db/create_db.d.ts.map +1 -1
- package/dist/db/create_db.js +11 -10
- package/dist/db/db.d.ts +9 -7
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +5 -4
- package/dist/db/db_pglet.d.ts +43 -0
- package/dist/db/db_pglet.d.ts.map +1 -0
- package/dist/db/db_pglet.js +55 -0
- package/dist/db/migrate.d.ts +1 -1
- package/dist/db/migrate.js +2 -2
- package/dist/http/CLAUDE.md +2 -1
- package/dist/http/body_limit.d.ts +61 -0
- package/dist/http/body_limit.d.ts.map +1 -0
- package/dist/http/body_limit.js +87 -0
- package/dist/http/db_routes.js +2 -2
- package/dist/http/error_schemas.d.ts +2 -0
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +2 -0
- package/dist/realtime/outbound_budget.d.ts +91 -0
- package/dist/realtime/outbound_budget.d.ts.map +1 -0
- package/dist/realtime/outbound_budget.js +108 -0
- package/dist/realtime/sse.d.ts +36 -3
- package/dist/realtime/sse.d.ts.map +1 -1
- package/dist/realtime/sse.js +58 -6
- package/dist/realtime/sse_constants.d.ts +12 -0
- package/dist/realtime/sse_constants.d.ts.map +1 -1
- package/dist/realtime/sse_constants.js +12 -0
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +3 -6
- package/dist/testing/CLAUDE.md +87 -28
- package/dist/testing/admin_integration.js +1 -1
- package/dist/testing/app_server.d.ts +5 -5
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +21 -20
- package/dist/testing/audit_completeness.d.ts +1 -1
- package/dist/testing/audit_completeness.js +1 -1
- package/dist/testing/audit_drift_guard.d.ts +1 -1
- package/dist/testing/audit_drift_guard.js +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.d.ts +9 -9
- package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.js +110 -60
- package/dist/testing/cross_backend/capabilities.d.ts +18 -15
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts +5 -5
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +6 -6
- package/dist/testing/cross_backend/in_process_setup.d.ts +2 -2
- package/dist/testing/cross_backend/in_process_setup.js +3 -3
- package/dist/testing/cross_backend/role_grant_role_grammar.d.ts +6 -0
- package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -0
- package/dist/testing/cross_backend/role_grant_role_grammar.js +117 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +2 -3
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +2 -3
- package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +1 -1
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +8 -4
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +84 -4
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +102 -3
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +2 -2
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +9 -7
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +29 -0
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_outbound_overflow.js +211 -0
- package/dist/testing/db.d.ts +49 -60
- package/dist/testing/db.d.ts.map +1 -1
- package/dist/testing/db.js +104 -101
- package/dist/testing/integration.js +1 -1
- package/dist/testing/rate_limiting.d.ts +2 -1
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +2 -2
- package/dist/testing/schema_introspect.d.ts +10 -2
- package/dist/testing/schema_introspect.d.ts.map +1 -1
- package/dist/testing/schema_introspect.js +15 -7
- package/dist/testing/sse_round_trip.d.ts +1 -1
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +2 -2
- package/dist/testing/stubs.js +1 -1
- package/dist/testing/transports/ws_transport.d.ts +4 -2
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +2 -1
- package/dist/testing/ws_round_trip.d.ts +32 -2
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +26 -4
- package/package.json +6 -1
|
@@ -44,6 +44,56 @@
|
|
|
44
44
|
* uses the two-phase form. The twin of the Rust `fuz_realtime`
|
|
45
45
|
* `ConnectionRegistry`.
|
|
46
46
|
*
|
|
47
|
+
* ## Outbound bound
|
|
48
|
+
*
|
|
49
|
+
* Every frame the server sends a connection goes through one path —
|
|
50
|
+
* `send_to`, which responses, error frames and socket-scoped notifications
|
|
51
|
+
* use, and the fan-outs (`send`, `broadcast_filtered`, `send_to_account`,
|
|
52
|
+
* `request_connection`) — into the connection's `WsOutboundWriter`
|
|
53
|
+
* (`actions/ws_outbound_writer.ts`). A send never waits and never throws: one
|
|
54
|
+
* recipient that stopped reading must not slow a producer or another
|
|
55
|
+
* recipient.
|
|
56
|
+
*
|
|
57
|
+
* What waits for a socket is held to a byte budget, `outbound_queue_bytes`
|
|
58
|
+
* (`DEFAULT_WS_OUTBOUND_QUEUE_BYTES`): each waiting frame is charged its UTF-8
|
|
59
|
+
* length plus `OUTBOUND_FRAME_OVERHEAD_BYTES`, and a frame is always accepted
|
|
60
|
+
* when nothing is waiting. Nothing counts frames. A frame sent to an idle
|
|
61
|
+
* connection goes straight to the socket; the rest of a burst sent in one
|
|
62
|
+
* synchronous run waits, as does anything sent while the socket's own buffer
|
|
63
|
+
* is backed up.
|
|
64
|
+
*
|
|
65
|
+
* A frame that does not fit **closes the connection** rather than being
|
|
66
|
+
* dropped alone: the connection is removed in the same synchronous step — the
|
|
67
|
+
* overflowing send does not count it, later sends miss it, the count accessors
|
|
68
|
+
* and the cap no longer see it — its controller is aborted, what was waiting
|
|
69
|
+
* is discarded, and the socket is closed with `WS_CLOSE_OUTBOUND_OVERFLOW`.
|
|
70
|
+
* Frames already handed to the socket may still reach the client ahead of the
|
|
71
|
+
* close frame; nothing follows it, the response of the request whose handler
|
|
72
|
+
* overflowed the connection included. One `warn` line names the connection,
|
|
73
|
+
* its account, and what was waiting; no audit row is written — a client can
|
|
74
|
+
* provoke an overflow at will, and a write per overflow would amplify it.
|
|
75
|
+
*
|
|
76
|
+
* A socket that holds unwritten bytes past `WS_WRITE_STALL_TIMEOUT` is dropped
|
|
77
|
+
* with no close frame, and removed the same way, with one `warn` line. Every
|
|
78
|
+
* other close the transport makes (a revocation, the cap, the shutdown,
|
|
79
|
+
* `close_connection`) hands the frames still waiting to the socket ahead of
|
|
80
|
+
* its close frame while the socket's buffer is below
|
|
81
|
+
* `WS_WRITE_HIGH_WATER_BYTES`, discards the rest, and cuts the socket if the
|
|
82
|
+
* close frame is still unwritten after `WS_CLOSE_WRITE_TIMEOUT`. A reply sent
|
|
83
|
+
* to an otherwise idle connection just before such a close reaches the client;
|
|
84
|
+
* one queued behind a backed-up socket does not.
|
|
85
|
+
*
|
|
86
|
+
* A connection the transport closed or dropped is out of its map, so the
|
|
87
|
+
* per-account cap does not count it while its socket is still closing. What
|
|
88
|
+
* the runtime holds for such a socket is bounded by the writer
|
|
89
|
+
* (`actions/ws_outbound_writer.ts`, "Closing" and "Runtimes").
|
|
90
|
+
*
|
|
91
|
+
* The twin of the Rust `fuz_realtime` outbound queue — same budget, charge,
|
|
92
|
+
* close code and deadlines. Where they differ: the Rust spine discards every
|
|
93
|
+
* frame queued before any close, and queues every frame, where this transport
|
|
94
|
+
* writes the first frame of a run straight through and, at a close that is
|
|
95
|
+
* not an overflow, the waiting frames its socket has room for.
|
|
96
|
+
*
|
|
47
97
|
* ## Shutdown
|
|
48
98
|
*
|
|
49
99
|
* `close_all_sockets` — the close `AppServer.close` runs — closes every
|
|
@@ -110,6 +160,18 @@ export declare const is_filterable_broadcast_transport: (transport: Transport) =
|
|
|
110
160
|
* spine's `DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT`.
|
|
111
161
|
*/
|
|
112
162
|
export declare const DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT = 50;
|
|
163
|
+
/**
|
|
164
|
+
* Default byte budget of the frames waiting to be written to one connection,
|
|
165
|
+
* applied by `BackendWebsocketTransport` unless overridden via
|
|
166
|
+
* `BackendWebsocketTransportOptions.outbound_queue_bytes`.
|
|
167
|
+
*
|
|
168
|
+
* 4 MiB — four messages at `DEFAULT_WS_MAX_MESSAGE_BYTES` as it stands, and a
|
|
169
|
+
* value of its own: it does not move with the inbound message cap. A consumer
|
|
170
|
+
* whose server pushes more than this at a client in one burst — large frames,
|
|
171
|
+
* or long runs of them — sets its own. Same value as the Rust spine's
|
|
172
|
+
* `DEFAULT_WS_OUTBOUND_QUEUE_BYTES`.
|
|
173
|
+
*/
|
|
174
|
+
export declare const DEFAULT_WS_OUTBOUND_QUEUE_BYTES: number;
|
|
113
175
|
/** Options for `BackendWebsocketTransport`. */
|
|
114
176
|
export interface BackendWebsocketTransportOptions {
|
|
115
177
|
/**
|
|
@@ -129,7 +191,21 @@ export interface BackendWebsocketTransportOptions {
|
|
|
129
191
|
*/
|
|
130
192
|
max_connections_per_account?: number | null;
|
|
131
193
|
/**
|
|
132
|
-
*
|
|
194
|
+
* Byte budget of the frames waiting to be written to one connection. Each
|
|
195
|
+
* waiting frame is charged its UTF-8 length plus
|
|
196
|
+
* `OUTBOUND_FRAME_OVERHEAD_BYTES`; a frame that does not fit closes the
|
|
197
|
+
* connection with `WS_CLOSE_OUTBOUND_OVERFLOW`, and a frame is always
|
|
198
|
+
* accepted when nothing is waiting, so one message larger than the whole
|
|
199
|
+
* budget is still deliverable (module doc, "Outbound bound"). Not derived
|
|
200
|
+
* from the inbound message cap.
|
|
201
|
+
*
|
|
202
|
+
* @default DEFAULT_WS_OUTBOUND_QUEUE_BYTES
|
|
203
|
+
*/
|
|
204
|
+
outbound_queue_bytes?: number;
|
|
205
|
+
/**
|
|
206
|
+
* Logger for cap evictions, outbound overflows and stalls, and failed sends
|
|
207
|
+
* or closes. An overflow or a stall logs one `warn` line — the connection,
|
|
208
|
+
* its account, and what was waiting. A cap eviction logs
|
|
133
209
|
* at info — the account, how many connections were closed, and the cap —
|
|
134
210
|
* so an operator can see a client that keeps meeting it. Unset falls back
|
|
135
211
|
* to a `[ws]` logger; `null` silences it. `register_action_ws` passes its
|
|
@@ -142,7 +218,7 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
|
|
|
142
218
|
readonly transport_name: "backend_websocket_rpc";
|
|
143
219
|
/**
|
|
144
220
|
* @throws Error when `max_connections_per_account` is neither `null` nor a
|
|
145
|
-
* positive integer
|
|
221
|
+
* positive integer, or `outbound_queue_bytes` is not a positive integer
|
|
146
222
|
*/
|
|
147
223
|
constructor(options?: BackendWebsocketTransportOptions);
|
|
148
224
|
/**
|
|
@@ -155,8 +231,9 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
|
|
|
155
231
|
* later `admit` then refuses. Until admitted it evicts nothing, is not
|
|
156
232
|
* counted toward the per-account cap, and receives no message.
|
|
157
233
|
*
|
|
158
|
-
* The caller owns the entry until it is admitted:
|
|
159
|
-
* `remove_connection`
|
|
234
|
+
* The caller owns the entry until it is admitted: a refusal closes it with
|
|
235
|
+
* `close_connection`, and `remove_connection` drops it once its socket has
|
|
236
|
+
* closed.
|
|
160
237
|
*
|
|
161
238
|
* Once the transport is closing (`close_all_sockets`) the registration is
|
|
162
239
|
* born closed: nothing is inserted, `abort_controller` is aborted, and `ws`
|
|
@@ -235,12 +312,36 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
|
|
|
235
312
|
*
|
|
236
313
|
* Removal only: the socket is not closed and the registered
|
|
237
314
|
* `abort_controller` is not aborted — the caller that ends a socket does
|
|
238
|
-
* both.
|
|
315
|
+
* both. What was waiting to be written to it is dropped.
|
|
239
316
|
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
317
|
+
* For a socket that has closed (an adapter's close event): on a connection
|
|
318
|
+
* the transport closed it also drops the close deadline, so calling it while
|
|
319
|
+
* that socket is still closing leaves a client that is not reading uncut.
|
|
320
|
+
*
|
|
321
|
+
* @mutates this - deletes the connection's entry from `#connections`, drops
|
|
322
|
+
* its waiting frames and write deadlines, and settles its pending peer
|
|
323
|
+
* requests as `connection_gone`
|
|
242
324
|
*/
|
|
243
325
|
remove_connection(connection_id: Uuid): void;
|
|
326
|
+
/**
|
|
327
|
+
* Close one connection, pending or admitted, with `code` and `reason` — the
|
|
328
|
+
* close `register_action_ws` makes for a heartbeat timeout, an oversized
|
|
329
|
+
* message, a failed `on_socket_open`, and a refused admission.
|
|
330
|
+
*
|
|
331
|
+
* The connection is removed and its controller aborted at once; frames still
|
|
332
|
+
* waiting for the socket are handed to it ahead of the close frame while its
|
|
333
|
+
* buffer has room and discarded otherwise, and the close frame is held to
|
|
334
|
+
* `WS_CLOSE_WRITE_TIMEOUT` (module doc, "Outbound bound"). A socket whose
|
|
335
|
+
* `close` throws is logged, not thrown.
|
|
336
|
+
*
|
|
337
|
+
* @param connection_id - the id `register_pending` / `add_connection` returned
|
|
338
|
+
* @param code - the close code
|
|
339
|
+
* @param reason - the close reason
|
|
340
|
+
* @returns `false` when the connection is not registered — nothing was closed
|
|
341
|
+
* @mutates this - removes the connection, aborts its registered controller,
|
|
342
|
+
* and closes its `WSContext`
|
|
343
|
+
*/
|
|
344
|
+
close_connection(connection_id: Uuid, code: number, reason: string): boolean;
|
|
244
345
|
/**
|
|
245
346
|
* Close all sockets associated with a specific session token hash,
|
|
246
347
|
* including a registration still pending admission.
|
|
@@ -312,7 +413,10 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
|
|
|
312
413
|
* When no ACL is needed, callers should prefer `send(message)` / `#broadcast`
|
|
313
414
|
* to skip the per-connection predicate overhead.
|
|
314
415
|
*
|
|
315
|
-
*
|
|
416
|
+
* A connection the message overflows is closed and not counted (module doc,
|
|
417
|
+
* "Outbound bound").
|
|
418
|
+
*
|
|
419
|
+
* @returns the number of connections that accepted the message
|
|
316
420
|
*/
|
|
317
421
|
broadcast_filtered(message: JsonrpcMessageFromServerToClient, predicate: (identity: ConnectionIdentity) => boolean): number;
|
|
318
422
|
/**
|
|
@@ -327,9 +431,28 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
|
|
|
327
431
|
* for the account (session, bearer, and daemon-token) receives the
|
|
328
432
|
* message.
|
|
329
433
|
*
|
|
330
|
-
* @returns the number of
|
|
434
|
+
* @returns the number of connections that accepted the message
|
|
331
435
|
*/
|
|
332
436
|
send_to_account(account_id: Uuid, message: JsonrpcMessageFromServerToClient): number;
|
|
437
|
+
/**
|
|
438
|
+
* Send one serialized frame to a single admitted connection — the path
|
|
439
|
+
* `register_action_ws` sends every response, error frame, and socket-scoped
|
|
440
|
+
* notification through.
|
|
441
|
+
*
|
|
442
|
+
* Never waits and never throws. A frame the connection's outbound budget
|
|
443
|
+
* does not fit closes the connection with `WS_CLOSE_OUTBOUND_OVERFLOW` before
|
|
444
|
+
* this returns (module doc, "Outbound bound").
|
|
445
|
+
*
|
|
446
|
+
* @param connection_id - the id `register_pending` / `add_connection` returned
|
|
447
|
+
* @param frame - the frame's text, already serialized
|
|
448
|
+
* @returns `false` when the frame was not accepted: the connection is not
|
|
449
|
+
* registered, is still pending admission, or was just closed for the
|
|
450
|
+
* overflow this frame caused
|
|
451
|
+
* @mutates this - queues the frame on the connection's writer; on an
|
|
452
|
+
* overflow removes the connection, aborts its controller, and closes its
|
|
453
|
+
* `WSContext`
|
|
454
|
+
*/
|
|
455
|
+
send_to(connection_id: Uuid, frame: string): boolean;
|
|
333
456
|
/**
|
|
334
457
|
* Initiate a JSON-RPC request to a single connected client and await its
|
|
335
458
|
* reply — the server→client request/response direction (ActionPeer).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transports_ws_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_backend.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"transports_ws_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_backend.ts"],"names":[],"mappings":"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"}
|
|
@@ -44,6 +44,56 @@
|
|
|
44
44
|
* uses the two-phase form. The twin of the Rust `fuz_realtime`
|
|
45
45
|
* `ConnectionRegistry`.
|
|
46
46
|
*
|
|
47
|
+
* ## Outbound bound
|
|
48
|
+
*
|
|
49
|
+
* Every frame the server sends a connection goes through one path —
|
|
50
|
+
* `send_to`, which responses, error frames and socket-scoped notifications
|
|
51
|
+
* use, and the fan-outs (`send`, `broadcast_filtered`, `send_to_account`,
|
|
52
|
+
* `request_connection`) — into the connection's `WsOutboundWriter`
|
|
53
|
+
* (`actions/ws_outbound_writer.ts`). A send never waits and never throws: one
|
|
54
|
+
* recipient that stopped reading must not slow a producer or another
|
|
55
|
+
* recipient.
|
|
56
|
+
*
|
|
57
|
+
* What waits for a socket is held to a byte budget, `outbound_queue_bytes`
|
|
58
|
+
* (`DEFAULT_WS_OUTBOUND_QUEUE_BYTES`): each waiting frame is charged its UTF-8
|
|
59
|
+
* length plus `OUTBOUND_FRAME_OVERHEAD_BYTES`, and a frame is always accepted
|
|
60
|
+
* when nothing is waiting. Nothing counts frames. A frame sent to an idle
|
|
61
|
+
* connection goes straight to the socket; the rest of a burst sent in one
|
|
62
|
+
* synchronous run waits, as does anything sent while the socket's own buffer
|
|
63
|
+
* is backed up.
|
|
64
|
+
*
|
|
65
|
+
* A frame that does not fit **closes the connection** rather than being
|
|
66
|
+
* dropped alone: the connection is removed in the same synchronous step — the
|
|
67
|
+
* overflowing send does not count it, later sends miss it, the count accessors
|
|
68
|
+
* and the cap no longer see it — its controller is aborted, what was waiting
|
|
69
|
+
* is discarded, and the socket is closed with `WS_CLOSE_OUTBOUND_OVERFLOW`.
|
|
70
|
+
* Frames already handed to the socket may still reach the client ahead of the
|
|
71
|
+
* close frame; nothing follows it, the response of the request whose handler
|
|
72
|
+
* overflowed the connection included. One `warn` line names the connection,
|
|
73
|
+
* its account, and what was waiting; no audit row is written — a client can
|
|
74
|
+
* provoke an overflow at will, and a write per overflow would amplify it.
|
|
75
|
+
*
|
|
76
|
+
* A socket that holds unwritten bytes past `WS_WRITE_STALL_TIMEOUT` is dropped
|
|
77
|
+
* with no close frame, and removed the same way, with one `warn` line. Every
|
|
78
|
+
* other close the transport makes (a revocation, the cap, the shutdown,
|
|
79
|
+
* `close_connection`) hands the frames still waiting to the socket ahead of
|
|
80
|
+
* its close frame while the socket's buffer is below
|
|
81
|
+
* `WS_WRITE_HIGH_WATER_BYTES`, discards the rest, and cuts the socket if the
|
|
82
|
+
* close frame is still unwritten after `WS_CLOSE_WRITE_TIMEOUT`. A reply sent
|
|
83
|
+
* to an otherwise idle connection just before such a close reaches the client;
|
|
84
|
+
* one queued behind a backed-up socket does not.
|
|
85
|
+
*
|
|
86
|
+
* A connection the transport closed or dropped is out of its map, so the
|
|
87
|
+
* per-account cap does not count it while its socket is still closing. What
|
|
88
|
+
* the runtime holds for such a socket is bounded by the writer
|
|
89
|
+
* (`actions/ws_outbound_writer.ts`, "Closing" and "Runtimes").
|
|
90
|
+
*
|
|
91
|
+
* The twin of the Rust `fuz_realtime` outbound queue — same budget, charge,
|
|
92
|
+
* close code and deadlines. Where they differ: the Rust spine discards every
|
|
93
|
+
* frame queued before any close, and queues every frame, where this transport
|
|
94
|
+
* writes the first frame of a run straight through and, at a close that is
|
|
95
|
+
* not an overflow, the waiting frames its socket has room for.
|
|
96
|
+
*
|
|
47
97
|
* ## Shutdown
|
|
48
98
|
*
|
|
49
99
|
* `close_all_sockets` — the close `AppServer.close` runs — closes every
|
|
@@ -67,8 +117,9 @@ import { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
|
67
117
|
import { create_uuid } from '@fuzdev/fuz_util/id.ts';
|
|
68
118
|
import { jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
|
|
69
119
|
import { create_jsonrpc_error_response, create_jsonrpc_request, to_jsonrpc_message_id, is_jsonrpc_request } from '../http/jsonrpc_helpers.js';
|
|
70
|
-
import { WS_CLOSE_CONNECTION_LIMIT, WS_CLOSE_GOING_AWAY, WS_CLOSE_GOING_AWAY_REASON, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON } from './transports.js';
|
|
120
|
+
import { WS_CLOSE_CONNECTION_LIMIT, WS_CLOSE_GOING_AWAY, WS_CLOSE_GOING_AWAY_REASON, WS_CLOSE_OUTBOUND_OVERFLOW, WS_CLOSE_OUTBOUND_OVERFLOW_REASON, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON, utf8_length } from './transports.js';
|
|
71
121
|
import { PendingPeerRequests } from './peer_request.js';
|
|
122
|
+
import { WsOutboundWriter } from './ws_outbound_writer.js';
|
|
72
123
|
/** Type guard for `FilterableBroadcastTransport`. */
|
|
73
124
|
export const is_filterable_broadcast_transport = (transport) => 'broadcast_filtered' in transport &&
|
|
74
125
|
typeof transport.broadcast_filtered === 'function';
|
|
@@ -84,16 +135,34 @@ export const is_filterable_broadcast_transport = (transport) => 'broadcast_filte
|
|
|
84
135
|
* spine's `DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT`.
|
|
85
136
|
*/
|
|
86
137
|
export const DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT = 50;
|
|
138
|
+
/**
|
|
139
|
+
* Default byte budget of the frames waiting to be written to one connection,
|
|
140
|
+
* applied by `BackendWebsocketTransport` unless overridden via
|
|
141
|
+
* `BackendWebsocketTransportOptions.outbound_queue_bytes`.
|
|
142
|
+
*
|
|
143
|
+
* 4 MiB — four messages at `DEFAULT_WS_MAX_MESSAGE_BYTES` as it stands, and a
|
|
144
|
+
* value of its own: it does not move with the inbound message cap. A consumer
|
|
145
|
+
* whose server pushes more than this at a client in one burst — large frames,
|
|
146
|
+
* or long runs of them — sets its own. Same value as the Rust spine's
|
|
147
|
+
* `DEFAULT_WS_OUTBOUND_QUEUE_BYTES`.
|
|
148
|
+
*/
|
|
149
|
+
export const DEFAULT_WS_OUTBOUND_QUEUE_BYTES = 4 * 1024 * 1024;
|
|
87
150
|
export class BackendWebsocketTransport {
|
|
88
151
|
transport_name = 'backend_websocket_rpc';
|
|
89
152
|
// Per-account connection cap; `null` = uncapped.
|
|
90
153
|
#max_connections_per_account;
|
|
154
|
+
// Byte budget of each connection's waiting frames.
|
|
155
|
+
#outbound_queue_bytes;
|
|
91
156
|
#log;
|
|
92
157
|
// One entry per registered connection, pending or admitted, in
|
|
93
158
|
// registration order (Map iteration is insertion order — the cap's
|
|
94
159
|
// "oldest"). Adding a new identity scope (e.g. `device_id`) means adding a
|
|
95
160
|
// field to `ConnectionIdentity`, not a parallel map.
|
|
96
161
|
#connections = new Map();
|
|
162
|
+
// Writers of connections the transport has closed, until their socket's
|
|
163
|
+
// close arrives (`remove_connection`) or the close deadline passes — so the
|
|
164
|
+
// deadline timer is cleared when the socket goes.
|
|
165
|
+
#closing_writers = new Map();
|
|
97
166
|
// set once by `close_all_sockets`, never cleared — every later
|
|
98
167
|
// registration is born closed (module doc, "Shutdown")
|
|
99
168
|
#closing = false;
|
|
@@ -103,7 +172,7 @@ export class BackendWebsocketTransport {
|
|
|
103
172
|
#pending = new PendingPeerRequests();
|
|
104
173
|
/**
|
|
105
174
|
* @throws Error when `max_connections_per_account` is neither `null` nor a
|
|
106
|
-
* positive integer
|
|
175
|
+
* positive integer, or `outbound_queue_bytes` is not a positive integer
|
|
107
176
|
*/
|
|
108
177
|
constructor(options) {
|
|
109
178
|
const max = options?.max_connections_per_account === undefined
|
|
@@ -113,6 +182,13 @@ export class BackendWebsocketTransport {
|
|
|
113
182
|
throw new Error(`BackendWebsocketTransport: max_connections_per_account must be a positive integer or null, got ${max}`);
|
|
114
183
|
}
|
|
115
184
|
this.#max_connections_per_account = max;
|
|
185
|
+
const outbound_queue_bytes = options?.outbound_queue_bytes ?? DEFAULT_WS_OUTBOUND_QUEUE_BYTES;
|
|
186
|
+
if (!Number.isSafeInteger(outbound_queue_bytes) || outbound_queue_bytes < 1) {
|
|
187
|
+
// zero would close a connection for the first frame queued behind one
|
|
188
|
+
// still waiting
|
|
189
|
+
throw new Error(`BackendWebsocketTransport: outbound_queue_bytes must be a positive integer, got ${outbound_queue_bytes}`);
|
|
190
|
+
}
|
|
191
|
+
this.#outbound_queue_bytes = outbound_queue_bytes;
|
|
116
192
|
this.#log = options?.log === undefined ? new Logger('[ws]') : options.log;
|
|
117
193
|
}
|
|
118
194
|
/**
|
|
@@ -125,8 +201,9 @@ export class BackendWebsocketTransport {
|
|
|
125
201
|
* later `admit` then refuses. Until admitted it evicts nothing, is not
|
|
126
202
|
* counted toward the per-account cap, and receives no message.
|
|
127
203
|
*
|
|
128
|
-
* The caller owns the entry until it is admitted:
|
|
129
|
-
* `remove_connection`
|
|
204
|
+
* The caller owns the entry until it is admitted: a refusal closes it with
|
|
205
|
+
* `close_connection`, and `remove_connection` drops it once its socket has
|
|
206
|
+
* closed.
|
|
130
207
|
*
|
|
131
208
|
* Once the transport is closing (`close_all_sockets`) the registration is
|
|
132
209
|
* born closed: nothing is inserted, `abort_controller` is aborted, and `ws`
|
|
@@ -223,7 +300,13 @@ export class BackendWebsocketTransport {
|
|
|
223
300
|
ws,
|
|
224
301
|
identity: { token_hash, account_id, api_token_id },
|
|
225
302
|
admitted,
|
|
226
|
-
abort_controller
|
|
303
|
+
abort_controller,
|
|
304
|
+
writer: new WsOutboundWriter({
|
|
305
|
+
ws,
|
|
306
|
+
max_bytes: this.#outbound_queue_bytes,
|
|
307
|
+
on_stall: (stall) => this.#drop_stalled(connection_id, stall),
|
|
308
|
+
log: this.#log
|
|
309
|
+
})
|
|
227
310
|
});
|
|
228
311
|
return connection_id;
|
|
229
312
|
}
|
|
@@ -239,14 +322,56 @@ export class BackendWebsocketTransport {
|
|
|
239
322
|
*
|
|
240
323
|
* Removal only: the socket is not closed and the registered
|
|
241
324
|
* `abort_controller` is not aborted — the caller that ends a socket does
|
|
242
|
-
* both.
|
|
325
|
+
* both. What was waiting to be written to it is dropped.
|
|
326
|
+
*
|
|
327
|
+
* For a socket that has closed (an adapter's close event): on a connection
|
|
328
|
+
* the transport closed it also drops the close deadline, so calling it while
|
|
329
|
+
* that socket is still closing leaves a client that is not reading uncut.
|
|
243
330
|
*
|
|
244
|
-
* @mutates this - deletes the connection's entry from `#connections
|
|
245
|
-
* settles its pending peer
|
|
331
|
+
* @mutates this - deletes the connection's entry from `#connections`, drops
|
|
332
|
+
* its waiting frames and write deadlines, and settles its pending peer
|
|
333
|
+
* requests as `connection_gone`
|
|
246
334
|
*/
|
|
247
335
|
remove_connection(connection_id) {
|
|
336
|
+
this.#connections.get(connection_id)?.writer.dispose();
|
|
337
|
+
// a connection the transport closed: its socket is gone now
|
|
338
|
+
const closing = this.#closing_writers.get(connection_id);
|
|
339
|
+
if (closing) {
|
|
340
|
+
closing.dispose();
|
|
341
|
+
this.#closing_writers.delete(connection_id);
|
|
342
|
+
}
|
|
248
343
|
this.#cleanup_connection(connection_id);
|
|
249
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Close one connection, pending or admitted, with `code` and `reason` — the
|
|
347
|
+
* close `register_action_ws` makes for a heartbeat timeout, an oversized
|
|
348
|
+
* message, a failed `on_socket_open`, and a refused admission.
|
|
349
|
+
*
|
|
350
|
+
* The connection is removed and its controller aborted at once; frames still
|
|
351
|
+
* waiting for the socket are handed to it ahead of the close frame while its
|
|
352
|
+
* buffer has room and discarded otherwise, and the close frame is held to
|
|
353
|
+
* `WS_CLOSE_WRITE_TIMEOUT` (module doc, "Outbound bound"). A socket whose
|
|
354
|
+
* `close` throws is logged, not thrown.
|
|
355
|
+
*
|
|
356
|
+
* @param connection_id - the id `register_pending` / `add_connection` returned
|
|
357
|
+
* @param code - the close code
|
|
358
|
+
* @param reason - the close reason
|
|
359
|
+
* @returns `false` when the connection is not registered — nothing was closed
|
|
360
|
+
* @mutates this - removes the connection, aborts its registered controller,
|
|
361
|
+
* and closes its `WSContext`
|
|
362
|
+
*/
|
|
363
|
+
close_connection(connection_id, code, reason) {
|
|
364
|
+
const entry = this.#connections.get(connection_id);
|
|
365
|
+
if (!entry)
|
|
366
|
+
return false;
|
|
367
|
+
try {
|
|
368
|
+
this.#end_connection(connection_id, entry, code, reason);
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
this.#log?.error(`error closing client with ${code}:`, error);
|
|
372
|
+
}
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
250
375
|
/**
|
|
251
376
|
* Close every connection whose identity matches the predicate — pending or
|
|
252
377
|
* admitted. Never filter on admission here: reaching a registration in
|
|
@@ -384,12 +509,63 @@ export class BackendWebsocketTransport {
|
|
|
384
509
|
// gone, so the request can never complete.
|
|
385
510
|
this.#pending.drain(connection_id);
|
|
386
511
|
}
|
|
387
|
-
#end_connection(connection_id, entry, code, reason) {
|
|
512
|
+
#end_connection(connection_id, entry, code, reason, backlog = 'flush') {
|
|
388
513
|
this.#cleanup_connection(connection_id);
|
|
389
514
|
// abort before the close so a handler observing `ctx.signal` bails out
|
|
390
515
|
// without waiting for the close handshake
|
|
391
516
|
entry.abort_controller?.abort();
|
|
392
|
-
|
|
517
|
+
this.#closing_writers.set(connection_id, entry.writer);
|
|
518
|
+
entry.writer.close(code, reason, backlog, () => {
|
|
519
|
+
this.#closing_writers.delete(connection_id);
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Hand one frame to a connection's writer. An overflow closes the
|
|
524
|
+
* connection before this returns (module doc, "Outbound bound").
|
|
525
|
+
*
|
|
526
|
+
* @param bytes - the frame's UTF-8 length, when the caller measured it once
|
|
527
|
+
* for several recipients
|
|
528
|
+
* @returns whether the frame was accepted
|
|
529
|
+
*/
|
|
530
|
+
#deliver(connection_id, entry, frame, bytes) {
|
|
531
|
+
const pushed = entry.writer.push(frame, bytes);
|
|
532
|
+
if (pushed.kind === 'accepted')
|
|
533
|
+
return true;
|
|
534
|
+
if (pushed.kind === 'overflow') {
|
|
535
|
+
this.#close_overflowed(connection_id, entry, pushed.overflow);
|
|
536
|
+
}
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
#close_overflowed(connection_id, entry, overflow) {
|
|
540
|
+
// same line as the Rust spine's registry, so one grep finds either
|
|
541
|
+
this.#log?.warn('ws: outbound queue overflow, closing the connection', {
|
|
542
|
+
connection_id,
|
|
543
|
+
account_id: entry.identity.account_id,
|
|
544
|
+
...overflow
|
|
545
|
+
});
|
|
546
|
+
try {
|
|
547
|
+
this.#end_connection(connection_id, entry, WS_CLOSE_OUTBOUND_OVERFLOW, WS_CLOSE_OUTBOUND_OVERFLOW_REASON, 'discard');
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
// the producer that overflowed this connection must not fail for it
|
|
551
|
+
this.#log?.error('error closing overflowed client:', error);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* A connection's write deadline passed: remove it and abort its controller.
|
|
556
|
+
* Its writer has discarded what was waiting and cuts the socket next.
|
|
557
|
+
*/
|
|
558
|
+
#drop_stalled(connection_id, stall) {
|
|
559
|
+
const entry = this.#connections.get(connection_id);
|
|
560
|
+
if (!entry)
|
|
561
|
+
return;
|
|
562
|
+
this.#cleanup_connection(connection_id);
|
|
563
|
+
this.#log?.warn('ws: write stalled, dropping the connection', {
|
|
564
|
+
connection_id,
|
|
565
|
+
account_id: entry.identity.account_id,
|
|
566
|
+
...stall
|
|
567
|
+
});
|
|
568
|
+
entry.abort_controller?.abort();
|
|
393
569
|
}
|
|
394
570
|
/**
|
|
395
571
|
* Make room for one more admitted connection on `account_id`: when the
|
|
@@ -416,10 +592,8 @@ export class BackendWebsocketTransport {
|
|
|
416
592
|
this.#log?.info('ws: connection cap closed oldest', { account_id, closed: excess, max });
|
|
417
593
|
}
|
|
418
594
|
#supersede_connection(connection_id, entry) {
|
|
419
|
-
this.#cleanup_connection(connection_id);
|
|
420
595
|
try {
|
|
421
|
-
entry
|
|
422
|
-
entry.ws.close(WS_CLOSE_CONNECTION_LIMIT, 'connection limit');
|
|
596
|
+
this.#end_connection(connection_id, entry, WS_CLOSE_CONNECTION_LIMIT, 'connection limit');
|
|
423
597
|
}
|
|
424
598
|
catch (error) {
|
|
425
599
|
// the evicted socket is another connection's — a failure closing it
|
|
@@ -442,16 +616,15 @@ export class BackendWebsocketTransport {
|
|
|
442
616
|
}
|
|
443
617
|
#broadcast(message) {
|
|
444
618
|
const serialized = JSON.stringify(message);
|
|
445
|
-
|
|
619
|
+
// measured once, not once per recipient that has to queue it
|
|
620
|
+
const bytes = utf8_length(serialized);
|
|
621
|
+
// deleting the current entry during Map iteration is safe — an overflow
|
|
622
|
+
// removes its connection
|
|
623
|
+
for (const [connection_id, entry] of this.#connections) {
|
|
446
624
|
// a registration pending admission is not a connection yet
|
|
447
625
|
if (!entry.admitted)
|
|
448
626
|
continue;
|
|
449
|
-
|
|
450
|
-
entry.ws.send(serialized);
|
|
451
|
-
}
|
|
452
|
-
catch (error) {
|
|
453
|
-
this.#log?.error('error broadcasting to client:', error);
|
|
454
|
-
}
|
|
627
|
+
this.#deliver(connection_id, entry, serialized, bytes);
|
|
455
628
|
}
|
|
456
629
|
// TODO hack - remove if not ever needed, I assume this will need to be async so let's hold that assumption
|
|
457
630
|
return Promise.resolve();
|
|
@@ -465,21 +638,21 @@ export class BackendWebsocketTransport {
|
|
|
465
638
|
* When no ACL is needed, callers should prefer `send(message)` / `#broadcast`
|
|
466
639
|
* to skip the per-connection predicate overhead.
|
|
467
640
|
*
|
|
468
|
-
*
|
|
641
|
+
* A connection the message overflows is closed and not counted (module doc,
|
|
642
|
+
* "Outbound bound").
|
|
643
|
+
*
|
|
644
|
+
* @returns the number of connections that accepted the message
|
|
469
645
|
*/
|
|
470
646
|
broadcast_filtered(message, predicate) {
|
|
471
647
|
const serialized = JSON.stringify(message);
|
|
648
|
+
// measured once, not once per recipient that has to queue it
|
|
649
|
+
const bytes = utf8_length(serialized);
|
|
472
650
|
let count = 0;
|
|
473
|
-
for (const entry of this.#connections
|
|
651
|
+
for (const [connection_id, entry] of this.#connections) {
|
|
474
652
|
if (!entry.admitted || !predicate(entry.identity))
|
|
475
653
|
continue;
|
|
476
|
-
|
|
477
|
-
entry.ws.send(serialized);
|
|
654
|
+
if (this.#deliver(connection_id, entry, serialized, bytes))
|
|
478
655
|
count++;
|
|
479
|
-
}
|
|
480
|
-
catch (error) {
|
|
481
|
-
this.#log?.error('error broadcasting filtered to client:', error);
|
|
482
|
-
}
|
|
483
656
|
}
|
|
484
657
|
return count;
|
|
485
658
|
}
|
|
@@ -495,11 +668,35 @@ export class BackendWebsocketTransport {
|
|
|
495
668
|
* for the account (session, bearer, and daemon-token) receives the
|
|
496
669
|
* message.
|
|
497
670
|
*
|
|
498
|
-
* @returns the number of
|
|
671
|
+
* @returns the number of connections that accepted the message
|
|
499
672
|
*/
|
|
500
673
|
send_to_account(account_id, message) {
|
|
501
674
|
return this.broadcast_filtered(message, (id) => id.account_id === account_id);
|
|
502
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* Send one serialized frame to a single admitted connection — the path
|
|
678
|
+
* `register_action_ws` sends every response, error frame, and socket-scoped
|
|
679
|
+
* notification through.
|
|
680
|
+
*
|
|
681
|
+
* Never waits and never throws. A frame the connection's outbound budget
|
|
682
|
+
* does not fit closes the connection with `WS_CLOSE_OUTBOUND_OVERFLOW` before
|
|
683
|
+
* this returns (module doc, "Outbound bound").
|
|
684
|
+
*
|
|
685
|
+
* @param connection_id - the id `register_pending` / `add_connection` returned
|
|
686
|
+
* @param frame - the frame's text, already serialized
|
|
687
|
+
* @returns `false` when the frame was not accepted: the connection is not
|
|
688
|
+
* registered, is still pending admission, or was just closed for the
|
|
689
|
+
* overflow this frame caused
|
|
690
|
+
* @mutates this - queues the frame on the connection's writer; on an
|
|
691
|
+
* overflow removes the connection, aborts its controller, and closes its
|
|
692
|
+
* `WSContext`
|
|
693
|
+
*/
|
|
694
|
+
send_to(connection_id, frame) {
|
|
695
|
+
const entry = this.#connections.get(connection_id);
|
|
696
|
+
if (!entry?.admitted)
|
|
697
|
+
return false;
|
|
698
|
+
return this.#deliver(connection_id, entry, frame);
|
|
699
|
+
}
|
|
503
700
|
/**
|
|
504
701
|
* Initiate a JSON-RPC request to a single connected client and await its
|
|
505
702
|
* reply — the server→client request/response direction (ActionPeer).
|
|
@@ -526,17 +723,23 @@ export class BackendWebsocketTransport {
|
|
|
526
723
|
if (!entry?.admitted) {
|
|
527
724
|
return Promise.resolve({ ok: false, error: { kind: 'connection_gone' } });
|
|
528
725
|
}
|
|
529
|
-
const { ws } = entry;
|
|
530
726
|
const registered = this.#pending.register(connection_id, options?.timeout_ms);
|
|
531
727
|
if (!registered)
|
|
532
728
|
return Promise.resolve({ ok: false, error: { kind: 'too_many_in_flight' } });
|
|
533
729
|
const { id, outcome } = registered;
|
|
730
|
+
let frame;
|
|
534
731
|
try {
|
|
535
|
-
|
|
732
|
+
frame = JSON.stringify(create_jsonrpc_request(method, params, id));
|
|
536
733
|
}
|
|
537
734
|
catch {
|
|
538
|
-
//
|
|
539
|
-
|
|
735
|
+
// params that are not JSON reach no client
|
|
736
|
+
this.#pending.settle(connection_id, id, { ok: false, error: { kind: 'connection_gone' } });
|
|
737
|
+
return outcome;
|
|
738
|
+
}
|
|
739
|
+
if (!this.#deliver(connection_id, entry, frame)) {
|
|
740
|
+
// Not accepted — the socket refused it, or the frame overflowed the
|
|
741
|
+
// connection and closed it; settle now so the caller isn't left
|
|
742
|
+
// awaiting until the deadline.
|
|
540
743
|
this.#pending.settle(connection_id, id, { ok: false, error: { kind: 'connection_gone' } });
|
|
541
744
|
}
|
|
542
745
|
return outcome;
|
|
@@ -66,6 +66,15 @@ export interface WsEndpointSpec {
|
|
|
66
66
|
* @default DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT
|
|
67
67
|
*/
|
|
68
68
|
max_connections_per_account?: number | null;
|
|
69
|
+
/**
|
|
70
|
+
* Byte budget of the frames waiting to be written to one connection, for
|
|
71
|
+
* the auto-created transport. See
|
|
72
|
+
* `RegisterActionWsOptions.outbound_queue_bytes` — rejected alongside
|
|
73
|
+
* `transport`, which carries its own budget.
|
|
74
|
+
*
|
|
75
|
+
* @default DEFAULT_WS_OUTBOUND_QUEUE_BYTES
|
|
76
|
+
*/
|
|
77
|
+
outbound_queue_bytes?: number;
|
|
69
78
|
/**
|
|
70
79
|
* Server-side heartbeat policy. Default-on (60s receive-silence
|
|
71
80
|
* timeout). Set `false` only when an upstream stack (TCP keepalive,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws_endpoint_spec.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/ws_endpoint_spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC5E,OAAO,KAAK,EACX,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAEvE;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC9B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACzC;;;;OAIG;IACH,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,sBAAsB,CAAC;IAC7C,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,kBAAkB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAC;CACxD;AAED,yFAAyF;AACzF,MAAM,MAAM,sBAAsB,GAAG,cAAc,GAAG;IAAE,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC;AAEjG;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAChC,cAAc,aAAa,CAAC,cAAc,CAAC,GAAG,SAAS,EACvD,iBAAiB,aAAa,CAAC,MAAM,CAAC,KACpC,KAAK,CAAC,sBAAsB,CAAC,GAAG,SAI/B,CAAC"}
|
|
1
|
+
{"version":3,"file":"ws_endpoint_spec.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/ws_endpoint_spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC5E,OAAO,KAAK,EACX,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAEvE;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC9B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACzC;;;;OAIG;IACH,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,sBAAsB,CAAC;IAC7C,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,kBAAkB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAC;CACxD;AAED,yFAAyF;AACzF,MAAM,MAAM,sBAAsB,GAAG,cAAc,GAAG;IAAE,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC;AAEjG;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAChC,cAAc,aAAa,CAAC,cAAc,CAAC,GAAG,SAAS,EACvD,iBAAiB,aAAa,CAAC,MAAM,CAAC,KACpC,KAAK,CAAC,sBAAsB,CAAC,GAAG,SAI/B,CAAC"}
|