@floegence/flowersec-core 0.20.1 → 0.21.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.
@@ -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
  }
@@ -24,7 +24,6 @@ export type ResolvedProxyPreset = Readonly<{
24
24
  }>;
25
25
  }>;
26
26
  export declare const DEFAULT_PROXY_PRESET_MANIFEST: ProxyPresetManifest;
27
- export declare const CODESERVER_PROXY_PRESET_MANIFEST: ProxyPresetManifest;
28
27
  export type ProxyPresetInput = ProxyPresetManifest | Partial<ProxyPresetLimits>;
29
28
  export declare function assertProxyPresetManifest(value: unknown): ProxyPresetManifest;
30
29
  export declare function resolveNamedProxyPreset(name: string): ProxyPresetManifest;
@@ -17,17 +17,6 @@ export const DEFAULT_PROXY_PRESET_MANIFEST = Object.freeze({
17
17
  max_ws_frame_bytes: DEFAULT_MAX_WS_FRAME_BYTES,
18
18
  },
19
19
  });
20
- export const CODESERVER_PROXY_PRESET_MANIFEST = Object.freeze({
21
- v: 1,
22
- preset_id: "codeserver",
23
- deprecated: true,
24
- limits: {
25
- max_json_frame_bytes: DEFAULT_MAX_JSON_FRAME_BYTES,
26
- max_chunk_bytes: DEFAULT_MAX_CHUNK_BYTES,
27
- max_body_bytes: DEFAULT_MAX_BODY_BYTES,
28
- max_ws_frame_bytes: 32 * 1024 * 1024,
29
- },
30
- });
31
20
  function isRecord(v) {
32
21
  return typeof v === "object" && v != null && !Array.isArray(v);
33
22
  }
@@ -91,8 +80,6 @@ export function resolveNamedProxyPreset(name) {
91
80
  case "":
92
81
  case "default":
93
82
  return DEFAULT_PROXY_PRESET_MANIFEST;
94
- case "codeserver":
95
- return CODESERVER_PROXY_PRESET_MANIFEST;
96
83
  default:
97
84
  throw new Error(`unknown proxy preset: ${name}`);
98
85
  }
@@ -6,7 +6,7 @@ export type ProxyProfile = Readonly<{
6
6
  maxWsFrameBytes: number;
7
7
  timeoutMs: number;
8
8
  }>;
9
- export type ProxyProfileName = "default" | "codeserver";
9
+ export type ProxyProfileName = "default";
10
10
  export declare const PROXY_PROFILE_DEFAULT: Readonly<{
11
11
  maxJsonFrameBytes: number;
12
12
  maxChunkBytes: number;
@@ -14,12 +14,5 @@ export declare const PROXY_PROFILE_DEFAULT: Readonly<{
14
14
  maxWsFrameBytes: number;
15
15
  timeoutMs: number;
16
16
  }>;
17
- export declare const PROXY_PROFILE_CODESERVER: Readonly<{
18
- maxJsonFrameBytes: number;
19
- maxChunkBytes: number;
20
- maxBodyBytes: number;
21
- maxWsFrameBytes: number;
22
- timeoutMs: number;
23
- }>;
24
17
  export declare function resolveProxyProfile(profile?: ProxyProfileName | Partial<ProxyProfile>): ProxyProfile;
25
18
  export declare function profileToPresetManifest(profile?: ProxyProfileName | Partial<ProxyProfile>): ProxyPresetManifest;
@@ -1,4 +1,4 @@
1
- import { CODESERVER_PROXY_PRESET_MANIFEST, DEFAULT_PROXY_PRESET_MANIFEST, resolveProxyPreset, } from "./preset.js";
1
+ import { DEFAULT_PROXY_PRESET_MANIFEST, resolveProxyPreset, } from "./preset.js";
2
2
  function toLegacyProfile(manifest) {
3
3
  const resolved = resolveProxyPreset(manifest);
4
4
  return Object.freeze({
@@ -10,9 +10,7 @@ function toLegacyProfile(manifest) {
10
10
  });
11
11
  }
12
12
  const DEFAULT_PROFILE = toLegacyProfile(DEFAULT_PROXY_PRESET_MANIFEST);
13
- const CODESERVER_PROFILE = toLegacyProfile(CODESERVER_PROXY_PRESET_MANIFEST);
14
13
  export const PROXY_PROFILE_DEFAULT = DEFAULT_PROFILE;
15
- export const PROXY_PROFILE_CODESERVER = CODESERVER_PROFILE;
16
14
  function normalizeSafeInt(name, value) {
17
15
  if (!Number.isFinite(value))
18
16
  throw new Error(`${name} must be a finite number`);
@@ -27,8 +25,6 @@ function resolveNamedProfile(name) {
27
25
  switch (name) {
28
26
  case "default":
29
27
  return DEFAULT_PROFILE;
30
- case "codeserver":
31
- return CODESERVER_PROFILE;
32
28
  default:
33
29
  throw new Error(`unknown proxy profile: ${name}`);
34
30
  }
@@ -55,8 +51,6 @@ export function profileToPresetManifest(profile) {
55
51
  switch (profile) {
56
52
  case "default":
57
53
  return DEFAULT_PROXY_PRESET_MANIFEST;
58
- case "codeserver":
59
- return CODESERVER_PROXY_PRESET_MANIFEST;
60
54
  default:
61
55
  throw new Error(`unknown proxy profile: ${profile}`);
62
56
  }
@@ -15,8 +15,10 @@ export type ProxyRuntimeLimits = Readonly<{
15
15
  maxChunkBytes: number;
16
16
  maxBodyBytes: number;
17
17
  maxWsFrameBytes: number;
18
+ maxWsBufferedAmountBytes: number;
18
19
  maxConcurrentHttpStreams: number;
19
20
  maxQueuedHttpRequests: number;
21
+ maxQueuedHttpBodyBytes: number;
20
22
  }>;
21
23
  export type ProxyRuntime = Readonly<{
22
24
  limits: ProxyRuntimeLimits;
@@ -42,8 +44,10 @@ export type ProxyRuntimeOptions = Readonly<{
42
44
  maxChunkBytes?: number;
43
45
  maxBodyBytes?: number;
44
46
  maxWsFrameBytes?: number;
47
+ maxWsBufferedAmountBytes?: number;
45
48
  maxConcurrentHttpStreams?: number;
46
49
  maxQueuedHttpRequests?: number;
50
+ maxQueuedHttpBodyBytes?: number;
47
51
  timeoutMs?: number;
48
52
  extraRequestHeaders?: readonly string[];
49
53
  extraResponseHeaders?: readonly string[];
@@ -132,6 +132,8 @@ function normalizeMaxBytes(name, v, defaultValue) {
132
132
  }
133
133
  const DEFAULT_MAX_CONCURRENT_HTTP_STREAMS = 24;
134
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);
135
137
  function normalizePositiveLimit(name, value, defaultValue) {
136
138
  if (value == null)
137
139
  return defaultValue;
@@ -152,15 +154,18 @@ class HttpStreamAdmission {
152
154
  path;
153
155
  maxConcurrent;
154
156
  maxQueued;
157
+ maxQueuedBodyBytes;
155
158
  active = 0;
156
159
  pending = [];
160
+ pendingBodyBytes = 0;
157
161
  closed = false;
158
- constructor(path, maxConcurrent, maxQueued) {
162
+ constructor(path, maxConcurrent, maxQueued, maxQueuedBodyBytes) {
159
163
  this.path = path;
160
164
  this.maxConcurrent = maxConcurrent;
161
165
  this.maxQueued = maxQueued;
166
+ this.maxQueuedBodyBytes = maxQueuedBodyBytes;
162
167
  }
163
- acquire(signal) {
168
+ acquire(bodyBytes, signal) {
164
169
  if (this.closed)
165
170
  return Promise.reject(this.closedError());
166
171
  if (signal?.aborted)
@@ -177,8 +182,17 @@ class HttpStreamAdmission {
177
182
  message: "proxy runtime HTTP request queue is full",
178
183
  }));
179
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
+ }
180
193
  return new Promise((resolve, reject) => {
181
194
  const waiter = {
195
+ bodyBytes,
182
196
  resolve,
183
197
  reject,
184
198
  ...(signal === undefined ? {} : { signal }),
@@ -188,11 +202,13 @@ class HttpStreamAdmission {
188
202
  if (index < 0)
189
203
  return;
190
204
  this.pending.splice(index, 1);
205
+ this.pendingBodyBytes = Math.max(0, this.pendingBodyBytes - waiter.bodyBytes);
191
206
  this.cleanupWaiter(waiter);
192
207
  reject(this.abortedError());
193
208
  };
194
209
  signal?.addEventListener("abort", waiter.onAbort, { once: true });
195
210
  this.pending.push(waiter);
211
+ this.pendingBodyBytes += bodyBytes;
196
212
  });
197
213
  }
198
214
  close() {
@@ -200,6 +216,7 @@ class HttpStreamAdmission {
200
216
  return;
201
217
  this.closed = true;
202
218
  for (const waiter of this.pending.splice(0)) {
219
+ this.pendingBodyBytes = Math.max(0, this.pendingBodyBytes - waiter.bodyBytes);
203
220
  this.cleanupWaiter(waiter);
204
221
  waiter.reject(this.closedError());
205
222
  }
@@ -221,6 +238,7 @@ class HttpStreamAdmission {
221
238
  drain() {
222
239
  while (!this.closed && this.active < this.maxConcurrent && this.pending.length > 0) {
223
240
  const waiter = this.pending.shift();
241
+ this.pendingBodyBytes = Math.max(0, this.pendingBodyBytes - waiter.bodyBytes);
224
242
  this.cleanupWaiter(waiter);
225
243
  if (waiter.signal?.aborted) {
226
244
  waiter.reject(this.abortedError());
@@ -287,9 +305,11 @@ export function createProxyRuntime(opts) {
287
305
  const maxChunkBytes = normalizeMaxBytes("maxChunkBytes", opts.maxChunkBytes, DEFAULT_MAX_CHUNK_BYTES);
288
306
  const maxBodyBytes = normalizeMaxBytes("maxBodyBytes", opts.maxBodyBytes, DEFAULT_MAX_BODY_BYTES);
289
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);
290
309
  const maxConcurrentHttpStreams = normalizePositiveLimit("maxConcurrentHttpStreams", opts.maxConcurrentHttpStreams, DEFAULT_MAX_CONCURRENT_HTTP_STREAMS);
291
310
  const maxQueuedHttpRequests = normalizeNonNegativeLimit("maxQueuedHttpRequests", opts.maxQueuedHttpRequests, DEFAULT_MAX_QUEUED_HTTP_REQUESTS);
292
- const httpStreamAdmission = new HttpStreamAdmission(client.path, maxConcurrentHttpStreams, maxQueuedHttpRequests);
311
+ const maxQueuedHttpBodyBytes = normalizeNonNegativeLimit("maxQueuedHttpBodyBytes", opts.maxQueuedHttpBodyBytes, DEFAULT_MAX_QUEUED_HTTP_BODY_BYTES);
312
+ const httpStreamAdmission = new HttpStreamAdmission(client.path, maxConcurrentHttpStreams, maxQueuedHttpRequests, maxQueuedHttpBodyBytes);
293
313
  const timeoutMs = normalizeTimeoutMs(opts.timeoutMs);
294
314
  const extraRequestHeaders = opts.extraRequestHeaders ?? [];
295
315
  const extraResponseHeaders = opts.extraResponseHeaders ?? [];
@@ -337,7 +357,10 @@ export function createProxyRuntime(opts) {
337
357
  assertPathPolicyAllows("http", path, pathPolicy);
338
358
  const requestID = req.id.trim() !== "" ? req.id : randomB64u(18);
339
359
  const externalOrigin = externalOriginOverride ?? normalizeExternalOrigin(req.external_origin);
340
- releaseAdmission = await httpStreamAdmission.acquire(ac.signal);
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);
341
364
  httpStreamAdmission.assertOpen();
342
365
  stream = await client.openStream(PROXY_KIND_HTTP1, { signal: ac.signal });
343
366
  const reader = createByteReader(stream, { signal: ac.signal });
@@ -353,7 +376,6 @@ export function createProxyRuntime(opts) {
353
376
  ...(externalOrigin === undefined ? {} : { external_origin: externalOrigin }),
354
377
  timeout_ms: timeoutMs
355
378
  });
356
- const body = req.body != null ? new Uint8Array(req.body) : new Uint8Array();
357
379
  await writeChunkFrames(stream, body, Math.min(64 * 1024, maxChunkBytes), maxBodyBytes);
358
380
  const respMeta = (await readJsonFrame(reader, maxJsonFrameBytes));
359
381
  if (respMeta.v !== PROXY_PROTOCOL_VERSION || respMeta.request_id !== requestID) {
@@ -451,8 +473,10 @@ export function createProxyRuntime(opts) {
451
473
  maxChunkBytes,
452
474
  maxBodyBytes,
453
475
  maxWsFrameBytes,
476
+ maxWsBufferedAmountBytes,
454
477
  maxConcurrentHttpStreams,
455
478
  maxQueuedHttpRequests,
479
+ maxQueuedHttpBodyBytes,
456
480
  },
457
481
  dispatchFetch,
458
482
  openWebSocketStream,
@@ -36,16 +36,20 @@ export declare function resolveRuntimeLimitsFromScope(scope: ProxyRuntimeScopeV1
36
36
  maxChunkBytes?: number;
37
37
  maxBodyBytes?: number;
38
38
  maxWsFrameBytes?: number;
39
+ maxWsBufferedAmountBytes?: number;
39
40
  maxConcurrentHttpStreams?: number;
40
41
  maxQueuedHttpRequests?: number;
42
+ maxQueuedHttpBodyBytes?: number;
41
43
  timeoutMs?: number;
42
44
  }> | undefined): Readonly<{
43
45
  maxJsonFrameBytes?: number;
44
46
  maxChunkBytes?: number;
45
47
  maxBodyBytes?: number;
46
48
  maxWsFrameBytes?: number;
49
+ maxWsBufferedAmountBytes?: number;
47
50
  maxConcurrentHttpStreams?: number;
48
51
  maxQueuedHttpRequests?: number;
52
+ maxQueuedHttpBodyBytes?: number;
49
53
  timeoutMs?: number;
50
54
  }> | undefined;
51
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;