@floegence/flowersec-core 0.24.0 → 0.25.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/README.md CHANGED
@@ -27,6 +27,12 @@ Start with the [TypeScript cookbook](https://github.com/floegence/flowersec/tree
27
27
 
28
28
  High-level WebSocket connections require TLS by default. Use `AllowPlaintextForLoopback` only for literal local development targets.
29
29
 
30
+ ## Node Proxy Server
31
+
32
+ The Node entrypoint exports `serveProxySession(...)`, `serveProxyStream(...)`, and `ProxyServerOptions`. Request bodies remain bounded per request by `maxBodyBytes`; `maxBufferedRequestBodyBytes` additionally caps the total buffered request-body bytes owned by one proxy session. WebSocket frames remain bounded by `maxWsFrameBytes`; `maxWsQueuedBytes` additionally caps upstream-to-Yamux queued bytes per connection, while the reverse direction waits for each Node `ws` send callback.
33
+
34
+ The defaults keep one session request-body budget equal to `maxBodyBytes` and one queued WebSocket frame plus its proxy header. Raise either limit only when the deployment has a matching memory budget.
35
+
30
36
  ## Runtime Boundaries
31
37
 
32
38
  TypeScript owns browser and Service Worker integration. Shared tunnel, proxy gateway, and helper binaries remain Go-owned.
@@ -35,7 +35,7 @@ export async function enforceTransportSecurity(args) {
35
35
  const policy = args.policy ?? RequireTLS;
36
36
  try {
37
37
  if (typeof policy === "function") {
38
- allowed = await policy(input);
38
+ allowed = await policy(input) === true;
39
39
  }
40
40
  else {
41
41
  allowed = evaluatePreset(policy, target);
@@ -1,5 +1,6 @@
1
1
  import { Role as ControlRole, assertChannelInitGrant } from "../gen/flowersec/controlplane/v1.gen.js";
2
2
  import { Role as TunnelRole } from "../gen/flowersec/tunnel/v1.gen.js";
3
+ import { withAbortAndTimeout } from "../client-connect/common.js";
3
4
  import { assertTunnelGrantContract, assertValidPSK, prepareChannelId } from "../client-connect/contract.js";
4
5
  import { enforceTransportSecurity } from "../client-connect/transportSecurity.js";
5
6
  import { SDK_DEFAULTS } from "../defaults.js";
@@ -12,7 +13,7 @@ import { isYamuxPingTimeoutError } from "../yamux/errors.js";
12
13
  import { YamuxSession } from "../yamux/session.js";
13
14
  import { RpcServer } from "../rpc/server.js";
14
15
  import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
15
- import { AbortError, FlowersecError } from "../utils/errors.js";
16
+ import { AbortError, FlowersecError, TimeoutError, throwIfAborted } from "../utils/errors.js";
16
17
  import { WebSocketBinaryTransport } from "../ws-client/binaryTransport.js";
17
18
  export class Session {
18
19
  secure;
@@ -152,15 +153,46 @@ export class Session {
152
153
  }
153
154
  }
154
155
  export async function acceptDirect(websocket, handshake, options = {}) {
156
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
157
+ if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
158
+ throw new FlowersecError({
159
+ path: "direct",
160
+ stage: "validate",
161
+ code: "invalid_option",
162
+ message: "handshakeTimeoutMs must be a non-negative number",
163
+ });
164
+ }
155
165
  await enforceIncomingDirectTransport(options);
166
+ const deadline = createHandshakeDeadline(handshakeTimeoutMs);
156
167
  const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
157
- return await establishSession("direct", transport, handshake, options);
168
+ return await establishSession("direct", transport, handshake, options, undefined, deadline);
158
169
  }
159
170
  export async function acceptDirectResolved(websocket, resolver, options = {}) {
171
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
172
+ if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
173
+ throw new FlowersecError({
174
+ path: "direct",
175
+ stage: "validate",
176
+ code: "invalid_option",
177
+ message: "handshakeTimeoutMs must be a non-negative number",
178
+ });
179
+ }
160
180
  await enforceIncomingDirectTransport(options);
181
+ const deadline = createHandshakeDeadline(handshakeTimeoutMs);
161
182
  const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
162
- const first = await transport.readBinary(readOptions(options));
183
+ let first;
184
+ try {
185
+ first = await runHandshakeStep(deadline, options.signal, () => transport.readBinary({
186
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
187
+ timeoutMs: remainingHandshakeTimeoutMs(deadline),
188
+ }));
189
+ }
190
+ catch (error) {
191
+ transport.close();
192
+ throw endpointHandshakeError("direct", error, "failed to read handshake init");
193
+ }
163
194
  let init;
195
+ let channelId;
164
196
  try {
165
197
  const decoded = decodeHandshakeFrame(first, options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes);
166
198
  if (decoded.handshakeType !== HANDSHAKE_TYPE_INIT)
@@ -168,30 +200,44 @@ export async function acceptDirectResolved(websocket, resolver, options = {}) {
168
200
  init = JSON.parse(new TextDecoder().decode(decoded.payloadJsonUtf8));
169
201
  if (init.version !== PROTOCOL_VERSION || init.role !== 1 || (init.suite !== 1 && init.suite !== 2))
170
202
  throw new Error("invalid handshake init");
203
+ channelId = prepareChannelId(init.channel_id, "direct");
204
+ if (channelId !== init.channel_id) {
205
+ throw new FlowersecError({
206
+ path: "direct",
207
+ stage: "validate",
208
+ code: "invalid_input",
209
+ message: "channel_id must not have leading or trailing whitespace",
210
+ });
211
+ }
171
212
  }
172
213
  catch (error) {
173
214
  transport.close();
215
+ if (error instanceof FlowersecError)
216
+ throw error;
174
217
  throw new FlowersecError({ path: "direct", stage: "handshake", code: "handshake_failed", message: "invalid handshake init", cause: error });
175
218
  }
176
219
  let credential;
177
220
  try {
178
- credential = await resolver({
179
- channelId: prepareChannelId(init.channel_id, "direct"),
221
+ credential = await runHandshakeStep(deadline, options.signal, () => resolver({
222
+ channelId,
180
223
  version: init.version,
181
224
  suite: init.suite,
182
225
  clientFeatures: init.client_features >>> 0,
183
- });
226
+ }));
184
227
  }
185
228
  catch (error) {
186
229
  transport.close();
230
+ const interrupted = endpointHandshakeInterruption("direct", error);
231
+ if (interrupted != null)
232
+ throw interrupted;
187
233
  throw new FlowersecError({ path: "direct", stage: "validate", code: "resolve_failed", message: "credential resolution failed", cause: error });
188
234
  }
189
235
  const replay = new PrefetchedTransport(transport, first);
190
236
  return await establishSession("direct", replay, {
191
- channelId: init.channel_id,
237
+ channelId,
192
238
  suite: init.suite,
193
239
  ...credential,
194
- }, options);
240
+ }, options, undefined, deadline);
195
241
  }
196
242
  export async function connectTunnel(grantInput, options) {
197
243
  let grant;
@@ -202,6 +248,7 @@ export async function connectTunnel(grantInput, options) {
202
248
  throw new FlowersecError({ path: "tunnel", stage: "validate", code: "invalid_input", message: "invalid ChannelInitGrant", cause: error });
203
249
  }
204
250
  assertTunnelGrantContract(grant, ControlRole.Role_server);
251
+ const channelId = prepareChannelId(grant.channel_id, "tunnel");
205
252
  const tunnelUrl = grant.tunnel_url.trim();
206
253
  if (tunnelUrl === "")
207
254
  throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_tunnel_url", message: "missing tunnel_url" });
@@ -212,35 +259,63 @@ export async function connectTunnel(grantInput, options) {
212
259
  const origin = options.origin.trim();
213
260
  if (origin === "")
214
261
  throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_origin", message: "missing origin" });
262
+ const connectTimeoutMs = options.connectTimeoutMs ?? SDK_DEFAULTS.transport.connectTimeoutMs;
263
+ if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs < 0) {
264
+ throw new FlowersecError({
265
+ path: "tunnel",
266
+ stage: "validate",
267
+ code: "invalid_option",
268
+ message: "connectTimeoutMs must be a non-negative number",
269
+ });
270
+ }
271
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs;
272
+ if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
273
+ throw new FlowersecError({
274
+ path: "tunnel",
275
+ stage: "validate",
276
+ code: "invalid_option",
277
+ message: "handshakeTimeoutMs must be a non-negative number",
278
+ });
279
+ }
280
+ const psk = assertValidPSK(grant.e2ee_psk_b64u, "tunnel");
215
281
  await enforceTransportSecurity({ rawUrl: tunnelUrl, path: "tunnel", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
216
282
  const endpointInstanceId = normalizeEndpointInstanceId(options.endpointInstanceId);
217
- const websocket = options.wsFactory(tunnelUrl, origin);
218
- const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
283
+ let transport;
219
284
  try {
220
- await waitForOpen(websocket, options.connectTimeoutMs ?? SDK_DEFAULTS.transport.connectTimeoutMs, options.signal);
285
+ throwIfAborted(options.signal, "connect aborted");
286
+ const websocket = options.wsFactory(tunnelUrl, origin);
287
+ transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
288
+ await waitForOpen(websocket, connectTimeoutMs, options.signal);
289
+ throwIfAborted(options.signal, "connect aborted");
221
290
  const attach = {
222
291
  v: 1,
223
- channel_id: prepareChannelId(grant.channel_id, "tunnel"),
292
+ channel_id: channelId,
224
293
  role: TunnelRole.Role_server,
225
294
  token: grant.token.trim(),
226
295
  endpoint_instance_id: endpointInstanceId,
227
296
  };
228
297
  websocket.send(JSON.stringify(attach));
229
298
  return await establishSession("tunnel", transport, {
230
- channelId: grant.channel_id,
299
+ channelId,
231
300
  suite: grant.default_suite,
232
- psk: assertValidPSK(grant.e2ee_psk_b64u, "tunnel"),
301
+ psk,
233
302
  initExpireAtUnixS: grant.channel_init_expire_at_unix_s,
234
303
  }, options, endpointInstanceId);
235
304
  }
236
305
  catch (error) {
237
- transport.close();
306
+ transport?.close();
238
307
  if (error instanceof FlowersecError)
239
308
  throw error;
309
+ if (error instanceof TimeoutError) {
310
+ throw new FlowersecError({ path: "tunnel", stage: "connect", code: "timeout", message: "endpoint tunnel connect timed out", cause: error });
311
+ }
312
+ if (error instanceof AbortError) {
313
+ throw new FlowersecError({ path: "tunnel", stage: "connect", code: "canceled", message: "endpoint tunnel connect canceled", cause: error });
314
+ }
240
315
  throw new FlowersecError({ path: "tunnel", stage: "connect", code: "dial_failed", message: "endpoint tunnel connect failed", cause: error });
241
316
  }
242
317
  }
243
- async function establishSession(path, transport, handshake, options, endpointInstanceId) {
318
+ async function establishSession(path, transport, handshake, options, endpointInstanceId, deadline) {
244
319
  let psk;
245
320
  try {
246
321
  psk = normalizePSK(handshake.psk, path);
@@ -250,7 +325,7 @@ async function establishSession(path, transport, handshake, options, endpointIns
250
325
  throw error;
251
326
  }
252
327
  try {
253
- const secure = await serverHandshake(transport, options.handshakeCache ?? new ServerHandshakeCache(), {
328
+ const startHandshake = () => serverHandshake(transport, options.handshakeCache ?? new ServerHandshakeCache(), {
254
329
  channelId: prepareChannelId(handshake.channelId, path),
255
330
  suite: handshake.suite,
256
331
  psk,
@@ -262,25 +337,52 @@ async function establishSession(path, transport, handshake, options, endpointIns
262
337
  outboundRecordChunkBytes: options.outboundRecordChunkBytes ?? SDK_DEFAULTS.e2ee.outboundRecordChunkBytes,
263
338
  maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes,
264
339
  maxOutboundBufferedBytes: options.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
265
- timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
340
+ timeoutMs: deadline == null
341
+ ? options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs
342
+ : remainingHandshakeTimeoutMs(deadline),
266
343
  ...(options.signal === undefined ? {} : { signal: options.signal }),
267
344
  });
345
+ const secure = deadline == null
346
+ ? await startHandshake()
347
+ : await runHandshakeStep(deadline, options.signal, startHandshake);
268
348
  try {
269
- await handshake.commitAuthenticated?.();
349
+ if (handshake.commitAuthenticated != null) {
350
+ if (deadline == null) {
351
+ await handshake.commitAuthenticated();
352
+ }
353
+ else {
354
+ await runHandshakeStep(deadline, options.signal, handshake.commitAuthenticated);
355
+ }
356
+ }
270
357
  }
271
358
  catch (error) {
272
359
  secure.close();
360
+ const interrupted = endpointHandshakeInterruption(path, error);
361
+ if (interrupted != null)
362
+ throw interrupted;
273
363
  throw new FlowersecError({ path, stage: "handshake", code: "credential_commit_failed", message: "credential commit failed", cause: error });
274
364
  }
275
- return Session.create(path, secure, {
276
- ...(options.yamuxLimits === undefined ? {} : { yamuxLimits: options.yamuxLimits }),
277
- ...(endpointInstanceId === undefined ? {} : { endpointInstanceId }),
278
- });
365
+ try {
366
+ throwIfAborted(options.signal, "handshake canceled");
367
+ if (deadline != null)
368
+ remainingHandshakeTimeoutMs(deadline);
369
+ return Session.create(path, secure, {
370
+ ...(options.yamuxLimits === undefined ? {} : { yamuxLimits: options.yamuxLimits }),
371
+ ...(endpointInstanceId === undefined ? {} : { endpointInstanceId }),
372
+ });
373
+ }
374
+ catch (error) {
375
+ secure.close();
376
+ throw error;
377
+ }
279
378
  }
280
379
  catch (error) {
281
380
  transport.close();
282
381
  if (error instanceof FlowersecError)
283
382
  throw error;
383
+ const interrupted = endpointHandshakeInterruption(path, error);
384
+ if (interrupted != null)
385
+ throw interrupted;
284
386
  throw new FlowersecError({ path, stage: "handshake", code: "handshake_failed", message: "endpoint handshake failed", cause: error });
285
387
  }
286
388
  finally {
@@ -352,22 +454,25 @@ function unwrapServerGrant(input) {
352
454
  function webSocketTransportOptions(options) {
353
455
  return options.webSocketLimits === undefined ? {} : { webSocketLimits: options.webSocketLimits };
354
456
  }
355
- function readOptions(options) {
356
- return {
357
- timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
358
- ...(options.signal === undefined ? {} : { signal: options.signal }),
359
- };
360
- }
361
457
  async function enforceIncomingDirectTransport(options) {
362
458
  const rawUrl = options.secureTransport === false ? "ws://127.0.0.1/" : "wss://127.0.0.1/";
363
459
  await enforceTransportSecurity({ rawUrl, path: "direct", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
364
460
  }
365
461
  function waitForOpen(websocket, timeoutMs, signal) {
366
- if (websocket.readyState === 1)
367
- return Promise.resolve();
368
462
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
369
463
  return Promise.reject(new RangeError("connectTimeoutMs must be non-negative"));
464
+ try {
465
+ throwIfAborted(signal, "connect aborted");
466
+ }
467
+ catch (error) {
468
+ return Promise.reject(error);
469
+ }
470
+ if (websocket.readyState === 1)
471
+ return Promise.resolve();
472
+ if (websocket.readyState >= 2)
473
+ return Promise.reject(new Error("websocket closed before open"));
370
474
  return new Promise((resolve, reject) => {
475
+ let settled = false;
371
476
  let timer;
372
477
  const cleanup = () => {
373
478
  if (timer != null)
@@ -378,6 +483,9 @@ function waitForOpen(websocket, timeoutMs, signal) {
378
483
  signal?.removeEventListener("abort", onAbort);
379
484
  };
380
485
  const finish = (error) => {
486
+ if (settled)
487
+ return;
488
+ settled = true;
381
489
  cleanup();
382
490
  if (error == null)
383
491
  resolve();
@@ -392,14 +500,58 @@ function waitForOpen(websocket, timeoutMs, signal) {
392
500
  websocket.addEventListener("error", onError);
393
501
  websocket.addEventListener("close", onClose);
394
502
  signal?.addEventListener("abort", onAbort, { once: true });
395
- if (timeoutMs > 0)
396
- timer = setTimeout(() => finish(new Error("websocket open timeout")), timeoutMs);
503
+ if (signal?.aborted) {
504
+ onAbort();
505
+ }
506
+ else if (websocket.readyState === 1) {
507
+ onOpen();
508
+ }
509
+ else if (websocket.readyState >= 2) {
510
+ onClose();
511
+ }
512
+ if (!settled && timeoutMs > 0)
513
+ timer = setTimeout(() => finish(new TimeoutError("websocket open timeout")), timeoutMs);
397
514
  });
398
515
  }
399
516
  function cleanupWaiter(waiter) {
400
517
  if (waiter.onAbort != null)
401
518
  waiter.signal?.removeEventListener("abort", waiter.onAbort);
402
519
  }
520
+ function createHandshakeDeadline(timeoutMs) {
521
+ return timeoutMs > 0 ? { expiresAtMs: Date.now() + timeoutMs } : {};
522
+ }
523
+ function remainingHandshakeTimeoutMs(deadline) {
524
+ if (deadline.expiresAtMs === undefined)
525
+ return 0;
526
+ const remaining = deadline.expiresAtMs - Date.now();
527
+ if (remaining <= 0)
528
+ throw new TimeoutError("handshake timeout");
529
+ return remaining;
530
+ }
531
+ async function runHandshakeStep(deadline, signal, operation) {
532
+ throwIfAborted(signal, "handshake canceled");
533
+ const timeoutMs = remainingHandshakeTimeoutMs(deadline);
534
+ const result = await withAbortAndTimeout(Promise.resolve().then(operation), {
535
+ timeoutMs,
536
+ ...(signal === undefined ? {} : { signal }),
537
+ });
538
+ throwIfAborted(signal, "handshake canceled");
539
+ remainingHandshakeTimeoutMs(deadline);
540
+ return result;
541
+ }
542
+ function endpointHandshakeInterruption(path, error) {
543
+ if (error instanceof TimeoutError) {
544
+ return new FlowersecError({ path, stage: "handshake", code: "timeout", message: "endpoint handshake timed out", cause: error });
545
+ }
546
+ if (error instanceof AbortError) {
547
+ return new FlowersecError({ path, stage: "handshake", code: "canceled", message: "endpoint handshake canceled", cause: error });
548
+ }
549
+ return undefined;
550
+ }
551
+ function endpointHandshakeError(path, error, message) {
552
+ return endpointHandshakeInterruption(path, error)
553
+ ?? new FlowersecError({ path, stage: "handshake", code: "handshake_failed", message, cause: error });
554
+ }
403
555
  function asError(error) {
404
556
  return error instanceof Error ? error : new Error(String(error));
405
557
  }
@@ -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, PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY, } from "./windowBridgeProtocol.js";
3
+ import { PROXY_WINDOW_FETCH_FORWARD_MSG_TYPE, PROXY_WINDOW_FETCH_MSG_TYPE, PROXY_WINDOW_STREAM_RESET_MSG_TYPE, PROXY_WINDOW_WS_ERROR_MSG_TYPE, PROXY_WINDOW_WS_BIDIRECTIONAL_ACK_CAPABILITY, PROXY_WINDOW_WS_OPEN_ACK_MSG_TYPE, PROXY_WINDOW_WS_OPEN_MSG_TYPE, } from "./windowBridgeProtocol.js";
4
4
  function resolveTargetWindow(raw) {
5
5
  const target = raw ?? globalThis.window;
6
6
  if (target == null)
@@ -59,6 +59,8 @@ export function registerProxyAppWindow(opts) {
59
59
  const targetWindow = resolveTargetWindow(opts.targetWindow);
60
60
  const controllerWindow = resolveControllerWindow(targetWindow, opts.controllerWindow);
61
61
  const capabilityNonce = normalizeCapabilityNonce(opts.capabilityNonce);
62
+ const activeWebSocketBridges = new Set();
63
+ let disposed = false;
62
64
  const sw = targetWindow.navigator?.serviceWorker;
63
65
  const onServiceWorkerMessage = (ev) => {
64
66
  const data = ev.data;
@@ -90,15 +92,32 @@ export function registerProxyAppWindow(opts) {
90
92
  : { maxWsBufferedAmountBytes: opts.maxWsBufferedAmountBytes }),
91
93
  },
92
94
  openWebSocketStream: async (path, wsOpts = {}) => {
95
+ if (disposed)
96
+ throw new Error("proxy app Window bridge is disposed");
93
97
  const channel = new MessageChannel();
94
98
  const port = channel.port1;
95
99
  port.start?.();
96
100
  return await new Promise((resolve, reject) => {
97
101
  let settled = false;
102
+ let terminal = false;
103
+ let stream = null;
104
+ const cleanup = () => {
105
+ activeWebSocketBridges.delete(bridge);
106
+ if (wsOpts.signal != null)
107
+ wsOpts.signal.removeEventListener("abort", onAbort);
108
+ };
109
+ const finishTerminal = () => {
110
+ if (terminal)
111
+ return false;
112
+ terminal = true;
113
+ cleanup();
114
+ return true;
115
+ };
98
116
  const finishReject = (error) => {
99
117
  if (settled)
100
118
  return;
101
119
  settled = true;
120
+ finishTerminal();
102
121
  try {
103
122
  port.close();
104
123
  }
@@ -107,16 +126,76 @@ export function registerProxyAppWindow(opts) {
107
126
  }
108
127
  reject(error instanceof Error ? error : new Error(String(error)));
109
128
  };
129
+ const disposeBridge = (error) => {
130
+ if (!finishTerminal())
131
+ return;
132
+ if (stream != null) {
133
+ try {
134
+ void Promise.resolve(stream.reset(error)).catch(() => {
135
+ // The bridge is already terminal.
136
+ });
137
+ }
138
+ catch {
139
+ // The bridge is already terminal.
140
+ }
141
+ return;
142
+ }
143
+ try {
144
+ port.postMessage({
145
+ type: PROXY_WINDOW_STREAM_RESET_MSG_TYPE,
146
+ message: error.message,
147
+ });
148
+ }
149
+ catch {
150
+ // Best-effort.
151
+ }
152
+ if (!settled) {
153
+ settled = true;
154
+ reject(error);
155
+ }
156
+ try {
157
+ port.close();
158
+ }
159
+ catch {
160
+ // Best-effort.
161
+ }
162
+ };
163
+ const bridge = { dispose: disposeBridge };
164
+ const onAbort = () => {
165
+ const reason = wsOpts.signal?.reason;
166
+ disposeBridge(reason instanceof Error ? reason : new Error(String(reason ?? "aborted")));
167
+ };
168
+ activeWebSocketBridges.add(bridge);
110
169
  const finishResolve = (ack) => {
111
- if (settled)
170
+ if (settled || terminal)
112
171
  return;
113
- settled = true;
114
172
  const capabilities = Array.isArray(ack.capabilities)
115
173
  ? ack.capabilities.filter((value) => typeof value === "string")
116
174
  : [];
117
- const writeAcknowledgements = capabilities.includes(PROXY_WINDOW_WS_WRITE_ACK_CAPABILITY);
175
+ if (!capabilities.includes(PROXY_WINDOW_WS_BIDIRECTIONAL_ACK_CAPABILITY)) {
176
+ try {
177
+ port.postMessage({
178
+ type: PROXY_WINDOW_STREAM_RESET_MSG_TYPE,
179
+ message: "proxy Window bridge capability mismatch",
180
+ });
181
+ }
182
+ catch {
183
+ // The capability error remains authoritative.
184
+ }
185
+ finishReject(new Error("proxy Window bridge does not support bidirectional stream acknowledgements"));
186
+ return;
187
+ }
188
+ if (disposed) {
189
+ disposeBridge(new Error("proxy app Window bridge is disposed"));
190
+ return;
191
+ }
192
+ settled = true;
193
+ stream = createMessagePortBackedStream(port, {
194
+ maxBufferedBytes: opts.maxWsBufferedAmountBytes ?? 4 * (1 << 20),
195
+ onTerminal: finishTerminal,
196
+ });
118
197
  resolve({
119
- stream: createMessagePortBackedStream(port, { writeAcknowledgements }),
198
+ stream,
120
199
  protocol: String(ack.protocol ?? ""),
121
200
  });
122
201
  };
@@ -135,15 +214,16 @@ export function registerProxyAppWindow(opts) {
135
214
  };
136
215
  if (wsOpts.signal != null) {
137
216
  if (wsOpts.signal.aborted) {
138
- finishReject(wsOpts.signal.reason ?? new Error("aborted"));
217
+ onAbort();
139
218
  return;
140
219
  }
141
- wsOpts.signal.addEventListener("abort", () => finishReject(wsOpts.signal?.reason ?? new Error("aborted")), { once: true });
220
+ wsOpts.signal.addEventListener("abort", onAbort, { once: true });
142
221
  }
143
222
  try {
144
223
  controllerWindow.postMessage({
145
224
  type: PROXY_WINDOW_WS_OPEN_MSG_TYPE,
146
225
  path,
226
+ capabilities: [PROXY_WINDOW_WS_BIDIRECTIONAL_ACK_CAPABILITY],
147
227
  ...(wsOpts.protocols === undefined ? {} : { protocols: wsOpts.protocols }),
148
228
  ...(capabilityNonce === "" ? {} : { capabilityNonce }),
149
229
  }, controllerOrigin, [channel.port2]);
@@ -157,7 +237,13 @@ export function registerProxyAppWindow(opts) {
157
237
  return {
158
238
  runtime,
159
239
  dispose: () => {
240
+ if (disposed)
241
+ return;
242
+ disposed = true;
160
243
  sw?.removeEventListener("message", onServiceWorkerMessage);
244
+ const error = new Error("proxy app Window bridge is disposed");
245
+ for (const bridge of [...activeWebSocketBridges])
246
+ bridge.dispose(error);
161
247
  },
162
248
  };
163
249
  }