@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.
Files changed (112) hide show
  1. package/dist/actions/CLAUDE.md +91 -9
  2. package/dist/actions/register_action_ws.d.ts +23 -3
  3. package/dist/actions/register_action_ws.d.ts.map +1 -1
  4. package/dist/actions/register_action_ws.js +57 -37
  5. package/dist/actions/transports.d.ts +23 -0
  6. package/dist/actions/transports.d.ts.map +1 -1
  7. package/dist/actions/transports.js +23 -0
  8. package/dist/actions/transports_ws_backend.d.ts +132 -9
  9. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  10. package/dist/actions/transports_ws_backend.js +236 -33
  11. package/dist/actions/ws_endpoint_spec.d.ts +9 -0
  12. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  13. package/dist/actions/ws_outbound_writer.d.ts +206 -0
  14. package/dist/actions/ws_outbound_writer.d.ts.map +1 -0
  15. package/dist/actions/ws_outbound_writer.js +431 -0
  16. package/dist/auth/CLAUDE.md +2 -2
  17. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  18. package/dist/auth/audit_log_routes.js +3 -1
  19. package/dist/auth/cell_actions.js +1 -1
  20. package/dist/auth/role_schema.d.ts +14 -3
  21. package/dist/auth/role_schema.d.ts.map +1 -1
  22. package/dist/auth/role_schema.js +14 -2
  23. package/dist/db/CLAUDE.md +10 -7
  24. package/dist/db/create_db.d.ts +8 -6
  25. package/dist/db/create_db.d.ts.map +1 -1
  26. package/dist/db/create_db.js +11 -10
  27. package/dist/db/db.d.ts +9 -7
  28. package/dist/db/db.d.ts.map +1 -1
  29. package/dist/db/db.js +5 -4
  30. package/dist/db/db_pglet.d.ts +43 -0
  31. package/dist/db/db_pglet.d.ts.map +1 -0
  32. package/dist/db/db_pglet.js +55 -0
  33. package/dist/db/migrate.d.ts +1 -1
  34. package/dist/db/migrate.js +2 -2
  35. package/dist/http/CLAUDE.md +2 -1
  36. package/dist/http/body_limit.d.ts +61 -0
  37. package/dist/http/body_limit.d.ts.map +1 -0
  38. package/dist/http/body_limit.js +87 -0
  39. package/dist/http/db_routes.js +2 -2
  40. package/dist/http/error_schemas.d.ts +2 -0
  41. package/dist/http/error_schemas.d.ts.map +1 -1
  42. package/dist/http/error_schemas.js +2 -0
  43. package/dist/realtime/outbound_budget.d.ts +91 -0
  44. package/dist/realtime/outbound_budget.d.ts.map +1 -0
  45. package/dist/realtime/outbound_budget.js +108 -0
  46. package/dist/realtime/sse.d.ts +36 -3
  47. package/dist/realtime/sse.d.ts.map +1 -1
  48. package/dist/realtime/sse.js +58 -6
  49. package/dist/realtime/sse_constants.d.ts +12 -0
  50. package/dist/realtime/sse_constants.d.ts.map +1 -1
  51. package/dist/realtime/sse_constants.js +12 -0
  52. package/dist/server/app_server.d.ts.map +1 -1
  53. package/dist/server/app_server.js +3 -6
  54. package/dist/testing/CLAUDE.md +87 -28
  55. package/dist/testing/admin_integration.js +1 -1
  56. package/dist/testing/app_server.d.ts +5 -5
  57. package/dist/testing/app_server.d.ts.map +1 -1
  58. package/dist/testing/app_server.js +21 -20
  59. package/dist/testing/audit_completeness.d.ts +1 -1
  60. package/dist/testing/audit_completeness.js +1 -1
  61. package/dist/testing/audit_drift_guard.d.ts +1 -1
  62. package/dist/testing/audit_drift_guard.js +1 -1
  63. package/dist/testing/cross_backend/body_size_smuggling.d.ts +9 -9
  64. package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
  65. package/dist/testing/cross_backend/body_size_smuggling.js +110 -60
  66. package/dist/testing/cross_backend/capabilities.d.ts +18 -15
  67. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  68. package/dist/testing/cross_backend/capabilities.js +1 -0
  69. package/dist/testing/cross_backend/default_backend_configs.d.ts +5 -5
  70. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  71. package/dist/testing/cross_backend/default_backend_configs.js +6 -6
  72. package/dist/testing/cross_backend/in_process_setup.d.ts +2 -2
  73. package/dist/testing/cross_backend/in_process_setup.js +3 -3
  74. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts +6 -0
  75. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -0
  76. package/dist/testing/cross_backend/role_grant_role_grammar.js +117 -0
  77. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +2 -3
  78. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  79. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +2 -3
  80. package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +1 -1
  81. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  82. package/dist/testing/cross_backend/testing_backdoor.js +8 -4
  83. package/dist/testing/cross_backend/testing_reset_actions.d.ts +84 -4
  84. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  85. package/dist/testing/cross_backend/testing_reset_actions.js +102 -3
  86. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +2 -2
  87. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  88. package/dist/testing/cross_backend/ts_spine_backend_config.js +9 -7
  89. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +29 -0
  90. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -0
  91. package/dist/testing/cross_backend/ws_outbound_overflow.js +211 -0
  92. package/dist/testing/db.d.ts +49 -60
  93. package/dist/testing/db.d.ts.map +1 -1
  94. package/dist/testing/db.js +104 -101
  95. package/dist/testing/integration.js +1 -1
  96. package/dist/testing/rate_limiting.d.ts +2 -1
  97. package/dist/testing/rate_limiting.d.ts.map +1 -1
  98. package/dist/testing/rate_limiting.js +2 -2
  99. package/dist/testing/schema_introspect.d.ts +10 -2
  100. package/dist/testing/schema_introspect.d.ts.map +1 -1
  101. package/dist/testing/schema_introspect.js +15 -7
  102. package/dist/testing/sse_round_trip.d.ts +1 -1
  103. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  104. package/dist/testing/sse_round_trip.js +2 -2
  105. package/dist/testing/stubs.js +1 -1
  106. package/dist/testing/transports/ws_transport.d.ts +4 -2
  107. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  108. package/dist/testing/transports/ws_transport.js +2 -1
  109. package/dist/testing/ws_round_trip.d.ts +32 -2
  110. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  111. package/dist/testing/ws_round_trip.js +26 -4
  112. 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
- * Logger for cap evictions and failed sends or closes. A cap eviction logs
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: every refusal path calls
159
- * `remove_connection` with the returned id.
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
- * @mutates this - deletes the connection's entry from `#connections` and
241
- * settles its pending peer requests as `connection_gone`
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
- * @returns the number of sockets the message was sent to
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 sockets the message was sent to
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":"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"}
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: every refusal path calls
129
- * `remove_connection` with the returned id.
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` and
245
- * settles its pending peer requests as `connection_gone`
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
- entry.ws.close(code, reason);
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.abort_controller?.abort();
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
- for (const entry of this.#connections.values()) {
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
- try {
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
- * @returns the number of sockets the message was sent to
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.values()) {
651
+ for (const [connection_id, entry] of this.#connections) {
474
652
  if (!entry.admitted || !predicate(entry.identity))
475
653
  continue;
476
- try {
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 sockets the message was sent to
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
- ws.send(JSON.stringify(create_jsonrpc_request(method, params, id)));
732
+ frame = JSON.stringify(create_jsonrpc_request(method, params, id));
536
733
  }
537
734
  catch {
538
- // Send failed — the socket is gone; settle now so the caller isn't
539
- // left awaiting until the deadline.
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"}