@fuzdev/fuz_app 0.134.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/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 +70 -10
- package/dist/testing/admin_integration.js +1 -1
- 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.js +5 -5
- 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/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 +82 -2
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +101 -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/integration.js +1 -1
- 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/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 +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbound side of one server WebSocket connection — a byte-budgeted
|
|
3
|
+
* queue of frames, the pump that hands them to the socket, and the deadlines
|
|
4
|
+
* on what the socket has not yet written.
|
|
5
|
+
*
|
|
6
|
+
* Every frame the server sends a connection goes through
|
|
7
|
+
* `WsOutboundWriter.push`: a response, an error frame, a notification, a
|
|
8
|
+
* broadcast, a server-initiated request. `BackendWebsocketTransport` owns one
|
|
9
|
+
* writer per connection and is its only caller.
|
|
10
|
+
*
|
|
11
|
+
* ## What waits
|
|
12
|
+
*
|
|
13
|
+
* A frame pushed at an idle connection — nothing waiting, nothing unwritten in
|
|
14
|
+
* the runtime's socket buffer, and no other frame pushed in the same
|
|
15
|
+
* synchronous run — goes straight to the socket. Every other frame **waits**
|
|
16
|
+
* in the writer's queue: the rest of a burst pushed in one synchronous run
|
|
17
|
+
* (a loop with no `await` in it gives the pump no turn), and anything pushed
|
|
18
|
+
* while the socket's buffer is at or over `WS_WRITE_HIGH_WATER_BYTES`. The
|
|
19
|
+
* pump hands waiting frames over in order — on the microtask after a push, and
|
|
20
|
+
* whenever the buffer has drained below the high-water mark.
|
|
21
|
+
*
|
|
22
|
+
* What waits is held to the connection's budget (`OutboundBudget`): each
|
|
23
|
+
* waiting frame is charged its UTF-8 length plus
|
|
24
|
+
* `OUTBOUND_FRAME_OVERHEAD_BYTES`, and a frame is always admitted when nothing
|
|
25
|
+
* is waiting. A frame handed to the socket is no longer charged — the budget
|
|
26
|
+
* bounds what waits, and the write deadline bounds what is in flight.
|
|
27
|
+
*
|
|
28
|
+
* A frame that does not fit is reported as an overflow and not queued; the
|
|
29
|
+
* owner closes the connection for it (`WS_CLOSE_OUTBOUND_OVERFLOW`).
|
|
30
|
+
*
|
|
31
|
+
* The queue is a linked list: a frame handed to the socket is unlinked, so the
|
|
32
|
+
* writer holds the frames that wait and no others, however long the queue goes
|
|
33
|
+
* without emptying.
|
|
34
|
+
*
|
|
35
|
+
* ## Closing
|
|
36
|
+
*
|
|
37
|
+
* `close` sends the close frame and takes nothing more. With `'flush'` it
|
|
38
|
+
* first runs the pump's rule once: waiting frames are handed to the socket
|
|
39
|
+
* while its buffer is below `WS_WRITE_HIGH_WATER_BYTES`. Whatever still waits
|
|
40
|
+
* is discarded, with either backlog choice. So a frame sent to an otherwise
|
|
41
|
+
* idle connection just before a close reaches the client ahead of the close
|
|
42
|
+
* frame, and a frame queued behind a backed-up socket does not. After a close
|
|
43
|
+
* a socket whose buffer the writer can read ("Runtimes") holds less than the
|
|
44
|
+
* high-water mark plus one frame, and the close frame.
|
|
45
|
+
*
|
|
46
|
+
* ## Deadlines
|
|
47
|
+
*
|
|
48
|
+
* - **A frame**: bytes the socket's buffer still holds
|
|
49
|
+
* `WS_WRITE_STALL_TIMEOUT` after they were handed to it mean the client is
|
|
50
|
+
* not reading. The writer discards what waits, reports the stall, and cuts
|
|
51
|
+
* the socket with no close frame (the client sees an abnormal close, 1006).
|
|
52
|
+
* - **A close frame**: a close still unwritten `WS_CLOSE_WRITE_TIMEOUT` after
|
|
53
|
+
* `close` cuts the socket the same way, so a closed connection whose client
|
|
54
|
+
* stopped reading does not hold its buffered frames for the runtime's own
|
|
55
|
+
* close timeout — where the runtime can cut a socket ("Runtimes").
|
|
56
|
+
*
|
|
57
|
+
* ## Runtimes
|
|
58
|
+
*
|
|
59
|
+
* The buffer is read through the socket Hono's `WSContext` wraps (`raw`):
|
|
60
|
+
* `bufferedAmount` on Node's `ws` and on Deno's standard `WebSocket`,
|
|
61
|
+
* `getBufferedAmount()` on Bun's `ServerWebSocket`. A socket that exposes
|
|
62
|
+
* neither reads as never backed up: its frames are still budgeted while they
|
|
63
|
+
* wait for the pump, but nothing detects a stall. The cut is `terminate()`,
|
|
64
|
+
* which Node's `ws` and Bun's `ServerWebSocket` have. Deno's standard
|
|
65
|
+
* `WebSocket` has no way to drop a socket without the closing handshake, so
|
|
66
|
+
* there a stalled connection is closed with `WS_CLOSE_OUTBOUND_OVERFLOW`
|
|
67
|
+
* instead — a frame the client reads only if it starts reading again — and
|
|
68
|
+
* the close deadline cuts nothing: a closed socket whose client is not reading
|
|
69
|
+
* stays in the runtime, holding what its buffer held at the close ("Closing"),
|
|
70
|
+
* until the runtime or the client ends it.
|
|
71
|
+
*
|
|
72
|
+
* @module
|
|
73
|
+
*/
|
|
74
|
+
import type { WSContext } from 'hono/ws';
|
|
75
|
+
import type { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
76
|
+
import { type OutboundOverflow } from '../realtime/outbound_budget.js';
|
|
77
|
+
/**
|
|
78
|
+
* How long bytes handed to a socket may stay unwritten in its buffer before
|
|
79
|
+
* the connection is dropped (module doc, "Deadlines"). Equal to
|
|
80
|
+
* `DEFAULT_PEER_REQUEST_TIMEOUT`: a server-initiated request that cannot even
|
|
81
|
+
* be written within its own reply deadline has already failed. Same value as
|
|
82
|
+
* the Rust spine's `WS_WRITE_STALL_TIMEOUT`.
|
|
83
|
+
*
|
|
84
|
+
* The deadline covers the bytes in the buffer when it starts, not progress: a
|
|
85
|
+
* client taking them too slowly to finish inside it is dropped the same way,
|
|
86
|
+
* and reconnects.
|
|
87
|
+
*/
|
|
88
|
+
export declare const WS_WRITE_STALL_TIMEOUT = 10000;
|
|
89
|
+
/**
|
|
90
|
+
* How long a close frame may stay unwritten before the socket is cut without
|
|
91
|
+
* it (module doc, "Deadlines"). Same value as the Rust spine's bound on the
|
|
92
|
+
* same write.
|
|
93
|
+
*/
|
|
94
|
+
export declare const WS_CLOSE_WRITE_TIMEOUT = 1000;
|
|
95
|
+
/**
|
|
96
|
+
* How many unwritten bytes a socket's buffer may hold before the pump stops
|
|
97
|
+
* handing it waiting frames. Below it the pump keeps writing, so a runtime
|
|
98
|
+
* that reports a frame as buffered until its write completes (Deno) is not
|
|
99
|
+
* held to one frame per turn; at or over it frames wait in the budgeted queue
|
|
100
|
+
* instead of the runtime's unbounded buffer.
|
|
101
|
+
*/
|
|
102
|
+
export declare const WS_WRITE_HIGH_WATER_BYTES: number;
|
|
103
|
+
/** The outcome of one `WsOutboundWriter.push`. */
|
|
104
|
+
export type WsOutboundPush =
|
|
105
|
+
/** The frame went to the socket or is waiting for it. */
|
|
106
|
+
{
|
|
107
|
+
kind: 'accepted';
|
|
108
|
+
}
|
|
109
|
+
/** The frame was dropped: the writer has closed, or the socket refused it. */
|
|
110
|
+
| {
|
|
111
|
+
kind: 'refused';
|
|
112
|
+
}
|
|
113
|
+
/** The frame did not fit the budget and was not queued. */
|
|
114
|
+
| {
|
|
115
|
+
kind: 'overflow';
|
|
116
|
+
overflow: OutboundOverflow;
|
|
117
|
+
};
|
|
118
|
+
/** What a stalled connection held when its write deadline passed. */
|
|
119
|
+
export interface WsOutboundStall {
|
|
120
|
+
/** Payload bytes that were waiting, now discarded. */
|
|
121
|
+
queued_bytes: number;
|
|
122
|
+
/** Frames that were waiting, now discarded. */
|
|
123
|
+
queued_frames: number;
|
|
124
|
+
/** Bytes the socket's buffer still held. */
|
|
125
|
+
buffered_bytes: number;
|
|
126
|
+
}
|
|
127
|
+
/** What `WsOutboundWriter.close` does with the frames still waiting. */
|
|
128
|
+
export type WsOutboundBacklog =
|
|
129
|
+
/**
|
|
130
|
+
* Hand them to the socket ahead of the close frame while its buffer is below
|
|
131
|
+
* `WS_WRITE_HIGH_WATER_BYTES`, and drop the rest.
|
|
132
|
+
*/
|
|
133
|
+
'flush'
|
|
134
|
+
/** Drop them all. */
|
|
135
|
+
| 'discard';
|
|
136
|
+
/** Options for `WsOutboundWriter`. */
|
|
137
|
+
export interface WsOutboundWriterOptions {
|
|
138
|
+
/** The socket to write to. */
|
|
139
|
+
ws: WSContext;
|
|
140
|
+
/** The byte budget of the frames waiting for the socket. */
|
|
141
|
+
max_bytes: number;
|
|
142
|
+
/**
|
|
143
|
+
* Called once when a write deadline passes, after the writer discarded what
|
|
144
|
+
* was waiting and before it cuts the socket. The owner removes the
|
|
145
|
+
* connection and logs.
|
|
146
|
+
*/
|
|
147
|
+
on_stall: (stall: WsOutboundStall) => void;
|
|
148
|
+
/** Logger for a socket that throws on a send or a cut; `null` silences it. */
|
|
149
|
+
log: Logger | null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The byte-budgeted outbound queue and write pump of one WebSocket connection
|
|
153
|
+
* (module doc).
|
|
154
|
+
*/
|
|
155
|
+
export declare class WsOutboundWriter {
|
|
156
|
+
#private;
|
|
157
|
+
constructor(options: WsOutboundWriterOptions);
|
|
158
|
+
/** Payload bytes waiting for the socket, without the per-frame overhead. */
|
|
159
|
+
get queued_bytes(): number;
|
|
160
|
+
/** Frames waiting for the socket. */
|
|
161
|
+
get queued_frames(): number;
|
|
162
|
+
/**
|
|
163
|
+
* Send `text` as one frame: straight to the socket when the connection is
|
|
164
|
+
* idle, else queued behind what waits when it fits the budget (module doc,
|
|
165
|
+
* "What waits"). Never waits and never throws.
|
|
166
|
+
*
|
|
167
|
+
* @param text - the frame's text
|
|
168
|
+
* @param bytes - the UTF-8 length of `text` when the caller has measured it —
|
|
169
|
+
* a fan-out measures one frame once for its recipients; measured here when
|
|
170
|
+
* omitted. Trusted, not checked against `text`: a wrong value corrupts the
|
|
171
|
+
* budget
|
|
172
|
+
* @returns `accepted`, `refused` once the writer has closed, or `overflow`
|
|
173
|
+
* when the frame did not fit — the caller closes the connection
|
|
174
|
+
* @mutates this - queues the frame or writes it to the socket, and schedules
|
|
175
|
+
* the pump
|
|
176
|
+
*/
|
|
177
|
+
push(text: string, bytes?: number): WsOutboundPush;
|
|
178
|
+
/**
|
|
179
|
+
* Close the socket with `code` and `reason`, and take nothing more.
|
|
180
|
+
*
|
|
181
|
+
* With `backlog: 'flush'` the frames still waiting are handed to the socket
|
|
182
|
+
* ahead of the close frame under the pump's rule — while the socket's buffer
|
|
183
|
+
* is below `WS_WRITE_HIGH_WATER_BYTES` — so a reply sent to an otherwise idle
|
|
184
|
+
* connection just before a close is not lost to it. What still waits after
|
|
185
|
+
* that is dropped, as all of it is with `'discard'` (module doc, "Closing").
|
|
186
|
+
* Either way the close frame gets `WS_CLOSE_WRITE_TIMEOUT` to be written,
|
|
187
|
+
* after which a socket still holding unwritten bytes is cut where the runtime
|
|
188
|
+
* can cut one. A no-op once the writer has closed.
|
|
189
|
+
*
|
|
190
|
+
* @param code - the close code
|
|
191
|
+
* @param reason - the close reason
|
|
192
|
+
* @param backlog - what to do with the frames still waiting
|
|
193
|
+
* @param on_settled - called once the close deadline has passed, unless
|
|
194
|
+
* `dispose` ran first
|
|
195
|
+
* @mutates this - empties the queue, stops the pump, starts the close deadline
|
|
196
|
+
* @throws whatever the socket's `close` throws, with the writer already closed
|
|
197
|
+
*/
|
|
198
|
+
close(code: number, reason: string, backlog: WsOutboundBacklog, on_settled?: () => void): void;
|
|
199
|
+
/**
|
|
200
|
+
* The socket is gone: drop what waits and stop every timer. Idempotent.
|
|
201
|
+
*
|
|
202
|
+
* @mutates this - empties the queue and clears the pump and deadline timers
|
|
203
|
+
*/
|
|
204
|
+
dispose(): void;
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=ws_outbound_writer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws_outbound_writer.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/ws_outbound_writer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AAOvF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,QAAS,CAAC;AAE7C;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,OAAQ,CAAC;AAE5C;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,QAAY,CAAC;AAQnD,kDAAkD;AAClD,MAAM,MAAM,cAAc;AACzB,yDAAyD;AACvD;IAAE,IAAI,EAAE,UAAU,CAAA;CAAE;AACtB,8EAA8E;GAC5E;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE;AACrB,2DAA2D;GACzD;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,gBAAgB,CAAA;CAAE,CAAC;AAEpD,qEAAqE;AACrE,MAAM,WAAW,eAAe;IAC/B,sDAAsD;IACtD,YAAY,EAAE,MAAM,CAAC;IACrB,+CAA+C;IAC/C,aAAa,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,cAAc,EAAE,MAAM,CAAC;CACvB;AAED,wEAAwE;AACxE,MAAM,MAAM,iBAAiB;AAC5B;;;GAGG;AACD,OAAO;AACT,qBAAqB;GACnB,SAAS,CAAC;AAEb,sCAAsC;AACtC,MAAM,WAAW,uBAAuB;IACvC,8BAA8B;IAC9B,EAAE,EAAE,SAAS,CAAC;IACd,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,QAAQ,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IAC3C,8EAA8E;IAC9E,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CACnB;AA2CD;;;GAGG;AACH,qBAAa,gBAAgB;;gBA+BhB,OAAO,EAAE,uBAAuB;IAO5C,4EAA4E;IAC5E,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,qCAAqC;IACrC,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED;;;;;;;;;;;;;;OAcG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,cAAc;IAyBlD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,EAAE,UAAU,CAAC,EAAE,MAAM,IAAI,GAAG,IAAI;IA4B9F;;;;OAIG;IACH,OAAO,IAAI,IAAI;CAoJf"}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbound side of one server WebSocket connection — a byte-budgeted
|
|
3
|
+
* queue of frames, the pump that hands them to the socket, and the deadlines
|
|
4
|
+
* on what the socket has not yet written.
|
|
5
|
+
*
|
|
6
|
+
* Every frame the server sends a connection goes through
|
|
7
|
+
* `WsOutboundWriter.push`: a response, an error frame, a notification, a
|
|
8
|
+
* broadcast, a server-initiated request. `BackendWebsocketTransport` owns one
|
|
9
|
+
* writer per connection and is its only caller.
|
|
10
|
+
*
|
|
11
|
+
* ## What waits
|
|
12
|
+
*
|
|
13
|
+
* A frame pushed at an idle connection — nothing waiting, nothing unwritten in
|
|
14
|
+
* the runtime's socket buffer, and no other frame pushed in the same
|
|
15
|
+
* synchronous run — goes straight to the socket. Every other frame **waits**
|
|
16
|
+
* in the writer's queue: the rest of a burst pushed in one synchronous run
|
|
17
|
+
* (a loop with no `await` in it gives the pump no turn), and anything pushed
|
|
18
|
+
* while the socket's buffer is at or over `WS_WRITE_HIGH_WATER_BYTES`. The
|
|
19
|
+
* pump hands waiting frames over in order — on the microtask after a push, and
|
|
20
|
+
* whenever the buffer has drained below the high-water mark.
|
|
21
|
+
*
|
|
22
|
+
* What waits is held to the connection's budget (`OutboundBudget`): each
|
|
23
|
+
* waiting frame is charged its UTF-8 length plus
|
|
24
|
+
* `OUTBOUND_FRAME_OVERHEAD_BYTES`, and a frame is always admitted when nothing
|
|
25
|
+
* is waiting. A frame handed to the socket is no longer charged — the budget
|
|
26
|
+
* bounds what waits, and the write deadline bounds what is in flight.
|
|
27
|
+
*
|
|
28
|
+
* A frame that does not fit is reported as an overflow and not queued; the
|
|
29
|
+
* owner closes the connection for it (`WS_CLOSE_OUTBOUND_OVERFLOW`).
|
|
30
|
+
*
|
|
31
|
+
* The queue is a linked list: a frame handed to the socket is unlinked, so the
|
|
32
|
+
* writer holds the frames that wait and no others, however long the queue goes
|
|
33
|
+
* without emptying.
|
|
34
|
+
*
|
|
35
|
+
* ## Closing
|
|
36
|
+
*
|
|
37
|
+
* `close` sends the close frame and takes nothing more. With `'flush'` it
|
|
38
|
+
* first runs the pump's rule once: waiting frames are handed to the socket
|
|
39
|
+
* while its buffer is below `WS_WRITE_HIGH_WATER_BYTES`. Whatever still waits
|
|
40
|
+
* is discarded, with either backlog choice. So a frame sent to an otherwise
|
|
41
|
+
* idle connection just before a close reaches the client ahead of the close
|
|
42
|
+
* frame, and a frame queued behind a backed-up socket does not. After a close
|
|
43
|
+
* a socket whose buffer the writer can read ("Runtimes") holds less than the
|
|
44
|
+
* high-water mark plus one frame, and the close frame.
|
|
45
|
+
*
|
|
46
|
+
* ## Deadlines
|
|
47
|
+
*
|
|
48
|
+
* - **A frame**: bytes the socket's buffer still holds
|
|
49
|
+
* `WS_WRITE_STALL_TIMEOUT` after they were handed to it mean the client is
|
|
50
|
+
* not reading. The writer discards what waits, reports the stall, and cuts
|
|
51
|
+
* the socket with no close frame (the client sees an abnormal close, 1006).
|
|
52
|
+
* - **A close frame**: a close still unwritten `WS_CLOSE_WRITE_TIMEOUT` after
|
|
53
|
+
* `close` cuts the socket the same way, so a closed connection whose client
|
|
54
|
+
* stopped reading does not hold its buffered frames for the runtime's own
|
|
55
|
+
* close timeout — where the runtime can cut a socket ("Runtimes").
|
|
56
|
+
*
|
|
57
|
+
* ## Runtimes
|
|
58
|
+
*
|
|
59
|
+
* The buffer is read through the socket Hono's `WSContext` wraps (`raw`):
|
|
60
|
+
* `bufferedAmount` on Node's `ws` and on Deno's standard `WebSocket`,
|
|
61
|
+
* `getBufferedAmount()` on Bun's `ServerWebSocket`. A socket that exposes
|
|
62
|
+
* neither reads as never backed up: its frames are still budgeted while they
|
|
63
|
+
* wait for the pump, but nothing detects a stall. The cut is `terminate()`,
|
|
64
|
+
* which Node's `ws` and Bun's `ServerWebSocket` have. Deno's standard
|
|
65
|
+
* `WebSocket` has no way to drop a socket without the closing handshake, so
|
|
66
|
+
* there a stalled connection is closed with `WS_CLOSE_OUTBOUND_OVERFLOW`
|
|
67
|
+
* instead — a frame the client reads only if it starts reading again — and
|
|
68
|
+
* the close deadline cuts nothing: a closed socket whose client is not reading
|
|
69
|
+
* stays in the runtime, holding what its buffer held at the close ("Closing"),
|
|
70
|
+
* until the runtime or the client ends it.
|
|
71
|
+
*
|
|
72
|
+
* @module
|
|
73
|
+
*/
|
|
74
|
+
import { OutboundBudget } from '../realtime/outbound_budget.js';
|
|
75
|
+
import { WS_CLOSE_OUTBOUND_OVERFLOW, WS_CLOSE_OUTBOUND_OVERFLOW_REASON, utf8_length } from './transports.js';
|
|
76
|
+
/**
|
|
77
|
+
* How long bytes handed to a socket may stay unwritten in its buffer before
|
|
78
|
+
* the connection is dropped (module doc, "Deadlines"). Equal to
|
|
79
|
+
* `DEFAULT_PEER_REQUEST_TIMEOUT`: a server-initiated request that cannot even
|
|
80
|
+
* be written within its own reply deadline has already failed. Same value as
|
|
81
|
+
* the Rust spine's `WS_WRITE_STALL_TIMEOUT`.
|
|
82
|
+
*
|
|
83
|
+
* The deadline covers the bytes in the buffer when it starts, not progress: a
|
|
84
|
+
* client taking them too slowly to finish inside it is dropped the same way,
|
|
85
|
+
* and reconnects.
|
|
86
|
+
*/
|
|
87
|
+
export const WS_WRITE_STALL_TIMEOUT = 10_000;
|
|
88
|
+
/**
|
|
89
|
+
* How long a close frame may stay unwritten before the socket is cut without
|
|
90
|
+
* it (module doc, "Deadlines"). Same value as the Rust spine's bound on the
|
|
91
|
+
* same write.
|
|
92
|
+
*/
|
|
93
|
+
export const WS_CLOSE_WRITE_TIMEOUT = 1_000;
|
|
94
|
+
/**
|
|
95
|
+
* How many unwritten bytes a socket's buffer may hold before the pump stops
|
|
96
|
+
* handing it waiting frames. Below it the pump keeps writing, so a runtime
|
|
97
|
+
* that reports a frame as buffered until its write completes (Deno) is not
|
|
98
|
+
* held to one frame per turn; at or over it frames wait in the budgeted queue
|
|
99
|
+
* instead of the runtime's unbounded buffer.
|
|
100
|
+
*/
|
|
101
|
+
export const WS_WRITE_HIGH_WATER_BYTES = 64 * 1024;
|
|
102
|
+
/** The longest wait between two reads of a backed-up socket's buffer. */
|
|
103
|
+
const DRAIN_POLL_MAX_DELAY = 32;
|
|
104
|
+
/** `WebSocket.CLOSED`. */
|
|
105
|
+
const READY_STATE_CLOSED = 3;
|
|
106
|
+
/** A timer that does not keep the process alive, where the runtime allows. */
|
|
107
|
+
const set_unref_timeout = (fn, ms) => {
|
|
108
|
+
const timer = setTimeout(fn, ms);
|
|
109
|
+
if (typeof timer === 'object' && 'unref' in timer)
|
|
110
|
+
timer.unref();
|
|
111
|
+
return timer;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* The bytes handed to `ws` that its runtime has not yet written — `0` for a
|
|
115
|
+
* socket that does not say.
|
|
116
|
+
*/
|
|
117
|
+
const read_buffered_bytes = (ws) => {
|
|
118
|
+
const raw = ws.raw;
|
|
119
|
+
if (typeof raw !== 'object' || raw === null)
|
|
120
|
+
return 0;
|
|
121
|
+
if (typeof raw.getBufferedAmount === 'function') {
|
|
122
|
+
const buffered = raw.getBufferedAmount.call(raw);
|
|
123
|
+
return typeof buffered === 'number' ? buffered : 0;
|
|
124
|
+
}
|
|
125
|
+
return typeof raw.bufferedAmount === 'number' ? raw.bufferedAmount : 0;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Drop `ws` without a closing handshake, where its runtime can.
|
|
129
|
+
*
|
|
130
|
+
* @returns whether the socket was cut
|
|
131
|
+
*/
|
|
132
|
+
const terminate_socket = (ws) => {
|
|
133
|
+
const raw = ws.raw;
|
|
134
|
+
if (typeof raw !== 'object' || raw === null || typeof raw.terminate !== 'function')
|
|
135
|
+
return false;
|
|
136
|
+
raw.terminate.call(raw);
|
|
137
|
+
return true;
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* The byte-budgeted outbound queue and write pump of one WebSocket connection
|
|
141
|
+
* (module doc).
|
|
142
|
+
*/
|
|
143
|
+
export class WsOutboundWriter {
|
|
144
|
+
#ws;
|
|
145
|
+
#budget;
|
|
146
|
+
#on_stall;
|
|
147
|
+
#log;
|
|
148
|
+
// `open` takes frames; `closing` has sent its close and waits out the close
|
|
149
|
+
// deadline; `ended` is done with the socket
|
|
150
|
+
#state = 'open';
|
|
151
|
+
// the frames waiting, oldest first: a frame is unlinked as it is handed to
|
|
152
|
+
// the socket, so nothing here refers to a frame that is not waiting
|
|
153
|
+
#head = null;
|
|
154
|
+
#tail = null;
|
|
155
|
+
// a pump is scheduled for the end of the current synchronous run — which is
|
|
156
|
+
// also what marks a frame as already written through in this run
|
|
157
|
+
#pump_pending = false;
|
|
158
|
+
#poll_timer = null;
|
|
159
|
+
#poll_delay = 1;
|
|
160
|
+
#last_buffered = 0;
|
|
161
|
+
// The write deadline covers the bytes in the buffer when it starts: it is
|
|
162
|
+
// met once the buffer holds no more than what was handed over since.
|
|
163
|
+
#stall_timer = null;
|
|
164
|
+
#handed_since_stall_start = 0;
|
|
165
|
+
#close_timer = null;
|
|
166
|
+
#on_close_settled = null;
|
|
167
|
+
constructor(options) {
|
|
168
|
+
this.#ws = options.ws;
|
|
169
|
+
this.#budget = new OutboundBudget(options.max_bytes);
|
|
170
|
+
this.#on_stall = options.on_stall;
|
|
171
|
+
this.#log = options.log;
|
|
172
|
+
}
|
|
173
|
+
/** Payload bytes waiting for the socket, without the per-frame overhead. */
|
|
174
|
+
get queued_bytes() {
|
|
175
|
+
return this.#budget.queued_bytes;
|
|
176
|
+
}
|
|
177
|
+
/** Frames waiting for the socket. */
|
|
178
|
+
get queued_frames() {
|
|
179
|
+
return this.#budget.queued_frames;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Send `text` as one frame: straight to the socket when the connection is
|
|
183
|
+
* idle, else queued behind what waits when it fits the budget (module doc,
|
|
184
|
+
* "What waits"). Never waits and never throws.
|
|
185
|
+
*
|
|
186
|
+
* @param text - the frame's text
|
|
187
|
+
* @param bytes - the UTF-8 length of `text` when the caller has measured it —
|
|
188
|
+
* a fan-out measures one frame once for its recipients; measured here when
|
|
189
|
+
* omitted. Trusted, not checked against `text`: a wrong value corrupts the
|
|
190
|
+
* budget
|
|
191
|
+
* @returns `accepted`, `refused` once the writer has closed, or `overflow`
|
|
192
|
+
* when the frame did not fit — the caller closes the connection
|
|
193
|
+
* @mutates this - queues the frame or writes it to the socket, and schedules
|
|
194
|
+
* the pump
|
|
195
|
+
*/
|
|
196
|
+
push(text, bytes) {
|
|
197
|
+
if (this.#state !== 'open')
|
|
198
|
+
return { kind: 'refused' };
|
|
199
|
+
if (!this.#pump_pending && this.#head === null && read_buffered_bytes(this.#ws) === 0) {
|
|
200
|
+
// idle, and everything handed over earlier is written
|
|
201
|
+
this.#clear_stall_timer();
|
|
202
|
+
this.#schedule_pump();
|
|
203
|
+
if (!this.#write(text))
|
|
204
|
+
return { kind: 'refused' };
|
|
205
|
+
// TODO: a runtime that reports a frame as buffered until its write
|
|
206
|
+
// completes (Deno) arms the deadline and the poll here and clears both
|
|
207
|
+
// a tick later, for each idle send
|
|
208
|
+
this.#watch(read_buffered_bytes(this.#ws));
|
|
209
|
+
return { kind: 'accepted' };
|
|
210
|
+
}
|
|
211
|
+
const frame_bytes = bytes ?? utf8_length(text);
|
|
212
|
+
if (!this.#budget.admit(frame_bytes)) {
|
|
213
|
+
return { kind: 'overflow', overflow: this.#budget.to_overflow(frame_bytes) };
|
|
214
|
+
}
|
|
215
|
+
const frame = { text, bytes: frame_bytes, next: null };
|
|
216
|
+
if (this.#tail === null)
|
|
217
|
+
this.#head = frame;
|
|
218
|
+
else
|
|
219
|
+
this.#tail.next = frame;
|
|
220
|
+
this.#tail = frame;
|
|
221
|
+
this.#schedule_pump();
|
|
222
|
+
return { kind: 'accepted' };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Close the socket with `code` and `reason`, and take nothing more.
|
|
226
|
+
*
|
|
227
|
+
* With `backlog: 'flush'` the frames still waiting are handed to the socket
|
|
228
|
+
* ahead of the close frame under the pump's rule — while the socket's buffer
|
|
229
|
+
* is below `WS_WRITE_HIGH_WATER_BYTES` — so a reply sent to an otherwise idle
|
|
230
|
+
* connection just before a close is not lost to it. What still waits after
|
|
231
|
+
* that is dropped, as all of it is with `'discard'` (module doc, "Closing").
|
|
232
|
+
* Either way the close frame gets `WS_CLOSE_WRITE_TIMEOUT` to be written,
|
|
233
|
+
* after which a socket still holding unwritten bytes is cut where the runtime
|
|
234
|
+
* can cut one. A no-op once the writer has closed.
|
|
235
|
+
*
|
|
236
|
+
* @param code - the close code
|
|
237
|
+
* @param reason - the close reason
|
|
238
|
+
* @param backlog - what to do with the frames still waiting
|
|
239
|
+
* @param on_settled - called once the close deadline has passed, unless
|
|
240
|
+
* `dispose` ran first
|
|
241
|
+
* @mutates this - empties the queue, stops the pump, starts the close deadline
|
|
242
|
+
* @throws whatever the socket's `close` throws, with the writer already closed
|
|
243
|
+
*/
|
|
244
|
+
close(code, reason, backlog, on_settled) {
|
|
245
|
+
if (this.#state !== 'open')
|
|
246
|
+
return;
|
|
247
|
+
this.#state = 'closing';
|
|
248
|
+
this.#clear_poll_timer();
|
|
249
|
+
this.#clear_stall_timer();
|
|
250
|
+
// a socket that throws on a send is not offered the frames behind it
|
|
251
|
+
if (backlog === 'flush')
|
|
252
|
+
this.#hand_over(read_buffered_bytes(this.#ws), true);
|
|
253
|
+
// what the socket did not take is not delivered
|
|
254
|
+
this.#discard_waiting();
|
|
255
|
+
this.#on_close_settled = on_settled ?? null;
|
|
256
|
+
this.#close_timer = set_unref_timeout(() => {
|
|
257
|
+
this.#close_timer = null;
|
|
258
|
+
this.#state = 'ended';
|
|
259
|
+
const settled = this.#on_close_settled;
|
|
260
|
+
this.#on_close_settled = null;
|
|
261
|
+
try {
|
|
262
|
+
if (this.#ws.readyState !== READY_STATE_CLOSED && read_buffered_bytes(this.#ws) > 0) {
|
|
263
|
+
// the client is not reading: the close frame cannot be written
|
|
264
|
+
terminate_socket(this.#ws);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
this.#log?.error('error cutting a socket past its close deadline:', error);
|
|
269
|
+
}
|
|
270
|
+
settled?.();
|
|
271
|
+
}, WS_CLOSE_WRITE_TIMEOUT);
|
|
272
|
+
this.#ws.close(code, reason);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The socket is gone: drop what waits and stop every timer. Idempotent.
|
|
276
|
+
*
|
|
277
|
+
* @mutates this - empties the queue and clears the pump and deadline timers
|
|
278
|
+
*/
|
|
279
|
+
dispose() {
|
|
280
|
+
this.#state = 'ended';
|
|
281
|
+
this.#clear_poll_timer();
|
|
282
|
+
this.#clear_stall_timer();
|
|
283
|
+
if (this.#close_timer !== null) {
|
|
284
|
+
clearTimeout(this.#close_timer);
|
|
285
|
+
this.#close_timer = null;
|
|
286
|
+
}
|
|
287
|
+
this.#on_close_settled = null;
|
|
288
|
+
this.#discard_waiting();
|
|
289
|
+
}
|
|
290
|
+
/** Hand one frame to the socket. `false` when the socket threw. */
|
|
291
|
+
#write(text) {
|
|
292
|
+
try {
|
|
293
|
+
this.#ws.send(text);
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
this.#log?.error('error sending to client:', error);
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
#discard_waiting() {
|
|
302
|
+
this.#head = null;
|
|
303
|
+
this.#tail = null;
|
|
304
|
+
this.#budget.clear();
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Hand waiting frames to the socket, oldest first, while its buffer is below
|
|
308
|
+
* the high-water mark — the one rule for what leaves the queue, at a pump
|
|
309
|
+
* turn and at a close.
|
|
310
|
+
*
|
|
311
|
+
* @param buffered_before - the socket's unwritten bytes as last read
|
|
312
|
+
* @param stop_when_refused - stop at the first frame the socket throws on
|
|
313
|
+
* @returns the socket's unwritten bytes after the last frame handed over
|
|
314
|
+
*/
|
|
315
|
+
#hand_over(buffered_before, stop_when_refused) {
|
|
316
|
+
let buffered = buffered_before;
|
|
317
|
+
let frame = this.#head;
|
|
318
|
+
while (frame !== null && buffered < WS_WRITE_HIGH_WATER_BYTES) {
|
|
319
|
+
// unlinked before the write, so the queue never refers to a frame the
|
|
320
|
+
// socket has
|
|
321
|
+
this.#head = frame.next;
|
|
322
|
+
if (this.#head === null)
|
|
323
|
+
this.#tail = null;
|
|
324
|
+
this.#budget.release(frame.bytes);
|
|
325
|
+
this.#handed_since_stall_start += frame.bytes;
|
|
326
|
+
if (!this.#write(frame.text) && stop_when_refused)
|
|
327
|
+
break;
|
|
328
|
+
buffered = read_buffered_bytes(this.#ws);
|
|
329
|
+
frame = this.#head;
|
|
330
|
+
}
|
|
331
|
+
return buffered;
|
|
332
|
+
}
|
|
333
|
+
#schedule_pump() {
|
|
334
|
+
if (this.#pump_pending)
|
|
335
|
+
return;
|
|
336
|
+
this.#pump_pending = true;
|
|
337
|
+
queueMicrotask(() => {
|
|
338
|
+
this.#pump_pending = false;
|
|
339
|
+
if (this.#state === 'open')
|
|
340
|
+
this.#drain();
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
/** Hand waiting frames to the socket while its buffer is below the high-water mark. */
|
|
344
|
+
#drain() {
|
|
345
|
+
let buffered = read_buffered_bytes(this.#ws);
|
|
346
|
+
if (this.#stall_timer !== null && buffered <= this.#handed_since_stall_start) {
|
|
347
|
+
// what the deadline covered is written
|
|
348
|
+
this.#clear_stall_timer();
|
|
349
|
+
}
|
|
350
|
+
const waiting = this.#budget.queued_frames;
|
|
351
|
+
buffered = this.#hand_over(buffered, false);
|
|
352
|
+
// progress: read the buffer again soon
|
|
353
|
+
if (this.#budget.queued_frames !== waiting)
|
|
354
|
+
this.#poll_delay = 1;
|
|
355
|
+
this.#watch(buffered);
|
|
356
|
+
}
|
|
357
|
+
/** Keep the write deadline and the buffer poll running while anything is unwritten. */
|
|
358
|
+
#watch(buffered) {
|
|
359
|
+
if (buffered > 0 && this.#stall_timer === null) {
|
|
360
|
+
this.#handed_since_stall_start = 0;
|
|
361
|
+
this.#stall_timer = set_unref_timeout(() => this.#stall(), WS_WRITE_STALL_TIMEOUT);
|
|
362
|
+
}
|
|
363
|
+
if (buffered === 0 && this.#head === null) {
|
|
364
|
+
this.#clear_poll_timer();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (buffered < this.#last_buffered)
|
|
368
|
+
this.#poll_delay = 1;
|
|
369
|
+
this.#last_buffered = buffered;
|
|
370
|
+
if (this.#poll_timer !== null)
|
|
371
|
+
return;
|
|
372
|
+
this.#poll_timer = set_unref_timeout(() => {
|
|
373
|
+
this.#poll_timer = null;
|
|
374
|
+
if (this.#state !== 'open')
|
|
375
|
+
return;
|
|
376
|
+
this.#poll_delay = Math.min(this.#poll_delay * 2, DRAIN_POLL_MAX_DELAY);
|
|
377
|
+
this.#drain();
|
|
378
|
+
}, this.#poll_delay);
|
|
379
|
+
}
|
|
380
|
+
/** The write deadline passed: discard, report, and cut the socket. */
|
|
381
|
+
#stall() {
|
|
382
|
+
this.#stall_timer = null;
|
|
383
|
+
if (this.#state !== 'open')
|
|
384
|
+
return;
|
|
385
|
+
this.#state = 'ended';
|
|
386
|
+
this.#clear_poll_timer();
|
|
387
|
+
let buffered_bytes = 0;
|
|
388
|
+
try {
|
|
389
|
+
buffered_bytes = read_buffered_bytes(this.#ws);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// a socket that cannot be read is cut all the same
|
|
393
|
+
}
|
|
394
|
+
const stall = {
|
|
395
|
+
queued_bytes: this.#budget.queued_bytes,
|
|
396
|
+
queued_frames: this.#budget.queued_frames,
|
|
397
|
+
buffered_bytes
|
|
398
|
+
};
|
|
399
|
+
this.#discard_waiting();
|
|
400
|
+
try {
|
|
401
|
+
this.#on_stall(stall);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
this.#log?.error('error reporting a stalled socket:', error);
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
if (!terminate_socket(this.#ws)) {
|
|
408
|
+
// no way to drop the socket on this runtime (module doc, "Runtimes")
|
|
409
|
+
this.#ws.close(WS_CLOSE_OUTBOUND_OVERFLOW, WS_CLOSE_OUTBOUND_OVERFLOW_REASON);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
this.#log?.error('error cutting a stalled socket:', error);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
#clear_poll_timer() {
|
|
417
|
+
if (this.#poll_timer !== null) {
|
|
418
|
+
clearTimeout(this.#poll_timer);
|
|
419
|
+
this.#poll_timer = null;
|
|
420
|
+
}
|
|
421
|
+
this.#poll_delay = 1;
|
|
422
|
+
this.#last_buffered = 0;
|
|
423
|
+
}
|
|
424
|
+
#clear_stall_timer() {
|
|
425
|
+
if (this.#stall_timer !== null) {
|
|
426
|
+
clearTimeout(this.#stall_timer);
|
|
427
|
+
this.#stall_timer = null;
|
|
428
|
+
}
|
|
429
|
+
this.#handed_since_stall_start = 0;
|
|
430
|
+
}
|
|
431
|
+
}
|
package/dist/auth/CLAUDE.md
CHANGED
|
@@ -57,7 +57,7 @@ Cross-cutting notes that don't live on any single symbol:
|
|
|
57
57
|
Convention — `*_schema.ts` is Zod-only; `*_ddl.ts` holds DDL strings.
|
|
58
58
|
|
|
59
59
|
- `auth/account_schema.ts` — `Account`, `Actor`, `RoleGrant`, `AuthSession`, `ApiToken` + client-safe JSON shapes. Grant predicates over wire fields: `is_role_grant_active` (not revoked, not expired) and `has_global_role(role_grants, role, now?)` (an active grant of the role with `scope_id === null` — the bare-list form of `has_scoped_role(ctx, role, null)`, used client-side by `AuthState`).
|
|
60
|
-
- `auth/role_schema.ts` — `RoleName
|
|
60
|
+
- `auth/role_schema.ts` — `RoleName` (lowercase letters and underscores, no leading or trailing underscore, at most `ROLE_NAME_LENGTH_MAX` = 64 characters — the grammar every `role` input and every registered role holds, twin of the Rust `is_valid_role_name`), `RoleSpec`, `ROLE_KEEPER`, `ROLE_ADMIN`, `is_builtin_role`, `create_role_schema`, `builtin_role_specs_by_name`, `role_has_grant_path`, `list_roles_with_grant_path`.
|
|
61
61
|
- `auth/scope_kind_schema.ts` — `ScopeKindName`, `create_scope_kind_schema` (open registry, no builtins).
|
|
62
62
|
- `auth/credential_type_schema.ts` — `CredentialTypeName`, `CREDENTIAL_TYPE_SESSION` / `_API_TOKEN` / `_DAEMON_TOKEN`, `create_credential_type_schema`.
|
|
63
63
|
- `auth/grant_path_schema.ts` — `GrantPathName`, `GRANT_PATH_ADMIN` / `_SELF_SERVICE` / `_SYSTEM` / `_BOOTSTRAP`, `create_grant_path_schema`.
|
|
@@ -296,7 +296,7 @@ auth, ids)`: batched strict relation-read filter. Every relation read —
|
|
|
296
296
|
The authorizer is **pure** — the handler reads the governing root's `data`
|
|
297
297
|
in-tx and hands it over as `root_data`, so a directory policy resolves
|
|
298
298
|
`root.data.policy[kind]` without its own DB read (which also dodges the
|
|
299
|
-
single-connection
|
|
299
|
+
single-connection deadlock of the in-memory drivers). `None`/omitted = open create (the default;
|
|
300
300
|
mirrors `validate_data`). `cell_moderate` is the `pending → approved | rejected`
|
|
301
301
|
transition, gated on `can_manage_cell` of the **governing root** (admin / root
|
|
302
302
|
owner — the author lands a 403, the anti-self-approval guard); approve flips
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit_log_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_log_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAUvD,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAU7E,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACpC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE;QACR,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;QAC9C,GAAG,EAAE,MAAM,CAAC;KACZ,CAAC;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,4BAA4B,GAAI,UAAU,oBAAoB,KAAG,KAAK,CAAC,SAAS,
|
|
1
|
+
{"version":3,"file":"audit_log_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_log_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAUvD,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAU7E,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACpC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE;QACR,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;QAC9C,GAAG,EAAE,MAAM,CAAC;KACZ,CAAC;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,4BAA4B,GAAI,UAAU,oBAAoB,KAAG,KAAK,CAAC,SAAS,CAsG5F,CAAC"}
|
|
@@ -134,7 +134,9 @@ export const create_audit_log_route_specs = (options) => {
|
|
|
134
134
|
// left during the re-reads gets a stream that is already closed,
|
|
135
135
|
// whose listener runs at once and removes the registration, so
|
|
136
136
|
// `admit` refuses it rather than evicting a live stream for it.
|
|
137
|
-
const { response, stream } = create_sse_response(c, log
|
|
137
|
+
const { response, stream } = create_sse_response(c, log, {
|
|
138
|
+
account_id: ctx.account.id
|
|
139
|
+
});
|
|
138
140
|
stream.on_close(pending.unsubscribe);
|
|
139
141
|
admitted = registry.admit(pending, stream);
|
|
140
142
|
if (!admitted) {
|