@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.
@@ -25,6 +25,8 @@ export type ConnectOptionsBase = Readonly<{
25
25
  maxRecordBytes?: number;
26
26
  /** Maximum buffered plaintext bytes in the secure channel (0 uses default). */
27
27
  maxBufferedBytes?: number;
28
+ /** Maximum queued outbound plaintext bytes in the secure channel (default 4 MiB; 0 uses default). */
29
+ maxOutboundBufferedBytes?: number;
28
30
  /** Preferred plaintext bytes per outbound encrypted record (default 64 KiB). */
29
31
  outboundRecordChunkBytes?: number;
30
32
  /** WebSocket inbound and outbound queue limits. */
@@ -14,6 +14,7 @@ import { isTunnelAttachCloseReason } from "./tunnelAttachCloseReason.js";
14
14
  import { enforceTransportSecurity } from "./transportSecurity.js";
15
15
  import { maxPlaintextBytes } from "../e2ee/record.js";
16
16
  import { isYamuxResourceExhaustedError } from "../yamux/errors.js";
17
+ import { registerClientTermination } from "./termination.js";
17
18
  export async function connectCore(args) {
18
19
  const observer = normalizeObserver(args.opts.observer, { path: args.path });
19
20
  const signal = args.opts.signal;
@@ -75,6 +76,10 @@ export async function connectCore(args) {
75
76
  if (!Number.isSafeInteger(maxBufferedBytes) || maxBufferedBytes < 0) {
76
77
  invalidOption("maxBufferedBytes must be a non-negative integer");
77
78
  }
79
+ const maxOutboundBufferedBytes = args.opts.maxOutboundBufferedBytes ?? 0;
80
+ if (!Number.isSafeInteger(maxOutboundBufferedBytes) || maxOutboundBufferedBytes < 0) {
81
+ invalidOption("maxOutboundBufferedBytes must be a non-negative integer");
82
+ }
78
83
  const effectiveMaxRecordBytes = maxRecordBytes > 0 ? maxRecordBytes : (1 << 20);
79
84
  const outboundRecordChunkBytes = args.opts.outboundRecordChunkBytes ?? 64 * 1024;
80
85
  if (!Number.isSafeInteger(outboundRecordChunkBytes) || outboundRecordChunkBytes <= 0 || outboundRecordChunkBytes > maxPlaintextBytes(effectiveMaxRecordBytes)) {
@@ -182,6 +187,7 @@ export async function connectCore(args) {
182
187
  maxRecordBytes: effectiveMaxRecordBytes,
183
188
  outboundRecordChunkBytes,
184
189
  ...(maxBufferedBytes > 0 ? { maxBufferedBytes } : {}),
190
+ ...(maxOutboundBufferedBytes > 0 ? { maxOutboundBufferedBytes } : {}),
185
191
  timeoutMs: handshakeTimeoutMs,
186
192
  ...(signal !== undefined ? { signal } : {}),
187
193
  }), {
@@ -220,9 +226,28 @@ export async function connectCore(args) {
220
226
  write: (b) => secure.write(b),
221
227
  close: () => secure.close(),
222
228
  };
229
+ let resolveTermination;
230
+ const termination = new Promise((resolve) => {
231
+ resolveTermination = resolve;
232
+ });
233
+ let terminationReported = false;
234
+ let closeAll = () => {
235
+ try {
236
+ secure.close();
237
+ }
238
+ catch { /* ignore */ }
239
+ };
240
+ const reportTermination = (error) => {
241
+ if (terminationReported)
242
+ return;
243
+ terminationReported = true;
244
+ closeAll();
245
+ resolveTermination({ error });
246
+ };
223
247
  const mux = new YamuxSession(conn, {
224
248
  client: true,
225
249
  ...(args.opts.yamuxLimits === undefined ? {} : { limits: args.opts.yamuxLimits }),
250
+ onTerminal: reportTermination,
226
251
  onDiagnostic: (event) => emitObserverDiagnostic(args.opts.observer, {
227
252
  path: args.path,
228
253
  stage: "yamux",
@@ -234,9 +259,19 @@ export async function connectCore(args) {
234
259
  limit: event.limit,
235
260
  }),
236
261
  });
262
+ closeAll = () => {
263
+ try {
264
+ mux.close();
265
+ }
266
+ catch { /* ignore */ }
267
+ try {
268
+ secure.close();
269
+ }
270
+ catch { /* ignore */ }
271
+ };
237
272
  let rpcStream;
238
273
  try {
239
- rpcStream = await mux.openStream();
274
+ rpcStream = await mux.openStream(signal === undefined ? {} : { signal });
240
275
  }
241
276
  catch (e) {
242
277
  mux.close();
@@ -266,7 +301,7 @@ export async function connectCore(args) {
266
301
  cause: e,
267
302
  });
268
303
  }
269
- const rpc = new RpcClient(readExactly, write, { observer });
304
+ const rpc = new RpcClient(readExactly, write, { observer, onTerminal: reportTermination });
270
305
  const ping = async () => {
271
306
  try {
272
307
  await secure.sendPing();
@@ -306,7 +341,7 @@ export async function connectCore(args) {
306
341
  clearInterval(livenessTimer);
307
342
  livenessTimer = undefined;
308
343
  };
309
- const closeAll = () => {
344
+ closeAll = () => {
310
345
  stopLiveness();
311
346
  try {
312
347
  rpc.close();
@@ -342,7 +377,7 @@ export async function connectCore(args) {
342
377
  }, liveness.intervalMs);
343
378
  livenessTimer?.unref?.();
344
379
  }
345
- return {
380
+ const client = {
346
381
  path: args.path,
347
382
  ...(args.attach != null ? { endpointInstanceId: args.attach.endpointInstanceId } : {}),
348
383
  secure,
@@ -374,9 +409,12 @@ export async function connectCore(args) {
374
409
  let abortListener;
375
410
  let s;
376
411
  try {
377
- s = await mux.openStream();
412
+ s = await mux.openStream(signal === undefined ? {} : { signal });
378
413
  }
379
414
  catch (e) {
415
+ if (signal?.aborted) {
416
+ throw new FlowersecError({ path: args.path, stage: "yamux", code: "canceled", message: "open stream aborted", cause: signal.reason });
417
+ }
380
418
  const exhausted = isYamuxResourceExhaustedError(e);
381
419
  throw new FlowersecError({ path: args.path, stage: "yamux", code: exhausted ? "resource_exhausted" : "open_stream_failed", message: exhausted ? "yamux stream limit reached" : "open stream failed", cause: e });
382
420
  }
@@ -456,6 +494,8 @@ export async function connectCore(args) {
456
494
  },
457
495
  close: closeAll,
458
496
  };
497
+ registerClientTermination(client, termination);
498
+ return client;
459
499
  }
460
500
  catch (e) {
461
501
  try {
@@ -0,0 +1,6 @@
1
+ import type { Client } from "../client.js";
2
+ export type ClientTermination = Readonly<{
3
+ error: Error;
4
+ }>;
5
+ export declare function registerClientTermination(client: Client, termination: Promise<ClientTermination>): void;
6
+ export declare function getClientTermination(client: Client): Promise<ClientTermination> | undefined;
@@ -0,0 +1,7 @@
1
+ const clientTerminations = new WeakMap();
2
+ export function registerClientTermination(client, termination) {
3
+ clientTerminations.set(client, termination);
4
+ }
5
+ export function getClientTermination(client) {
6
+ return clientTerminations.get(client);
7
+ }
@@ -18,6 +18,8 @@ export type HandshakeClientOptions = Readonly<{
18
18
  outboundRecordChunkBytes?: number;
19
19
  /** Maximum buffered plaintext bytes for the secure channel. */
20
20
  maxBufferedBytes?: number;
21
+ /** Maximum queued outbound plaintext bytes for the secure channel. */
22
+ maxOutboundBufferedBytes?: number;
21
23
  /** Optional AbortSignal to cancel the handshake. */
22
24
  signal?: AbortSignal;
23
25
  /** Optional total handshake timeout in milliseconds (>= 0; 0 disables). */
@@ -44,6 +46,8 @@ export type HandshakeServerOptions = Readonly<{
44
46
  outboundRecordChunkBytes?: number;
45
47
  /** Maximum buffered plaintext bytes for the secure channel. */
46
48
  maxBufferedBytes?: number;
49
+ /** Maximum queued outbound plaintext bytes for the secure channel. */
50
+ maxOutboundBufferedBytes?: number;
47
51
  /** Optional AbortSignal to cancel the handshake. */
48
52
  signal?: AbortSignal;
49
53
  /** Optional total handshake timeout in milliseconds (>= 0; 0 disables). */
@@ -161,6 +161,7 @@ export async function clientHandshake(transport, opts) {
161
161
  maxRecordBytes: opts.maxRecordBytes,
162
162
  ...(opts.outboundRecordChunkBytes !== undefined ? { outboundRecordChunkBytes: opts.outboundRecordChunkBytes } : {}),
163
163
  ...(opts.maxBufferedBytes !== undefined ? { maxBufferedBytes: opts.maxBufferedBytes } : {}),
164
+ ...(opts.maxOutboundBufferedBytes !== undefined ? { maxOutboundBufferedBytes: opts.maxOutboundBufferedBytes } : {}),
164
165
  sendKey: keys.c2sKey,
165
166
  recvKey: keys.s2cKey,
166
167
  sendNoncePrefix: keys.c2sNoncePrefix,
@@ -190,6 +191,7 @@ export class ServerHandshakeCache {
190
191
  }
191
192
  this.ttlMs = ttlMs;
192
193
  this.maxEntries = maxEntries;
194
+ serverHandshakeCacheStores.set(this, this.m);
193
195
  }
194
196
  cleanup(nowMs) {
195
197
  if (this.ttlMs <= 0)
@@ -231,6 +233,15 @@ export class ServerHandshakeCache {
231
233
  this.m.delete(initKey);
232
234
  }
233
235
  }
236
+ const serverHandshakeCacheStores = new WeakMap();
237
+ function takeServerHandshakeState(cache, expected) {
238
+ const store = serverHandshakeCacheStores.get(cache);
239
+ const current = store?.get(expected.initKey);
240
+ if (current !== expected)
241
+ return undefined;
242
+ store.delete(expected.initKey);
243
+ return current;
244
+ }
234
245
  // serverHandshake performs the server side of the E2EE handshake.
235
246
  export async function serverHandshake(transport, cache, opts) {
236
247
  if (opts.initExpireAtUnixS <= 0)
@@ -281,6 +292,8 @@ export async function serverHandshake(transport, cache, opts) {
281
292
  }
282
293
  if (decoded.handshakeType !== HANDSHAKE_TYPE_ACK)
283
294
  throw new Error("unexpected handshake type");
295
+ if (takeServerHandshakeState(cache, entry) == null)
296
+ throw new Error("handshake state unavailable");
284
297
  ack = JSON.parse(td.decode(decoded.payloadJsonUtf8));
285
298
  break;
286
299
  }
@@ -309,7 +322,6 @@ export async function serverHandshake(transport, cache, opts) {
309
322
  throw new E2EEHandshakeError("auth_tag_mismatch", "auth tag mismatch");
310
323
  const shared = suiteSharedSecret(suite, entry.serverPriv, clientPub);
311
324
  const keys = deriveSessionKeys(opts.psk, shared, th);
312
- cache.delete(init);
313
325
  // Server-finished confirmation: send an encrypted ping record (seq=1) immediately after the handshake.
314
326
  const pingFrame = encryptRecord(keys.s2cKey, keys.s2cNoncePrefix, RECORD_FLAG_PING, 1n, new Uint8Array(), opts.maxRecordBytes);
315
327
  await transport.writeBinary(pingFrame, ioWriteOpts(opts.signal));
@@ -319,6 +331,7 @@ export async function serverHandshake(transport, cache, opts) {
319
331
  maxRecordBytes: opts.maxRecordBytes,
320
332
  ...(opts.outboundRecordChunkBytes !== undefined ? { outboundRecordChunkBytes: opts.outboundRecordChunkBytes } : {}),
321
333
  ...(opts.maxBufferedBytes !== undefined ? { maxBufferedBytes: opts.maxBufferedBytes } : {}),
334
+ ...(opts.maxOutboundBufferedBytes !== undefined ? { maxOutboundBufferedBytes: opts.maxOutboundBufferedBytes } : {}),
322
335
  sendKey: keys.s2cKey,
323
336
  recvKey: keys.c2sKey,
324
337
  sendNoncePrefix: keys.s2cNoncePrefix,
@@ -21,8 +21,10 @@ export type SecureChannelOptions = Readonly<{
21
21
  maxRecordBytes: number;
22
22
  /** Preferred plaintext bytes per outbound record. */
23
23
  outboundRecordChunkBytes?: number;
24
- /** Maximum queued plaintext bytes before backpressure/errors. */
24
+ /** Maximum queued inbound plaintext bytes before the channel is closed. */
25
25
  maxBufferedBytes?: number;
26
+ /** Maximum queued outbound plaintext bytes (default 4 MiB; 0 uses default). */
27
+ maxOutboundBufferedBytes?: number;
26
28
  }>;
27
29
  type Direction = 1 | 2;
28
30
  export declare class SecureChannel {
@@ -30,6 +32,7 @@ export declare class SecureChannel {
30
32
  private readonly maxRecordBytes;
31
33
  private readonly outboundRecordChunkBytes;
32
34
  private readonly maxBufferedBytes;
35
+ private readonly maxOutboundBufferedBytes;
33
36
  private sendKey;
34
37
  private recvKey;
35
38
  private sendNoncePrefix;
@@ -44,6 +47,7 @@ export declare class SecureChannel {
44
47
  private sendQueueHead;
45
48
  private sendWaiters;
46
49
  private sendWaitersHead;
50
+ private sendQueueBytes;
47
51
  private sendClosed;
48
52
  private sendErr;
49
53
  private readonly recvQueue;
@@ -57,6 +61,7 @@ export declare class SecureChannel {
57
61
  maxRecordBytes: number;
58
62
  outboundRecordChunkBytes?: number;
59
63
  maxBufferedBytes?: number;
64
+ maxOutboundBufferedBytes?: number;
60
65
  sendKey: Uint8Array;
61
66
  recvKey: Uint8Array;
62
67
  sendNoncePrefix: Uint8Array;
@@ -17,6 +17,7 @@ export class SecureChannel {
17
17
  outboundRecordChunkBytes;
18
18
  // Upper bound for buffered plaintext in memory.
19
19
  maxBufferedBytes;
20
+ maxOutboundBufferedBytes;
20
21
  // Active encryption keys and nonce prefixes for the current epoch.
21
22
  sendKey;
22
23
  recvKey;
@@ -37,6 +38,7 @@ export class SecureChannel {
37
38
  sendQueueHead = 0;
38
39
  sendWaiters = [];
39
40
  sendWaitersHead = 0;
41
+ sendQueueBytes = 0;
40
42
  sendClosed = false;
41
43
  sendErr = null;
42
44
  // Receive queue and waiters for plaintext delivery.
@@ -55,6 +57,11 @@ export class SecureChannel {
55
57
  throw new RangeError("outboundRecordChunkBytes must be a positive integer within the record plaintext limit");
56
58
  }
57
59
  this.maxBufferedBytes = Math.max(0, args.maxBufferedBytes ?? 4 * (1 << 20));
60
+ const maxOutboundBufferedBytes = args.maxOutboundBufferedBytes ?? 4 * (1 << 20);
61
+ if (!Number.isSafeInteger(maxOutboundBufferedBytes) || maxOutboundBufferedBytes < 0) {
62
+ throw new RangeError("maxOutboundBufferedBytes must be a non-negative safe integer");
63
+ }
64
+ this.maxOutboundBufferedBytes = maxOutboundBufferedBytes === 0 ? 4 * (1 << 20) : maxOutboundBufferedBytes;
58
65
  this.sendKey = args.sendKey;
59
66
  this.recvKey = args.recvKey;
60
67
  this.sendNoncePrefix = args.sendNoncePrefix;
@@ -72,7 +79,7 @@ export class SecureChannel {
72
79
  async write(plaintext) {
73
80
  if (plaintext.length === 0)
74
81
  return;
75
- await this.enqueueSend("app", plaintext.slice());
82
+ await this.enqueueSend("app", plaintext);
76
83
  }
77
84
  // read resolves with the next plaintext chunk or throws on errors/close.
78
85
  async read() {
@@ -121,6 +128,10 @@ export class SecureChannel {
121
128
  return Promise.reject(this.sendErr);
122
129
  if (this.closed || this.sendClosed)
123
130
  return Promise.reject(new Error("closed"));
131
+ const bufferedBytes = payload?.byteLength ?? 0;
132
+ if (this.sendQueueBytes + bufferedBytes > this.maxOutboundBufferedBytes) {
133
+ return Promise.reject(new Error("secure channel outbound buffer exceeded"));
134
+ }
124
135
  return new Promise((resolve, reject) => {
125
136
  if (this.sendErr != null) {
126
137
  reject(this.sendErr);
@@ -130,7 +141,15 @@ export class SecureChannel {
130
141
  reject(new Error("closed"));
131
142
  return;
132
143
  }
133
- const req = payload === undefined ? { kind, resolve, reject } : { kind, payload, resolve, reject };
144
+ if (this.sendQueueBytes + bufferedBytes > this.maxOutboundBufferedBytes) {
145
+ reject(new Error("secure channel outbound buffer exceeded"));
146
+ return;
147
+ }
148
+ const retainedPayload = payload?.slice();
149
+ const req = retainedPayload === undefined
150
+ ? { kind, bufferedBytes, resolve, reject }
151
+ : { kind, payload: retainedPayload, bufferedBytes, resolve, reject };
152
+ this.sendQueueBytes += bufferedBytes;
134
153
  this.sendQueue.push(req);
135
154
  const w = this.shiftSendWaiter();
136
155
  if (w != null)
@@ -182,8 +201,11 @@ export class SecureChannel {
182
201
  const start = this.sendQueueHead;
183
202
  this.sendQueue = [];
184
203
  this.sendQueueHead = 0;
185
- for (let i = start; i < queued.length; i++)
186
- queued[i].reject(err);
204
+ for (let i = start; i < queued.length; i++) {
205
+ const req = queued[i];
206
+ this.sendQueueBytes = Math.max(0, this.sendQueueBytes - req.bufferedBytes);
207
+ req.reject(err);
208
+ }
187
209
  }
188
210
  failSend(err) {
189
211
  if (this.sendErr != null)
@@ -207,15 +229,15 @@ export class SecureChannel {
207
229
  const req = await this.nextSend();
208
230
  if (req == null)
209
231
  return;
210
- if (this.sendErr != null) {
211
- req.reject(this.sendErr);
212
- continue;
213
- }
214
- if (this.closed || this.sendClosed) {
215
- req.reject(new Error("closed"));
216
- continue;
217
- }
218
232
  try {
233
+ if (this.sendErr != null) {
234
+ req.reject(this.sendErr);
235
+ continue;
236
+ }
237
+ if (this.closed || this.sendClosed) {
238
+ req.reject(new Error("closed"));
239
+ continue;
240
+ }
219
241
  let frame;
220
242
  if (req.kind === "app") {
221
243
  const payload = req.payload ?? new Uint8Array();
@@ -247,6 +269,9 @@ export class SecureChannel {
247
269
  this.close();
248
270
  return;
249
271
  }
272
+ finally {
273
+ this.sendQueueBytes = Math.max(0, this.sendQueueBytes - req.bufferedBytes);
274
+ }
250
275
  }
251
276
  }
252
277
  async readLoop() {
@@ -6,6 +6,7 @@ export type RegisterProxyAppWindowOptions = Readonly<{
6
6
  controllerWindow?: Window | null;
7
7
  targetWindow?: Window;
8
8
  maxWsFrameBytes?: number;
9
+ maxWsBufferedAmountBytes?: number;
9
10
  capabilityNonce?: string;
10
11
  }>;
11
12
  export type ProxyAppWindowHandle = Readonly<{
@@ -1,6 +1,6 @@
1
1
  import { createServiceWorkerControllerGuard, } from "./controllerGuard.js";
2
2
  import { createMessagePortBackedStream } from "./portStream.js";
3
- import { PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE, PROXY_WINDOW_FETCH_MSG_TYPE, PROXY_WINDOW_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, } from "./windowBridgeProtocol.js";
3
+ import { PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE, PROXY_WINDOW_FETCH_MSG_TYPE, PROXY_WINDOW_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY, } from "./windowBridgeProtocol.js";
4
4
  function resolveTargetWindow(raw) {
5
5
  const target = raw ?? globalThis.window;
6
6
  if (target == null)
@@ -83,7 +83,12 @@ export function registerProxyAppWindow(opts) {
83
83
  };
84
84
  sw?.addEventListener("message", onServiceWorkerMessage);
85
85
  const runtime = {
86
- limits: opts.maxWsFrameBytes === undefined ? {} : { maxWsFrameBytes: opts.maxWsFrameBytes },
86
+ limits: {
87
+ ...(opts.maxWsFrameBytes === undefined ? {} : { maxWsFrameBytes: opts.maxWsFrameBytes }),
88
+ ...(opts.maxWsBufferedAmountBytes === undefined
89
+ ? {}
90
+ : { maxWsBufferedAmountBytes: opts.maxWsBufferedAmountBytes }),
91
+ },
87
92
  openWebSocketStream: async (path, wsOpts = {}) => {
88
93
  const channel = new MessageChannel();
89
94
  const port = channel.port1;
@@ -102,11 +107,18 @@ export function registerProxyAppWindow(opts) {
102
107
  }
103
108
  reject(error instanceof Error ? error : new Error(String(error)));
104
109
  };
105
- const finishResolve = (protocol) => {
110
+ const finishResolve = (ack) => {
106
111
  if (settled)
107
112
  return;
108
113
  settled = true;
109
- resolve({ stream: createMessagePortBackedStream(port), protocol });
114
+ const capabilities = Array.isArray(ack.capabilities)
115
+ ? ack.capabilities.filter((value) => typeof value === "string")
116
+ : [];
117
+ const writeAcknowledgements = capabilities.includes(PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY);
118
+ resolve({
119
+ stream: createMessagePortBackedStream(port, { writeAcknowledgements }),
120
+ protocol: String(ack.protocol ?? ""),
121
+ });
110
122
  };
111
123
  port.onmessage = (ev) => {
112
124
  const data = ev.data;
@@ -114,7 +126,7 @@ export function registerProxyAppWindow(opts) {
114
126
  return;
115
127
  const type = typeof data.type === "string" ? data.type : "";
116
128
  if (type === PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE) {
117
- finishResolve(String(data.protocol ?? ""));
129
+ finishResolve(data);
118
130
  return;
119
131
  }
120
132
  if (type === PROXY_WINDOW_WS_ERROR_MSG_TYPE) {
@@ -1,4 +1,4 @@
1
- import { PROXY_WINDOW_FETCH_MSG_TYPE, 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_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, } from "./windowBridgeProtocol.js";
1
+ import { PROXY_WINDOW_FETCH_MSG_TYPE, 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, PROXY_WINDOW_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY, } from "./windowBridgeProtocol.js";
2
2
  function normalizeOrigins(origins) {
3
3
  const out = [];
4
4
  const seen = new Set();
@@ -41,7 +41,12 @@ function hasExpectedCapability(data, capabilityNonce) {
41
41
  }
42
42
  function bridgeWebSocket(runtime, msg, port) {
43
43
  const ac = new AbortController();
44
- let streamClosed = false;
44
+ let terminal = false;
45
+ let acceptingWrites = true;
46
+ let stream = null;
47
+ let pendingWriteBytes = 0;
48
+ let writeChain = Promise.resolve();
49
+ const maxBufferedBytes = runtime.limits.maxWsBufferedAmountBytes ?? 4 * (1 << 20);
45
50
  const closePort = () => {
46
51
  try {
47
52
  port.close();
@@ -50,13 +55,43 @@ function bridgeWebSocket(runtime, msg, port) {
50
55
  // Best-effort.
51
56
  }
52
57
  };
58
+ const failBridge = (error) => {
59
+ if (terminal)
60
+ return;
61
+ terminal = true;
62
+ acceptingWrites = false;
63
+ const err = error instanceof Error ? error : new Error(String(error));
64
+ try {
65
+ stream?.reset(err);
66
+ }
67
+ catch {
68
+ // Best-effort.
69
+ }
70
+ try {
71
+ ac.abort(err.message);
72
+ }
73
+ catch {
74
+ // Best-effort.
75
+ }
76
+ try {
77
+ port.postMessage({
78
+ type: PROXY_WINDOW_STREAM_RESET_MSG_TYPE,
79
+ message: err.message,
80
+ });
81
+ }
82
+ catch {
83
+ // Best-effort.
84
+ }
85
+ closePort();
86
+ };
53
87
  void (async () => {
54
88
  try {
55
89
  const wsOpts = {
56
90
  signal: ac.signal,
57
91
  ...(msg.protocols === undefined ? {} : { protocols: msg.protocols }),
58
92
  };
59
- const { stream, protocol } = await runtime.openWebSocketStream(msg.path, wsOpts);
93
+ const opened = await runtime.openWebSocketStream(msg.path, wsOpts);
94
+ stream = opened.stream;
60
95
  port.onmessage = (ev) => {
61
96
  const data = ev.data;
62
97
  if (data == null || typeof data !== "object")
@@ -64,21 +99,50 @@ function bridgeWebSocket(runtime, msg, port) {
64
99
  const type = typeof data.type === "string" ? data.type : "";
65
100
  switch (type) {
66
101
  case PROXY_WINDOW_STREAM_CHUNK_MSG_TYPE: {
102
+ if (!acceptingWrites || stream == null)
103
+ return;
67
104
  const raw = data.data;
68
105
  if (!(raw instanceof ArrayBuffer))
69
106
  return;
70
- void stream.write(new Uint8Array(raw));
107
+ if (pendingWriteBytes + raw.byteLength > maxBufferedBytes) {
108
+ failBridge(new Error("proxy WebSocket outbound buffer exceeded"));
109
+ return;
110
+ }
111
+ const rawWriteId = data.writeId;
112
+ const writeId = Number.isSafeInteger(rawWriteId) && Number(rawWriteId) > 0
113
+ ? Number(rawWriteId)
114
+ : undefined;
115
+ const chunk = new Uint8Array(raw);
116
+ pendingWriteBytes += chunk.byteLength;
117
+ writeChain = writeChain
118
+ .then(async () => {
119
+ if (terminal || stream == null)
120
+ throw new Error("stream is closed");
121
+ await stream.write(chunk);
122
+ if (terminal || writeId === undefined)
123
+ return;
124
+ port.postMessage({
125
+ type: PROXY_WINDOW_STREAM_WRITE_ACK_MSG_TYPE,
126
+ writeId,
127
+ });
128
+ })
129
+ .catch((error) => failBridge(error))
130
+ .finally(() => {
131
+ pendingWriteBytes = Math.max(0, pendingWriteBytes - chunk.byteLength);
132
+ });
71
133
  return;
72
134
  }
73
135
  case PROXY_WINDOW_STREAM_CLOSE_MSG_TYPE:
74
- void stream.close();
136
+ if (!acceptingWrites || stream == null)
137
+ return;
138
+ acceptingWrites = false;
139
+ writeChain = writeChain
140
+ .then(() => stream?.close())
141
+ .catch((error) => failBridge(error));
75
142
  return;
76
143
  case PROXY_WINDOW_STREAM_RESET_MSG_TYPE: {
77
144
  const message = String(data.message ?? "stream reset");
78
- stream.reset(new Error(message));
79
- streamClosed = true;
80
- ac.abort(message);
81
- closePort();
145
+ failBridge(new Error(message));
82
146
  return;
83
147
  }
84
148
  default:
@@ -86,11 +150,16 @@ function bridgeWebSocket(runtime, msg, port) {
86
150
  }
87
151
  };
88
152
  port.start?.();
89
- port.postMessage({ type: PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, protocol });
90
- while (!streamClosed) {
153
+ port.postMessage({
154
+ type: PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE,
155
+ protocol: opened.protocol,
156
+ capabilities: [PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY],
157
+ });
158
+ while (!terminal) {
91
159
  const chunk = await stream.read();
92
160
  if (chunk == null) {
93
- streamClosed = true;
161
+ terminal = true;
162
+ acceptingWrites = false;
94
163
  port.postMessage({ type: PROXY_WINDOW_STREAM_END_MSG_TYPE });
95
164
  closePort();
96
165
  return;
@@ -101,8 +170,12 @@ function bridgeWebSocket(runtime, msg, port) {
101
170
  }
102
171
  catch (error) {
103
172
  const message = error instanceof Error ? error.message : String(error);
104
- port.postMessage({ type: PROXY_WINDOW_WS_ERROR_MSG_TYPE, message });
105
- closePort();
173
+ if (stream == null) {
174
+ port.postMessage({ type: PROXY_WINDOW_WS_ERROR_MSG_TYPE, message });
175
+ closePort();
176
+ return;
177
+ }
178
+ failBridge(error);
106
179
  }
107
180
  })();
108
181
  }
@@ -16,15 +16,24 @@ const DEFAULT_RESPONSE_HEADER_ALLOWLIST = new Set([
16
16
  "content-disposition",
17
17
  "content-encoding",
18
18
  "content-language",
19
+ "content-security-policy",
20
+ "content-security-policy-report-only",
19
21
  "content-type",
22
+ "cross-origin-embedder-policy",
23
+ "cross-origin-opener-policy",
24
+ "cross-origin-resource-policy",
20
25
  "etag",
21
26
  "expires",
22
27
  "last-modified",
23
28
  "location",
29
+ "permissions-policy",
24
30
  "pragma",
31
+ "referrer-policy",
25
32
  "vary",
26
33
  "www-authenticate",
27
- "set-cookie"
34
+ "set-cookie",
35
+ "x-content-type-options",
36
+ "x-frame-options"
28
37
  ]);
29
38
  const DEFAULT_WS_HEADER_ALLOWLIST = new Set(["sec-websocket-protocol", "cookie"]);
30
39
  export function normalizeHeaderName(name) {
@@ -33,8 +33,10 @@ export type RegisterProxyIntegrationOptions = Readonly<{
33
33
  maxChunkBytes?: number;
34
34
  maxBodyBytes?: number;
35
35
  maxWsFrameBytes?: number;
36
+ maxWsBufferedAmountBytes?: number;
36
37
  maxConcurrentHttpStreams?: number;
37
38
  maxQueuedHttpRequests?: number;
39
+ maxQueuedHttpBodyBytes?: number;
38
40
  timeoutMs?: number;
39
41
  pathPolicy?: ProxyRuntimePathPolicy;
40
42
  externalOrigin?: string;
@@ -180,6 +180,9 @@ function buildRuntimeOptions(preset, runtime) {
180
180
  maxChunkBytes: runtime?.maxChunkBytes ?? preset.limits.max_chunk_bytes,
181
181
  maxBodyBytes: runtime?.maxBodyBytes ?? preset.limits.max_body_bytes,
182
182
  maxWsFrameBytes: runtime?.maxWsFrameBytes ?? preset.limits.max_ws_frame_bytes,
183
+ ...(runtime?.maxWsBufferedAmountBytes === undefined
184
+ ? {}
185
+ : { maxWsBufferedAmountBytes: runtime.maxWsBufferedAmountBytes }),
183
186
  timeoutMs: runtime?.timeoutMs ?? preset.limits.timeout_ms ?? 0,
184
187
  ...(runtime?.maxConcurrentHttpStreams === undefined
185
188
  ? {}
@@ -187,6 +190,9 @@ function buildRuntimeOptions(preset, runtime) {
187
190
  ...(runtime?.maxQueuedHttpRequests === undefined
188
191
  ? {}
189
192
  : { maxQueuedHttpRequests: runtime.maxQueuedHttpRequests }),
193
+ ...(runtime?.maxQueuedHttpBodyBytes === undefined
194
+ ? {}
195
+ : { maxQueuedHttpBodyBytes: runtime.maxQueuedHttpBodyBytes }),
190
196
  ...(runtime?.pathPolicy === undefined ? {} : { pathPolicy: runtime.pathPolicy }),
191
197
  ...(runtime?.externalOrigin === undefined ? {} : { externalOrigin: runtime.externalOrigin }),
192
198
  ...(runtime?.runtimeRegistrationToken === undefined ? {} : { runtimeRegistrationToken: runtime.runtimeRegistrationToken }),
@@ -1,2 +1,4 @@
1
1
  import type { YamuxStream } from "../yamux/stream.js";
2
- export declare function createMessagePortBackedStream(port: MessagePort): YamuxStream;
2
+ export declare function createMessagePortBackedStream(port: MessagePort, opts?: Readonly<{
3
+ writeAcknowledgements?: boolean;
4
+ }>): YamuxStream;