@floegence/flowersec-core 0.20.0 → 0.20.2

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.
@@ -1,14 +1,16 @@
1
- import { PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE, PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE, PROXY_WINDOW_STREAM_END_MSG_TYPE, PROXY_WINDOW_STREAM_RESET_MSG_TYPE, } from "./windowBridgeProtocol.js";
1
+ import { PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE, PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE, PROXY_WINDOW_STREAM_END_MSG_TYPE, PROXY_WINDOW_STREAM_RESET_MSG_TYPE, PROXY_WINDOW_STREAM_WRITE_ACK_MSG_TYPE, } from "./windowBridgeProtocol.js";
2
2
  function cloneChunk(chunk) {
3
3
  const out = new Uint8Array(chunk.byteLength);
4
4
  out.set(chunk);
5
5
  return out.buffer;
6
6
  }
7
- export function createMessagePortBackedStream(port) {
7
+ export function createMessagePortBackedStream(port, opts = {}) {
8
8
  let closed = false;
9
9
  let error = null;
10
10
  const queue = [];
11
11
  const waiters = [];
12
+ const pendingWrites = new Map();
13
+ let nextWriteId = 1;
12
14
  const resolveWaiter = (value) => {
13
15
  const waiter = waiters.shift();
14
16
  if (waiter) {
@@ -29,6 +31,9 @@ export function createMessagePortBackedStream(port) {
29
31
  while (resolveWaiter(err)) {
30
32
  // Drain waiters.
31
33
  }
34
+ for (const pending of pendingWrites.values())
35
+ pending.reject(err);
36
+ pendingWrites.clear();
32
37
  };
33
38
  port.onmessage = (ev) => {
34
39
  const data = ev.data;
@@ -46,8 +51,22 @@ export function createMessagePortBackedStream(port) {
46
51
  case PROXY_WINDOW_STREAM_END_MSG_TYPE:
47
52
  case PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE:
48
53
  closed = true;
54
+ for (const pending of pendingWrites.values())
55
+ pending.reject(new Error("stream is closed"));
56
+ pendingWrites.clear();
49
57
  pushValue(null);
50
58
  return;
59
+ case PROXY_WINDOW_STREAM_WRITE_ACK_MSG_TYPE: {
60
+ const writeId = data.writeId;
61
+ if (!Number.isSafeInteger(writeId) || writeId <= 0)
62
+ return;
63
+ const pending = pendingWrites.get(writeId);
64
+ if (pending == null)
65
+ return;
66
+ pendingWrites.delete(writeId);
67
+ pending.resolve();
68
+ return;
69
+ }
51
70
  case PROXY_WINDOW_STREAM_RESET_MSG_TYPE: {
52
71
  closed = true;
53
72
  const message = String(data.message ?? "stream reset");
@@ -87,12 +106,30 @@ export function createMessagePortBackedStream(port) {
87
106
  if (closed)
88
107
  throw new Error("stream is closed");
89
108
  const ab = cloneChunk(chunk);
90
- port.postMessage({ type: PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE, data: ab }, [ab]);
109
+ if (opts.writeAcknowledgements !== true) {
110
+ port.postMessage({ type: PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE, data: ab }, [ab]);
111
+ return;
112
+ }
113
+ const writeId = nextWriteId++;
114
+ await new Promise((resolve, reject) => {
115
+ pendingWrites.set(writeId, { resolve, reject });
116
+ try {
117
+ port.postMessage({ type: PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE, data: ab, writeId }, [ab]);
118
+ }
119
+ catch (error) {
120
+ pendingWrites.delete(writeId);
121
+ reject(error instanceof Error ? error : new Error(String(error)));
122
+ }
123
+ });
91
124
  },
92
125
  async close() {
93
126
  if (closed)
94
127
  return;
95
128
  closed = true;
129
+ const closeError = new Error("stream is closed");
130
+ for (const pending of pendingWrites.values())
131
+ pending.reject(closeError);
132
+ pendingWrites.clear();
96
133
  try {
97
134
  port.postMessage({ type: PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE });
98
135
  }
@@ -15,6 +15,10 @@ export type ProxyRuntimeLimits = Readonly<{
15
15
  maxChunkBytes: number;
16
16
  maxBodyBytes: number;
17
17
  maxWsFrameBytes: number;
18
+ maxWsBufferedAmountBytes: number;
19
+ maxConcurrentHttpStreams: number;
20
+ maxQueuedHttpRequests: number;
21
+ maxQueuedHttpBodyBytes: number;
18
22
  }>;
19
23
  export type ProxyRuntime = Readonly<{
20
24
  limits: ProxyRuntimeLimits;
@@ -40,6 +44,10 @@ export type ProxyRuntimeOptions = Readonly<{
40
44
  maxChunkBytes?: number;
41
45
  maxBodyBytes?: number;
42
46
  maxWsFrameBytes?: number;
47
+ maxWsBufferedAmountBytes?: number;
48
+ maxConcurrentHttpStreams?: number;
49
+ maxQueuedHttpRequests?: number;
50
+ maxQueuedHttpBodyBytes?: number;
43
51
  timeoutMs?: number;
44
52
  extraRequestHeaders?: readonly string[];
45
53
  extraResponseHeaders?: readonly string[];
@@ -2,6 +2,7 @@ import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../
2
2
  import { createByteReader } from "../streamio/index.js";
3
3
  import { base64urlEncode } from "../utils/base64url.js";
4
4
  import { readU32be, u32be } from "../utils/bin.js";
5
+ import { AbortError, FlowersecError, isFlowersecError } from "../utils/errors.js";
5
6
  import { CookieJar } from "./cookieJar.js";
6
7
  import { DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_CHUNK_BYTES, DEFAULT_MAX_WS_FRAME_BYTES, PROXY_KIND_HTTP1, PROXY_KIND_WS, PROXY_PROTOCOL_VERSION } from "./constants.js";
7
8
  import { filterRequestHeaders, filterResponseHeaders, filterWsOpenHeaders } from "./headerPolicy.js";
@@ -129,6 +130,141 @@ function normalizeMaxBytes(name, v, defaultValue) {
129
130
  return defaultValue;
130
131
  return n;
131
132
  }
133
+ const DEFAULT_MAX_CONCURRENT_HTTP_STREAMS = 24;
134
+ const DEFAULT_MAX_QUEUED_HTTP_REQUESTS = 128;
135
+ const DEFAULT_MAX_QUEUED_HTTP_BODY_BYTES = 64 * (1 << 20);
136
+ const DEFAULT_MAX_WS_BUFFERED_AMOUNT_BYTES = 4 * (1 << 20);
137
+ function normalizePositiveLimit(name, value, defaultValue) {
138
+ if (value == null)
139
+ return defaultValue;
140
+ if (!Number.isFinite(value) || !Number.isSafeInteger(value) || value <= 0) {
141
+ throw new Error(`${name} must be a positive safe integer`);
142
+ }
143
+ return value;
144
+ }
145
+ function normalizeNonNegativeLimit(name, value, defaultValue) {
146
+ if (value == null)
147
+ return defaultValue;
148
+ if (!Number.isFinite(value) || !Number.isSafeInteger(value) || value < 0) {
149
+ throw new Error(`${name} must be a non-negative safe integer`);
150
+ }
151
+ return value;
152
+ }
153
+ class HttpStreamAdmission {
154
+ path;
155
+ maxConcurrent;
156
+ maxQueued;
157
+ maxQueuedBodyBytes;
158
+ active = 0;
159
+ pending = [];
160
+ pendingBodyBytes = 0;
161
+ closed = false;
162
+ constructor(path, maxConcurrent, maxQueued, maxQueuedBodyBytes) {
163
+ this.path = path;
164
+ this.maxConcurrent = maxConcurrent;
165
+ this.maxQueued = maxQueued;
166
+ this.maxQueuedBodyBytes = maxQueuedBodyBytes;
167
+ }
168
+ acquire(bodyBytes, signal) {
169
+ if (this.closed)
170
+ return Promise.reject(this.closedError());
171
+ if (signal?.aborted)
172
+ return Promise.reject(this.abortedError());
173
+ if (this.active < this.maxConcurrent && this.pending.length === 0) {
174
+ this.active++;
175
+ return Promise.resolve(this.createRelease());
176
+ }
177
+ if (this.pending.length >= this.maxQueued) {
178
+ return Promise.reject(new FlowersecError({
179
+ path: this.path,
180
+ stage: "yamux",
181
+ code: "resource_exhausted",
182
+ message: "proxy runtime HTTP request queue is full",
183
+ }));
184
+ }
185
+ if (this.pendingBodyBytes + bodyBytes > this.maxQueuedBodyBytes) {
186
+ return Promise.reject(new FlowersecError({
187
+ path: this.path,
188
+ stage: "yamux",
189
+ code: "resource_exhausted",
190
+ message: "proxy runtime HTTP request body queue is full",
191
+ }));
192
+ }
193
+ return new Promise((resolve, reject) => {
194
+ const waiter = {
195
+ bodyBytes,
196
+ resolve,
197
+ reject,
198
+ ...(signal === undefined ? {} : { signal }),
199
+ };
200
+ waiter.onAbort = () => {
201
+ const index = this.pending.indexOf(waiter);
202
+ if (index < 0)
203
+ return;
204
+ this.pending.splice(index, 1);
205
+ this.pendingBodyBytes = Math.max(0, this.pendingBodyBytes - waiter.bodyBytes);
206
+ this.cleanupWaiter(waiter);
207
+ reject(this.abortedError());
208
+ };
209
+ signal?.addEventListener("abort", waiter.onAbort, { once: true });
210
+ this.pending.push(waiter);
211
+ this.pendingBodyBytes += bodyBytes;
212
+ });
213
+ }
214
+ close() {
215
+ if (this.closed)
216
+ return;
217
+ this.closed = true;
218
+ for (const waiter of this.pending.splice(0)) {
219
+ this.pendingBodyBytes = Math.max(0, this.pendingBodyBytes - waiter.bodyBytes);
220
+ this.cleanupWaiter(waiter);
221
+ waiter.reject(this.closedError());
222
+ }
223
+ }
224
+ assertOpen() {
225
+ if (this.closed)
226
+ throw this.closedError();
227
+ }
228
+ createRelease() {
229
+ let released = false;
230
+ return () => {
231
+ if (released)
232
+ return;
233
+ released = true;
234
+ this.active = Math.max(0, this.active - 1);
235
+ this.drain();
236
+ };
237
+ }
238
+ drain() {
239
+ while (!this.closed && this.active < this.maxConcurrent && this.pending.length > 0) {
240
+ const waiter = this.pending.shift();
241
+ this.pendingBodyBytes = Math.max(0, this.pendingBodyBytes - waiter.bodyBytes);
242
+ this.cleanupWaiter(waiter);
243
+ if (waiter.signal?.aborted) {
244
+ waiter.reject(this.abortedError());
245
+ continue;
246
+ }
247
+ this.active++;
248
+ waiter.resolve(this.createRelease());
249
+ }
250
+ }
251
+ cleanupWaiter(waiter) {
252
+ if (waiter.onAbort != null) {
253
+ waiter.signal?.removeEventListener("abort", waiter.onAbort);
254
+ }
255
+ }
256
+ abortedError() {
257
+ return new AbortError("proxy HTTP request canceled while waiting for stream admission");
258
+ }
259
+ closedError() {
260
+ return new FlowersecError({
261
+ path: this.path,
262
+ stage: "close",
263
+ code: "not_connected",
264
+ message: "proxy runtime is disposed",
265
+ });
266
+ }
267
+ }
132
268
  async function writeChunkFrames(stream, body, chunkSize, maxBodyBytes) {
133
269
  if (maxBodyBytes > 0 && body.length > maxBodyBytes)
134
270
  throw new Error("request body too large");
@@ -169,6 +305,11 @@ export function createProxyRuntime(opts) {
169
305
  const maxChunkBytes = normalizeMaxBytes("maxChunkBytes", opts.maxChunkBytes, DEFAULT_MAX_CHUNK_BYTES);
170
306
  const maxBodyBytes = normalizeMaxBytes("maxBodyBytes", opts.maxBodyBytes, DEFAULT_MAX_BODY_BYTES);
171
307
  const maxWsFrameBytes = normalizeMaxBytes("maxWsFrameBytes", opts.maxWsFrameBytes, DEFAULT_MAX_WS_FRAME_BYTES);
308
+ const maxWsBufferedAmountBytes = normalizeMaxBytes("maxWsBufferedAmountBytes", opts.maxWsBufferedAmountBytes, DEFAULT_MAX_WS_BUFFERED_AMOUNT_BYTES);
309
+ const maxConcurrentHttpStreams = normalizePositiveLimit("maxConcurrentHttpStreams", opts.maxConcurrentHttpStreams, DEFAULT_MAX_CONCURRENT_HTTP_STREAMS);
310
+ const maxQueuedHttpRequests = normalizeNonNegativeLimit("maxQueuedHttpRequests", opts.maxQueuedHttpRequests, DEFAULT_MAX_QUEUED_HTTP_REQUESTS);
311
+ const maxQueuedHttpBodyBytes = normalizeNonNegativeLimit("maxQueuedHttpBodyBytes", opts.maxQueuedHttpBodyBytes, DEFAULT_MAX_QUEUED_HTTP_BODY_BYTES);
312
+ const httpStreamAdmission = new HttpStreamAdmission(client.path, maxConcurrentHttpStreams, maxQueuedHttpRequests, maxQueuedHttpBodyBytes);
172
313
  const timeoutMs = normalizeTimeoutMs(opts.timeoutMs);
173
314
  const extraRequestHeaders = opts.extraRequestHeaders ?? [];
174
315
  const extraResponseHeaders = opts.extraResponseHeaders ?? [];
@@ -203,6 +344,7 @@ export function createProxyRuntime(opts) {
203
344
  const dispatchFetch = (req, port) => {
204
345
  const ac = new AbortController();
205
346
  let stream = null;
347
+ let releaseAdmission = null;
206
348
  port.onmessage = (ev) => {
207
349
  const m = ev.data;
208
350
  if (m && typeof m === "object" && m.type === "flowersec-proxy:abort") {
@@ -215,6 +357,11 @@ export function createProxyRuntime(opts) {
215
357
  assertPathPolicyAllows("http", path, pathPolicy);
216
358
  const requestID = req.id.trim() !== "" ? req.id : randomB64u(18);
217
359
  const externalOrigin = externalOriginOverride ?? normalizeExternalOrigin(req.external_origin);
360
+ const body = req.body != null ? new Uint8Array(req.body) : new Uint8Array();
361
+ if (maxBodyBytes > 0 && body.length > maxBodyBytes)
362
+ throw new Error("request body too large");
363
+ releaseAdmission = await httpStreamAdmission.acquire(body.byteLength, ac.signal);
364
+ httpStreamAdmission.assertOpen();
218
365
  stream = await client.openStream(PROXY_KIND_HTTP1, { signal: ac.signal });
219
366
  const reader = createByteReader(stream, { signal: ac.signal });
220
367
  const filteredReqHeaders = filterRequestHeaders(req.headers, { extraAllowed: extraRequestHeaders });
@@ -229,7 +376,6 @@ export function createProxyRuntime(opts) {
229
376
  ...(externalOrigin === undefined ? {} : { external_origin: externalOrigin }),
230
377
  timeout_ms: timeoutMs
231
378
  });
232
- const body = req.body != null ? new Uint8Array(req.body) : new Uint8Array();
233
379
  await writeChunkFrames(stream, body, Math.min(64 * 1024, maxChunkBytes), maxBodyBytes);
234
380
  const respMeta = (await readJsonFrame(reader, maxJsonFrameBytes));
235
381
  if (respMeta.v !== PROXY_PROTOCOL_VERSION || respMeta.request_id !== requestID) {
@@ -264,8 +410,17 @@ export function createProxyRuntime(opts) {
264
410
  }
265
411
  catch (e) {
266
412
  const msg = e instanceof Error ? e.message : String(e);
267
- const status = e instanceof ProxyRuntimePolicyError ? e.status : 502;
268
- port.postMessage({ type: "flowersec-proxy:response_error", status, message: msg });
413
+ const code = isFlowersecError(e) ? e.code : undefined;
414
+ const status = e instanceof ProxyRuntimePolicyError
415
+ ? e.status
416
+ : code === "resource_exhausted" || code === "not_connected"
417
+ ? 503
418
+ : 502;
419
+ port.postMessage({
420
+ type: "flowersec-proxy:response_error",
421
+ status,
422
+ message: msg,
423
+ });
269
424
  try {
270
425
  stream?.reset(new Error(msg));
271
426
  }
@@ -274,6 +429,7 @@ export function createProxyRuntime(opts) {
274
429
  }
275
430
  }
276
431
  finally {
432
+ releaseAdmission?.();
277
433
  try {
278
434
  port.close();
279
435
  }
@@ -312,10 +468,20 @@ export function createProxyRuntime(opts) {
312
468
  return { stream, protocol: resp.protocol ?? "" };
313
469
  }
314
470
  return {
315
- limits: { maxJsonFrameBytes, maxChunkBytes, maxBodyBytes, maxWsFrameBytes },
471
+ limits: {
472
+ maxJsonFrameBytes,
473
+ maxChunkBytes,
474
+ maxBodyBytes,
475
+ maxWsFrameBytes,
476
+ maxWsBufferedAmountBytes,
477
+ maxConcurrentHttpStreams,
478
+ maxQueuedHttpRequests,
479
+ maxQueuedHttpBodyBytes,
480
+ },
316
481
  dispatchFetch,
317
482
  openWebSocketStream,
318
483
  dispose: () => {
484
+ httpStreamAdmission.close();
319
485
  sw?.removeEventListener("message", onMessage);
320
486
  sw?.removeEventListener("controllerchange", registerRuntime);
321
487
  }
@@ -36,12 +36,20 @@ export declare function resolveRuntimeLimitsFromScope(scope: ProxyRuntimeScopeV1
36
36
  maxChunkBytes?: number;
37
37
  maxBodyBytes?: number;
38
38
  maxWsFrameBytes?: number;
39
+ maxWsBufferedAmountBytes?: number;
40
+ maxConcurrentHttpStreams?: number;
41
+ maxQueuedHttpRequests?: number;
42
+ maxQueuedHttpBodyBytes?: number;
39
43
  timeoutMs?: number;
40
44
  }> | undefined): Readonly<{
41
45
  maxJsonFrameBytes?: number;
42
46
  maxChunkBytes?: number;
43
47
  maxBodyBytes?: number;
44
48
  maxWsFrameBytes?: number;
49
+ maxWsBufferedAmountBytes?: number;
50
+ maxConcurrentHttpStreams?: number;
51
+ maxQueuedHttpRequests?: number;
52
+ maxQueuedHttpBodyBytes?: number;
45
53
  timeoutMs?: number;
46
54
  }> | undefined;
47
55
  export declare function resolvePresetInputFromScope(scope: ProxyRuntimeScopeV1, presetOverride: ProxyPresetInput | undefined): ProxyPresetInput | undefined;
@@ -3,8 +3,10 @@ export declare const PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE = "flowersec-proxy:wind
3
3
  export declare const PROXY_WINDOW_FETCH_MSG_TYPE = "flowersec-proxy:fetch";
4
4
  export declare const PROXY_WINDOW_WS_OPEN_MSG_TYPE = "flowersec-proxy:ws_open";
5
5
  export declare const PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE = "flowersec-proxy:ws_open_ack";
6
+ export declare const PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY = "stream_write_ack_v1";
6
7
  export declare const PROXY_WINDOW_WS_ERROR_MSG_TYPE = "flowersec-proxy:ws_error";
7
8
  export declare const PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE = "flowersec-proxy:stream_chunk";
9
+ export declare const PROXY_WINDOW_STREAM_WRITE_ACK_MSG_TYPE = "flowersec-proxy:stream_write_ack";
8
10
  export declare const PROXY_WINDOW_STREAM_END_MSG_TYPE = "flowersec-proxy:stream_end";
9
11
  export declare const PROXY_WINDOW_STREAM_RESET_MSG_TYPE = "flowersec-proxy:stream_reset";
10
12
  export declare const PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE = "flowersec-proxy:stream_close";
@@ -34,6 +36,7 @@ export type ProxyWindowWsOpenMsg = Readonly<{
34
36
  export type ProxyWindowWsOpenAckMsg = Readonly<{
35
37
  type: typeof PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE;
36
38
  protocol: string;
39
+ capabilities?: readonly string[];
37
40
  }>;
38
41
  export type ProxyWindowWsErrorMsg = Readonly<{
39
42
  type: typeof PROXY_WINDOW_WS_ERROR_MSG_TYPE;
@@ -42,6 +45,11 @@ export type ProxyWindowWsErrorMsg = Readonly<{
42
45
  export type ProxyWindowStreamChunkMsg = Readonly<{
43
46
  type: typeof PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE;
44
47
  data: ArrayBuffer;
48
+ writeId?: number;
49
+ }>;
50
+ export type ProxyWindowStreamWriteAckMsg = Readonly<{
51
+ type: typeof PROXY_WINDOW_STREAM_WRITE_ACK_MSG_TYPE;
52
+ writeId: number;
45
53
  }>;
46
54
  export type ProxyWindowStreamEndMsg = Readonly<{
47
55
  type: typeof PROXY_WINDOW_STREAM_END_MSG_TYPE;
@@ -2,8 +2,10 @@ export const PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE = "flowersec-proxy:window_fetch
2
2
  export const PROXY_WINDOW_FETCH_MSG_TYPE = "flowersec-proxy:fetch";
3
3
  export const PROXY_WINDOW_WS_OPEN_MSG_TYPE = "flowersec-proxy:ws_open";
4
4
  export const PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE = "flowersec-proxy:ws_open_ack";
5
+ export const PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY = "stream_write_ack_v1";
5
6
  export const PROXY_WINDOW_WS_ERROR_MSG_TYPE = "flowersec-proxy:ws_error";
6
7
  export const PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE = "flowersec-proxy:stream_chunk";
8
+ export const PROXY_WINDOW_STREAM_WRITE_ACK_MSG_TYPE = "flowersec-proxy:stream_write_ack";
7
9
  export const PROXY_WINDOW_STREAM_END_MSG_TYPE = "flowersec-proxy:stream_end";
8
10
  export const PROXY_WINDOW_STREAM_RESET_MSG_TYPE = "flowersec-proxy:stream_reset";
9
11
  export const PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE = "flowersec-proxy:stream_close";
@@ -13,6 +13,7 @@ export type WebSocketPatchOptions = Readonly<{
13
13
  }>;
14
14
  shouldProxy?: (url: URL) => boolean;
15
15
  maxWsFrameBytes?: number;
16
+ maxWsBufferedAmountBytes?: number;
16
17
  }>;
17
18
  export declare function installWebSocketPatch(opts: WebSocketPatchOptions): Readonly<{
18
19
  uninstall: () => void;
@@ -85,6 +85,22 @@ export function installWebSocketPatch(opts) {
85
85
  if (maxWsFrameBytesFloor < 0)
86
86
  throw new Error("maxWsFrameBytes must be >= 0");
87
87
  const maxWsFrameBytes = maxWsFrameBytesFloor === 0 ? runtimeMaxWsFrameBytes : maxWsFrameBytesFloor;
88
+ const defaultMaxWsBufferedAmountBytes = 4 * (1 << 20);
89
+ const runtimeMaxWsBufferedAmountBytesRaw = runtime.limits?.maxWsBufferedAmountBytes;
90
+ if (runtimeMaxWsBufferedAmountBytesRaw !== undefined &&
91
+ (!Number.isSafeInteger(runtimeMaxWsBufferedAmountBytesRaw) || runtimeMaxWsBufferedAmountBytesRaw < 0)) {
92
+ throw new Error("runtime maxWsBufferedAmountBytes must be a non-negative safe integer");
93
+ }
94
+ const runtimeMaxWsBufferedAmountBytes = runtimeMaxWsBufferedAmountBytesRaw == null || runtimeMaxWsBufferedAmountBytesRaw === 0
95
+ ? defaultMaxWsBufferedAmountBytes
96
+ : runtimeMaxWsBufferedAmountBytesRaw;
97
+ const maxWsBufferedAmountBytesRaw = opts.maxWsBufferedAmountBytes ?? runtimeMaxWsBufferedAmountBytes;
98
+ if (!Number.isSafeInteger(maxWsBufferedAmountBytesRaw) || maxWsBufferedAmountBytesRaw < 0) {
99
+ throw new Error("maxWsBufferedAmountBytes must be a non-negative safe integer");
100
+ }
101
+ const maxWsBufferedAmountBytes = maxWsBufferedAmountBytesRaw === 0
102
+ ? runtimeMaxWsBufferedAmountBytes
103
+ : maxWsBufferedAmountBytesRaw;
88
104
  class PatchedWebSocket {
89
105
  static CONNECTING = 0;
90
106
  static OPEN = 1;
@@ -119,36 +135,60 @@ export function installWebSocketPatch(opts) {
119
135
  removeEventListener(type, listener) {
120
136
  this.listeners.off(type, listener);
121
137
  }
122
- queueWriteFrame(stream, op, payload) {
138
+ queueWriteFrame(stream, op, payload, bufferedBytes = 0) {
123
139
  this.writeChain = this.writeChain
124
- .then(() => writeWSFrame(stream, op, payload, maxWsFrameBytes))
125
- .catch((e) => this.fail(e));
140
+ .then(async () => {
141
+ if (this.readyState === PatchedWebSocket.CLOSED)
142
+ return;
143
+ const resolved = typeof payload === "function" ? await payload() : payload;
144
+ await writeWSFrame(stream, op, resolved, maxWsFrameBytes);
145
+ })
146
+ .catch((e) => this.fail(e))
147
+ .finally(() => {
148
+ this.bufferedAmount = Math.max(0, this.bufferedAmount - bufferedBytes);
149
+ });
150
+ }
151
+ reserveBufferedAmount(bytes) {
152
+ if (this.bufferedAmount + bytes > maxWsBufferedAmountBytes) {
153
+ this.fail(new Error("WebSocket bufferedAmount limit exceeded"));
154
+ return false;
155
+ }
156
+ this.bufferedAmount += bytes;
157
+ return true;
126
158
  }
127
159
  send(data) {
128
160
  if (this.readyState !== PatchedWebSocket.OPEN || this.stream == null) {
129
161
  throw new Error("WebSocket is not open");
130
162
  }
131
163
  const s = this.stream;
132
- const sendBytes = (op, payload) => {
133
- this.queueWriteFrame(s, op, payload);
164
+ const sendBytes = (op, byteLength, copyPayload) => {
165
+ if (!this.reserveBufferedAmount(byteLength))
166
+ return;
167
+ try {
168
+ this.queueWriteFrame(s, op, copyPayload(), byteLength);
169
+ }
170
+ catch (error) {
171
+ this.bufferedAmount = Math.max(0, this.bufferedAmount - byteLength);
172
+ throw error;
173
+ }
134
174
  };
135
175
  if (typeof data === "string") {
136
- sendBytes(1, te.encode(data));
176
+ const payload = te.encode(data);
177
+ sendBytes(1, payload.byteLength, () => payload);
137
178
  return;
138
179
  }
139
180
  if (data instanceof ArrayBuffer) {
140
- sendBytes(2, new Uint8Array(data));
181
+ sendBytes(2, data.byteLength, () => new Uint8Array(data).slice());
141
182
  return;
142
183
  }
143
184
  if (ArrayBuffer.isView(data)) {
144
- sendBytes(2, new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
185
+ sendBytes(2, data.byteLength, () => new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice());
145
186
  return;
146
187
  }
147
188
  if (typeof Blob !== "undefined" && data instanceof Blob) {
148
- void data
149
- .arrayBuffer()
150
- .then((ab) => sendBytes(2, new Uint8Array(ab)))
151
- .catch((e) => this.fail(e));
189
+ if (!this.reserveBufferedAmount(data.size))
190
+ return;
191
+ this.queueWriteFrame(s, 2, async () => new Uint8Array(await data.arrayBuffer()), data.size);
152
192
  return;
153
193
  }
154
194
  throw new Error("unsupported WebSocket send payload");
@@ -260,10 +300,14 @@ export function installWebSocketPatch(opts) {
260
300
  }
261
301
  }
262
302
  fail(e) {
303
+ if (this.readyState === PatchedWebSocket.CLOSED)
304
+ return;
263
305
  this.readyState = PatchedWebSocket.CLOSED;
264
306
  const msg = e instanceof Error ? e.message : String(e);
307
+ this.bufferedAmount = 0;
265
308
  this.emit("error", { type: "error", message: msg });
266
309
  this.emit("close", { type: "close", code: 1006, reason: msg, wasClean: false });
310
+ this.stream = null;
267
311
  try {
268
312
  this.ac.abort(msg);
269
313
  }
@@ -1,3 +1,4 @@
1
+ import { getClientTermination } from "../client-connect/termination.js";
1
2
  import { emitObserverDiagnostic, withObserverContext } from "../observability/observer.js";
2
3
  export { createArtifactResolver, createControlplaneArtifactSource } from "./artifactControlplane.js";
3
4
  function normalizeAutoReconnect(cfg) {
@@ -213,6 +214,14 @@ export function createReconnectManager() {
213
214
  return;
214
215
  }
215
216
  setState({ status: "connected", client, error: null });
217
+ const termination = getClientTermination(client);
218
+ if (termination != null) {
219
+ void termination.then(({ error }) => {
220
+ if (s.client !== client)
221
+ return;
222
+ startReconnect(t, cfg, error);
223
+ });
224
+ }
216
225
  emitObserverDiagnostic(withObserverContext(cfg.observer, { attemptSeq }), {
217
226
  path: "auto",
218
227
  stage: "reconnect",
@@ -8,8 +8,10 @@ export declare class RpcClient {
8
8
  private readonly notifyHandlers;
9
9
  private closed;
10
10
  private readonly observer;
11
+ private readonly onTerminal;
11
12
  constructor(readExactly: (n: number) => Promise<Uint8Array>, write: (b: Uint8Array) => Promise<void>, opts?: Readonly<{
12
13
  observer?: ClientObserverLike;
14
+ onTerminal?: (error: Error) => void;
13
15
  }>);
14
16
  call(typeId: number, payload: unknown, signal?: AbortSignal): Promise<{
15
17
  payload: unknown;
@@ -17,10 +17,12 @@ export class RpcClient {
17
17
  closed = false;
18
18
  // Observer for RPC events.
19
19
  observer;
20
+ onTerminal;
20
21
  constructor(readExactly, write, opts = {}) {
21
22
  this.readExactly = readExactly;
22
23
  this.write = write;
23
24
  this.observer = normalizeObserver(opts.observer);
25
+ this.onTerminal = opts.onTerminal;
24
26
  void this.readLoop();
25
27
  }
26
28
  // call sends a request and awaits a response or abort.
@@ -136,10 +138,19 @@ export class RpcClient {
136
138
  }
137
139
  }
138
140
  catch (e) {
141
+ const unexpected = !this.closed;
139
142
  this.closed = true;
140
143
  for (const [, p] of this.pending)
141
144
  p.reject(e);
142
145
  this.pending.clear();
146
+ if (unexpected) {
147
+ try {
148
+ this.onTerminal?.(e instanceof Error ? e : new Error(String(e)));
149
+ }
150
+ catch {
151
+ // Lifecycle callbacks must not escape the read loop.
152
+ }
153
+ }
143
154
  }
144
155
  }
145
156
  }
@@ -33,6 +33,8 @@ export type YamuxSessionOptions = Readonly<{
33
33
  limits?: Partial<YamuxLimits>;
34
34
  /** Optional generic resource diagnostic callback. */
35
35
  onDiagnostic?: (event: YamuxDiagnostic) => void;
36
+ /** Internal lifecycle callback for unexpected session termination. */
37
+ onTerminal?: (error: Error) => void;
36
38
  }>;
37
39
  export declare class YamuxSession {
38
40
  private readonly conn;
@@ -41,6 +43,7 @@ export declare class YamuxSession {
41
43
  private readonly onIncomingStream;
42
44
  private readonly limits;
43
45
  private readonly onDiagnostic;
46
+ private readonly onTerminal;
44
47
  private readonly client;
45
48
  private nextStreamId;
46
49
  private closed;
@@ -51,7 +54,9 @@ export declare class YamuxSession {
51
54
  private readonly pingWaiters;
52
55
  private activeProbe;
53
56
  constructor(conn: ByteDuplex, opts: YamuxSessionOptions);
54
- openStream(): Promise<YamuxStream>;
57
+ openStream(opts?: Readonly<{
58
+ signal?: AbortSignal;
59
+ }>): Promise<YamuxStream>;
55
60
  getStream(id: number): YamuxStream | undefined;
56
61
  writeRaw(chunk: Uint8Array): Promise<void>;
57
62
  outboundFrameBytes(): number;
@@ -64,6 +69,8 @@ export declare class YamuxSession {
64
69
  onStreamEstablished(_streamId: number): void;
65
70
  onStreamClosed(streamId: number): void;
66
71
  close(): void;
72
+ private fail;
73
+ private closeInternal;
67
74
  private wakeSendWindowWaiters;
68
75
  private readLoop;
69
76
  private handlePing;