@floegence/flowersec-core 0.23.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.
@@ -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";
@@ -8,10 +9,11 @@ import { decodeHandshakeFrame } from "../e2ee/framing.js";
8
9
  import { HANDSHAKE_TYPE_INIT, PROTOCOL_VERSION } from "../e2ee/constants.js";
9
10
  import { readStreamHello, writeStreamHello } from "../streamhello/streamHello.js";
10
11
  import { ByteReader } from "../yamux/byteReader.js";
12
+ import { isYamuxPingTimeoutError } from "../yamux/errors.js";
11
13
  import { YamuxSession } from "../yamux/session.js";
12
14
  import { RpcServer } from "../rpc/server.js";
13
15
  import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
14
- import { AbortError, FlowersecError } from "../utils/errors.js";
16
+ import { AbortError, FlowersecError, TimeoutError, throwIfAborted } from "../utils/errors.js";
15
17
  import { WebSocketBinaryTransport } from "../ws-client/binaryTransport.js";
16
18
  export class Session {
17
19
  secure;
@@ -43,9 +45,10 @@ export class Session {
43
45
  return session;
44
46
  }
45
47
  async openStream(kind, options = {}) {
48
+ const streamKind = normalizeStreamKind(kind, this.path);
46
49
  const stream = await this.mux.openStream(options);
47
50
  try {
48
- await writeStreamHello((bytes) => stream.write(bytes), normalizeStreamKind(kind, this.path));
51
+ await writeStreamHello((bytes) => stream.write(bytes), streamKind);
49
52
  return stream;
50
53
  }
51
54
  catch (error) {
@@ -82,8 +85,19 @@ export class Session {
82
85
  return;
83
86
  }
84
87
  }
85
- probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
86
- return this.mux.probeLiveness(timeoutMs);
88
+ async probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
89
+ try {
90
+ return await this.mux.probeLiveness(timeoutMs);
91
+ }
92
+ catch (error) {
93
+ throw new FlowersecError({
94
+ path: this.path,
95
+ stage: "yamux",
96
+ code: isYamuxPingTimeoutError(error) ? "timeout" : "ping_failed",
97
+ message: "endpoint liveness probe failed",
98
+ cause: error,
99
+ });
100
+ }
87
101
  }
88
102
  async rekey() {
89
103
  try {
@@ -139,15 +153,46 @@ export class Session {
139
153
  }
140
154
  }
141
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
+ }
142
165
  await enforceIncomingDirectTransport(options);
166
+ const deadline = createHandshakeDeadline(handshakeTimeoutMs);
143
167
  const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
144
- return await establishSession("direct", transport, handshake, options);
168
+ return await establishSession("direct", transport, handshake, options, undefined, deadline);
145
169
  }
146
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
+ }
147
180
  await enforceIncomingDirectTransport(options);
181
+ const deadline = createHandshakeDeadline(handshakeTimeoutMs);
148
182
  const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
149
- 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
+ }
150
194
  let init;
195
+ let channelId;
151
196
  try {
152
197
  const decoded = decodeHandshakeFrame(first, options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes);
153
198
  if (decoded.handshakeType !== HANDSHAKE_TYPE_INIT)
@@ -155,30 +200,44 @@ export async function acceptDirectResolved(websocket, resolver, options = {}) {
155
200
  init = JSON.parse(new TextDecoder().decode(decoded.payloadJsonUtf8));
156
201
  if (init.version !== PROTOCOL_VERSION || init.role !== 1 || (init.suite !== 1 && init.suite !== 2))
157
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
+ }
158
212
  }
159
213
  catch (error) {
160
214
  transport.close();
215
+ if (error instanceof FlowersecError)
216
+ throw error;
161
217
  throw new FlowersecError({ path: "direct", stage: "handshake", code: "handshake_failed", message: "invalid handshake init", cause: error });
162
218
  }
163
219
  let credential;
164
220
  try {
165
- credential = await resolver({
166
- channelId: prepareChannelId(init.channel_id, "direct"),
221
+ credential = await runHandshakeStep(deadline, options.signal, () => resolver({
222
+ channelId,
167
223
  version: init.version,
168
224
  suite: init.suite,
169
225
  clientFeatures: init.client_features >>> 0,
170
- });
226
+ }));
171
227
  }
172
228
  catch (error) {
173
229
  transport.close();
230
+ const interrupted = endpointHandshakeInterruption("direct", error);
231
+ if (interrupted != null)
232
+ throw interrupted;
174
233
  throw new FlowersecError({ path: "direct", stage: "validate", code: "resolve_failed", message: "credential resolution failed", cause: error });
175
234
  }
176
235
  const replay = new PrefetchedTransport(transport, first);
177
236
  return await establishSession("direct", replay, {
178
- channelId: init.channel_id,
237
+ channelId,
179
238
  suite: init.suite,
180
239
  ...credential,
181
- }, options);
240
+ }, options, undefined, deadline);
182
241
  }
183
242
  export async function connectTunnel(grantInput, options) {
184
243
  let grant;
@@ -189,6 +248,7 @@ export async function connectTunnel(grantInput, options) {
189
248
  throw new FlowersecError({ path: "tunnel", stage: "validate", code: "invalid_input", message: "invalid ChannelInitGrant", cause: error });
190
249
  }
191
250
  assertTunnelGrantContract(grant, ControlRole.Role_server);
251
+ const channelId = prepareChannelId(grant.channel_id, "tunnel");
192
252
  const tunnelUrl = grant.tunnel_url.trim();
193
253
  if (tunnelUrl === "")
194
254
  throw new FlowersecError({ path: "tunnel", stage: "validate", code: "missing_tunnel_url", message: "missing tunnel_url" });
@@ -199,35 +259,63 @@ export async function connectTunnel(grantInput, options) {
199
259
  const origin = options.origin.trim();
200
260
  if (origin === "")
201
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");
202
281
  await enforceTransportSecurity({ rawUrl: tunnelUrl, path: "tunnel", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
203
282
  const endpointInstanceId = normalizeEndpointInstanceId(options.endpointInstanceId);
204
- const websocket = options.wsFactory(tunnelUrl, origin);
205
- const transport = new WebSocketBinaryTransport(websocket, webSocketTransportOptions(options));
283
+ let transport;
206
284
  try {
207
- 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");
208
290
  const attach = {
209
291
  v: 1,
210
- channel_id: prepareChannelId(grant.channel_id, "tunnel"),
292
+ channel_id: channelId,
211
293
  role: TunnelRole.Role_server,
212
294
  token: grant.token.trim(),
213
295
  endpoint_instance_id: endpointInstanceId,
214
296
  };
215
297
  websocket.send(JSON.stringify(attach));
216
298
  return await establishSession("tunnel", transport, {
217
- channelId: grant.channel_id,
299
+ channelId,
218
300
  suite: grant.default_suite,
219
- psk: assertValidPSK(grant.e2ee_psk_b64u, "tunnel"),
301
+ psk,
220
302
  initExpireAtUnixS: grant.channel_init_expire_at_unix_s,
221
303
  }, options, endpointInstanceId);
222
304
  }
223
305
  catch (error) {
224
- transport.close();
306
+ transport?.close();
225
307
  if (error instanceof FlowersecError)
226
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
+ }
227
315
  throw new FlowersecError({ path: "tunnel", stage: "connect", code: "dial_failed", message: "endpoint tunnel connect failed", cause: error });
228
316
  }
229
317
  }
230
- async function establishSession(path, transport, handshake, options, endpointInstanceId) {
318
+ async function establishSession(path, transport, handshake, options, endpointInstanceId, deadline) {
231
319
  let psk;
232
320
  try {
233
321
  psk = normalizePSK(handshake.psk, path);
@@ -237,7 +325,7 @@ async function establishSession(path, transport, handshake, options, endpointIns
237
325
  throw error;
238
326
  }
239
327
  try {
240
- const secure = await serverHandshake(transport, options.handshakeCache ?? new ServerHandshakeCache(), {
328
+ const startHandshake = () => serverHandshake(transport, options.handshakeCache ?? new ServerHandshakeCache(), {
241
329
  channelId: prepareChannelId(handshake.channelId, path),
242
330
  suite: handshake.suite,
243
331
  psk,
@@ -247,27 +335,54 @@ async function establishSession(path, transport, handshake, options, endpointIns
247
335
  maxHandshakePayload: options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes,
248
336
  maxRecordBytes: options.maxRecordBytes ?? SDK_DEFAULTS.e2ee.maxRecordBytes,
249
337
  outboundRecordChunkBytes: options.outboundRecordChunkBytes ?? SDK_DEFAULTS.e2ee.outboundRecordChunkBytes,
250
- maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
338
+ maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes,
251
339
  maxOutboundBufferedBytes: options.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
252
- timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
340
+ timeoutMs: deadline == null
341
+ ? options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs
342
+ : remainingHandshakeTimeoutMs(deadline),
253
343
  ...(options.signal === undefined ? {} : { signal: options.signal }),
254
344
  });
345
+ const secure = deadline == null
346
+ ? await startHandshake()
347
+ : await runHandshakeStep(deadline, options.signal, startHandshake);
255
348
  try {
256
- 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
+ }
257
357
  }
258
358
  catch (error) {
259
359
  secure.close();
360
+ const interrupted = endpointHandshakeInterruption(path, error);
361
+ if (interrupted != null)
362
+ throw interrupted;
260
363
  throw new FlowersecError({ path, stage: "handshake", code: "credential_commit_failed", message: "credential commit failed", cause: error });
261
364
  }
262
- return Session.create(path, secure, {
263
- ...(options.yamuxLimits === undefined ? {} : { yamuxLimits: options.yamuxLimits }),
264
- ...(endpointInstanceId === undefined ? {} : { endpointInstanceId }),
265
- });
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
+ }
266
378
  }
267
379
  catch (error) {
268
380
  transport.close();
269
381
  if (error instanceof FlowersecError)
270
382
  throw error;
383
+ const interrupted = endpointHandshakeInterruption(path, error);
384
+ if (interrupted != null)
385
+ throw interrupted;
271
386
  throw new FlowersecError({ path, stage: "handshake", code: "handshake_failed", message: "endpoint handshake failed", cause: error });
272
387
  }
273
388
  finally {
@@ -327,7 +442,7 @@ function randomEndpointInstanceId() {
327
442
  function normalizeStreamKind(kind, path) {
328
443
  const value = kind.trim();
329
444
  if (value === "")
330
- throw new FlowersecError({ path, stage: "validate", code: "missing_stream_kind", message: "missing stream kind" });
445
+ throw new FlowersecError({ path, stage: "rpc", code: "missing_stream_kind", message: "missing stream kind" });
331
446
  return value;
332
447
  }
333
448
  function unwrapServerGrant(input) {
@@ -339,22 +454,25 @@ function unwrapServerGrant(input) {
339
454
  function webSocketTransportOptions(options) {
340
455
  return options.webSocketLimits === undefined ? {} : { webSocketLimits: options.webSocketLimits };
341
456
  }
342
- function readOptions(options) {
343
- return {
344
- timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
345
- ...(options.signal === undefined ? {} : { signal: options.signal }),
346
- };
347
- }
348
457
  async function enforceIncomingDirectTransport(options) {
349
458
  const rawUrl = options.secureTransport === false ? "ws://127.0.0.1/" : "wss://127.0.0.1/";
350
459
  await enforceTransportSecurity({ rawUrl, path: "direct", ...(options.transportSecurityPolicy === undefined ? {} : { policy: options.transportSecurityPolicy }) });
351
460
  }
352
461
  function waitForOpen(websocket, timeoutMs, signal) {
353
- if (websocket.readyState === 1)
354
- return Promise.resolve();
355
462
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
356
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"));
357
474
  return new Promise((resolve, reject) => {
475
+ let settled = false;
358
476
  let timer;
359
477
  const cleanup = () => {
360
478
  if (timer != null)
@@ -365,6 +483,9 @@ function waitForOpen(websocket, timeoutMs, signal) {
365
483
  signal?.removeEventListener("abort", onAbort);
366
484
  };
367
485
  const finish = (error) => {
486
+ if (settled)
487
+ return;
488
+ settled = true;
368
489
  cleanup();
369
490
  if (error == null)
370
491
  resolve();
@@ -379,14 +500,58 @@ function waitForOpen(websocket, timeoutMs, signal) {
379
500
  websocket.addEventListener("error", onError);
380
501
  websocket.addEventListener("close", onClose);
381
502
  signal?.addEventListener("abort", onAbort, { once: true });
382
- if (timeoutMs > 0)
383
- 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);
384
514
  });
385
515
  }
386
516
  function cleanupWaiter(waiter) {
387
517
  if (waiter.onAbort != null)
388
518
  waiter.signal?.removeEventListener("abort", waiter.onAbort);
389
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
+ }
390
555
  function asError(error) {
391
556
  return error instanceof Error ? error : new Error(String(error));
392
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
  }