@floegence/flowersec-core 2.5.2 → 2.5.4

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.
@@ -9,6 +9,6 @@ type ReadExactlyFn = (n: number) => Promise<Uint8Array>;
9
9
  type ReadExactlyLike = Readonly<{
10
10
  readExactly: (n: number) => Promise<Uint8Array>;
11
11
  }>;
12
- export declare function writeJsonFrame(write: WriteFn | WriteLike, v: unknown): Promise<void>;
12
+ export declare function writeJsonFrame(write: WriteFn | WriteLike, v: unknown, maxBytes?: number): Promise<void>;
13
13
  export declare function readJsonFrame(readExactly: ReadExactlyFn | ReadExactlyLike, maxBytes: number): Promise<unknown>;
14
14
  export {};
@@ -13,8 +13,10 @@ function normalizeReadExactly(readExactly) {
13
13
  return typeof readExactly === "function" ? readExactly : (n) => readExactly.readExactly(n);
14
14
  }
15
15
  // writeJsonFrame encodes a JSON payload with a 4-byte length prefix.
16
- export async function writeJsonFrame(write, v) {
16
+ export async function writeJsonFrame(write, v, maxBytes = 0) {
17
17
  const json = te.encode(JSON.stringify(v));
18
+ if (maxBytes > 0 && json.length > maxBytes)
19
+ throw new JsonFramingError("frame too large");
18
20
  const hdr = u32be(json.length);
19
21
  const out = new Uint8Array(4 + json.length);
20
22
  out.set(hdr, 0);
@@ -1,5 +1,5 @@
1
1
  import { RpcRouter } from "../rpc/server.js";
2
- import { assertRpcError } from "../rpc/validate.js";
2
+ import { assertRpcError, assertRpcTypeId } from "../rpc/validate.js";
3
3
  import { acceptReceivedSessionV2, receiveSessionAdmissionV2, rejectSessionAdmissionV2, } from "../connector/sessionAcceptor.js";
4
4
  import { nodeSessionRuntimeV2 } from "./sessionRuntime.js";
5
5
  import { startNodeWebSocketServer, } from "./webSocketServer.js";
@@ -85,10 +85,13 @@ function registerNotification(state, typeId, handler) {
85
85
  state.notifications.set(typeId, handler);
86
86
  }
87
87
  function validateRPCRegistration(typeId, handler) {
88
- if (!Number.isSafeInteger(typeId)
89
- || typeId < 1
90
- || typeId > 0xffff_ffff
91
- || typeof handler !== "function") {
88
+ try {
89
+ assertRpcTypeId(typeId);
90
+ }
91
+ catch {
92
+ throw new HandlerRegistrationError("invalid_handler");
93
+ }
94
+ if (typeof handler !== "function") {
92
95
  throw new HandlerRegistrationError("invalid_handler");
93
96
  }
94
97
  }
@@ -443,12 +446,31 @@ export async function createAcceptor(options) {
443
446
  const abort = () => controller.abort(operation.signal?.reason);
444
447
  operation.signal?.addEventListener("abort", abort, { once: true });
445
448
  try {
446
- return await Promise.any(listeners.map(async (listener, index) => {
447
- const selected = listeners[(index + cursor) % listeners.length];
448
- return await selected.accept({ signal: controller.signal });
449
- })).finally(() => {
450
- cursor = (cursor + 1) % listeners.length;
451
- controller.abort();
449
+ return await new Promise((resolve, reject) => {
450
+ let settled = false;
451
+ let remaining = listeners.length;
452
+ const errors = [];
453
+ listeners.forEach((listener, index) => {
454
+ const selected = listeners[(index + cursor) % listeners.length];
455
+ void selected.accept({ signal: controller.signal }).then((carrier) => {
456
+ if (!settled) {
457
+ settled = true;
458
+ cursor = (cursor + 1) % listeners.length;
459
+ controller.abort();
460
+ resolve(carrier);
461
+ }
462
+ else {
463
+ carrier.abort({ code: 1000, reason: "accept race lost" });
464
+ }
465
+ }, (error) => {
466
+ errors.push(error);
467
+ remaining -= 1;
468
+ if (!settled && remaining === 0) {
469
+ settled = true;
470
+ reject(new AggregateError(errors, "all listeners failed to accept"));
471
+ }
472
+ });
473
+ });
452
474
  });
453
475
  }
454
476
  finally {
@@ -45,11 +45,34 @@ function normalizeTimeout(input) {
45
45
  }
46
46
  return input;
47
47
  }
48
+ class InvalidProxyPathError extends TypeError {
49
+ }
48
50
  function normalizePath(input) {
49
- if (input !== input.trim() || !input.startsWith("/") || input.startsWith("//") || /[\u0000-\u0020]/u.test(input) || input.includes("://")) {
50
- throw new TypeError("proxy path must be an origin-relative path");
51
+ if (input !== input.trim() || !input.startsWith("/") || input.startsWith("//") || /[\u0000-\u0020]/u.test(input) || input.includes("://") || input.includes("#")) {
52
+ throw new InvalidProxyPathError("proxy path must be an origin-relative path");
51
53
  }
52
- return input;
54
+ let parsed;
55
+ try {
56
+ parsed = new URL(input, "https://flowersec.invalid/");
57
+ }
58
+ catch {
59
+ throw new InvalidProxyPathError("proxy path must be an origin-relative path");
60
+ }
61
+ const pathname = normalizePercentEscapes(parsed.pathname, true).replace(/\/{2,}/gu, "/");
62
+ const search = normalizePercentEscapes(parsed.search, false);
63
+ return pathname + search;
64
+ }
65
+ function normalizePercentEscapes(input, rejectEncodedSeparators) {
66
+ if (/%(?![0-9a-f]{2})/iu.test(input))
67
+ throw new InvalidProxyPathError("proxy path contains invalid percent encoding");
68
+ return input.replace(/%([0-9a-f]{2})/giu, (_match, hex) => {
69
+ const value = Number.parseInt(hex, 16);
70
+ if (rejectEncodedSeparators && (value === 0x2f || value === 0x5c)) {
71
+ throw new InvalidProxyPathError("proxy path contains an encoded separator");
72
+ }
73
+ const character = String.fromCharCode(value);
74
+ return /[A-Za-z0-9\-._~]/u.test(character) ? character : `%${hex.toUpperCase()}`;
75
+ });
53
76
  }
54
77
  function pathName(path) {
55
78
  const query = path.indexOf("?");
@@ -176,11 +199,19 @@ class StreamAdmission {
176
199
  return Promise.reject(new SessionError("resource_exhausted"));
177
200
  }
178
201
  return new Promise((resolve, reject) => {
202
+ let cleaned = false;
203
+ const cleanup = () => {
204
+ if (cleaned)
205
+ return;
206
+ cleaned = true;
207
+ signal?.removeEventListener("abort", onAbort);
208
+ };
179
209
  const entry = {
180
210
  bytes,
181
211
  ...(signal === undefined ? {} : { signal }),
182
- resolve,
183
- reject: (error) => reject(error),
212
+ cleanup,
213
+ resolve: (release) => { cleanup(); resolve(release); },
214
+ reject: (error) => { cleanup(); reject(error); },
184
215
  };
185
216
  const onAbort = () => {
186
217
  const index = this.queue.indexOf(entry);
@@ -188,7 +219,7 @@ class StreamAdmission {
188
219
  return;
189
220
  this.queue.splice(index, 1);
190
221
  this.queuedBytes -= bytes;
191
- reject(new SessionError("canceled"));
222
+ entry.reject(new SessionError("canceled"));
192
223
  };
193
224
  signal?.addEventListener("abort", onAbort, { once: true });
194
225
  this.queue.push(entry);
@@ -251,6 +282,8 @@ async function* readChunks(reader, maxChunkBytes, maxBodyBytes) {
251
282
  function publicFailure(error) {
252
283
  if (error instanceof ProxyPolicyError)
253
284
  return { status: 403, code: "policy_denied", message: "proxy request denied" };
285
+ if (error instanceof InvalidProxyPathError)
286
+ return { status: 400, code: "invalid_request", message: "invalid proxy request" };
254
287
  if (error instanceof SessionError && (error.code === "resource_exhausted" || error.code === "closed" || error.code === "going_away")) {
255
288
  return { status: 503, code: error.code, message: "proxy service unavailable" };
256
289
  }
@@ -15,6 +15,8 @@ export type ProxyServiceWorkerScriptOptions = Readonly<{
15
15
  sameOriginOnly?: boolean;
16
16
  maxRequestBodyBytes?: number;
17
17
  maxInjectHTMLBytes?: number;
18
+ responseMetadataTimeoutMs?: number;
19
+ responseBodyInactivityTimeoutMs?: number;
18
20
  passthrough?: ProxyServiceWorkerPassthroughOptions;
19
21
  proxyPathPrefix?: string;
20
22
  stripProxyPathPrefix?: boolean;
@@ -17,6 +17,12 @@ function bounded(name, value, fallback, maximum) {
17
17
  throw new TypeError(`${name} is invalid`);
18
18
  return result;
19
19
  }
20
+ function optionalBounded(name, value, maximum) {
21
+ const result = value ?? 0;
22
+ if (!Number.isSafeInteger(result) || result < 0 || result > maximum)
23
+ throw new TypeError(`${name} is invalid`);
24
+ return result;
25
+ }
20
26
  function token(name, value, fallback = "") {
21
27
  const result = value ?? fallback;
22
28
  if (result !== result.trim() || /[\u0000-\u0020\u007f]/u.test(result) || result.length > 512)
@@ -42,6 +48,8 @@ function normalizeOptions(options) {
42
48
  sameOriginOnly: options.sameOriginOnly ?? true,
43
49
  maxRequestBodyBytes: bounded("maxRequestBodyBytes", options.maxRequestBodyBytes, 64 * 1024 * 1024, 256 * 1024 * 1024),
44
50
  maxInjectHTMLBytes: bounded("maxInjectHTMLBytes", options.maxInjectHTMLBytes, 8 * 1024 * 1024, 32 * 1024 * 1024),
51
+ responseMetadataTimeoutMs: bounded("responseMetadataTimeoutMs", options.responseMetadataTimeoutMs, 10_000, 300_000),
52
+ responseBodyInactivityTimeoutMs: optionalBounded("responseBodyInactivityTimeoutMs", options.responseBodyInactivityTimeoutMs, 300_000),
45
53
  passthroughPaths: strings("passthrough.paths", options.passthrough?.paths),
46
54
  passthroughPrefixes: strings("passthrough.prefixes", options.passthrough?.prefixes),
47
55
  proxyPathPrefix,
@@ -127,6 +135,8 @@ function serviceWorkerMain(config) {
127
135
  return new Response("proxy runtime unavailable", { status: 503 });
128
136
  }
129
137
  let body;
138
+ if (request.signal.aborted)
139
+ return new Response("proxy request canceled", { status: 499 });
130
140
  if (request.method !== "GET" && request.method !== "HEAD") {
131
141
  const requestBody = await request.clone().arrayBuffer();
132
142
  if (requestBody.byteLength > config.maxRequestBodyBytes)
@@ -138,16 +148,54 @@ function serviceWorkerMain(config) {
138
148
  let metadata = null;
139
149
  let controller = null;
140
150
  let finished = false;
141
- const finishError = (status, message) => {
151
+ let remoteAbortSent = false;
152
+ let bodyInactivityTimer;
153
+ const abortRemote = () => {
154
+ if (remoteAbortSent)
155
+ return;
156
+ remoteAbortSent = true;
157
+ try {
158
+ channel.port1.postMessage({ type: "flowersec-proxy:abort" });
159
+ }
160
+ catch { /* Port already failed. */ }
161
+ };
162
+ const cleanup = () => {
163
+ clearTimeout(metadataTimer);
164
+ clearTimeout(bodyInactivityTimer);
165
+ clearInterval(runtimeWatchdog);
166
+ request.signal.removeEventListener("abort", requestAborted);
167
+ channel.port1.close();
168
+ };
169
+ const finishError = (status, message, cancelRemote = false) => {
142
170
  if (finished)
143
171
  return;
144
172
  finished = true;
173
+ if (cancelRemote)
174
+ abortRemote();
145
175
  if (controller !== null)
146
176
  controller.error(new Error(message));
147
177
  else
148
178
  resolve(new Response(message, { status }));
149
- channel.port1.close();
179
+ cleanup();
180
+ };
181
+ const requestAborted = () => finishError(499, "proxy request canceled", true);
182
+ const armBodyInactivityTimer = () => {
183
+ clearTimeout(bodyInactivityTimer);
184
+ if (config.responseBodyInactivityTimeoutMs === 0)
185
+ return;
186
+ bodyInactivityTimer = setTimeout(() => finishError(504, "proxy response body timed out", true), config.responseBodyInactivityTimeoutMs);
150
187
  };
188
+ const metadataTimer = setTimeout(() => finishError(504, "proxy response timed out", true), config.responseMetadataTimeoutMs);
189
+ const runtimeWatchdog = setInterval(() => {
190
+ void worker.clients.get(target.id).then((current) => {
191
+ if (current !== null || finished)
192
+ return;
193
+ if (config.windowTarget === "registered_runtime")
194
+ runtimeClientId = "";
195
+ finishError(503, "proxy runtime unavailable", true);
196
+ }).catch(() => finishError(503, "proxy runtime unavailable", true));
197
+ }, 250);
198
+ request.signal.addEventListener("abort", requestAborted, { once: true });
151
199
  channel.port1.onmessage = (message) => {
152
200
  const value = message.data;
153
201
  if (value === null || typeof value !== "object" || finished)
@@ -162,13 +210,21 @@ function serviceWorkerMain(config) {
162
210
  headers.append(entry.name, entry.value);
163
211
  }
164
212
  metadata = { status: value.status, headers };
213
+ clearTimeout(metadataTimer);
214
+ armBodyInactivityTimer();
165
215
  const stream = new ReadableStream({
166
216
  start(valueController) {
167
217
  controller = valueController;
168
218
  channel.port1.postMessage({ type: "flowersec-proxy:response_credit" });
169
219
  },
170
220
  pull() { channel.port1.postMessage({ type: "flowersec-proxy:response_credit" }); },
171
- cancel() { channel.port1.postMessage({ type: "flowersec-proxy:abort" }); channel.port1.close(); },
221
+ cancel() {
222
+ if (finished)
223
+ return;
224
+ finished = true;
225
+ abortRemote();
226
+ cleanup();
227
+ },
172
228
  });
173
229
  resolve(new Response(stream, { status: metadata.status, headers: metadata.headers }));
174
230
  return;
@@ -177,19 +233,20 @@ function serviceWorkerMain(config) {
177
233
  if (controller === null || !(value.data instanceof ArrayBuffer))
178
234
  return finishError(502, "invalid proxy response");
179
235
  controller.enqueue(new Uint8Array(value.data));
236
+ armBodyInactivityTimer();
180
237
  return;
181
238
  }
182
239
  if (value.type === "flowersec-proxy:response_end") {
183
240
  finished = true;
184
241
  controller?.close();
185
- channel.port1.close();
242
+ cleanup();
186
243
  return;
187
244
  }
188
245
  if (value.type === "flowersec-proxy:response_error") {
189
246
  finishError(Number.isInteger(value.status) ? value.status : 502, typeof value.message === "string" ? value.message : "proxy request failed");
190
247
  }
191
248
  };
192
- channel.port1.onmessageerror = () => finishError(502, "proxy request failed");
249
+ channel.port1.onmessageerror = () => finishError(502, "proxy request failed", true);
193
250
  const headers = Array.from(request.headers.entries()).map(([name, value]) => ({ name, value }));
194
251
  try {
195
252
  target.postMessage({
@@ -207,8 +264,7 @@ function serviceWorkerMain(config) {
207
264
  catch {
208
265
  if (config.windowTarget === "registered_runtime")
209
266
  runtimeClientId = "";
210
- channel.port1.close();
211
- resolve(new Response("proxy runtime unavailable", { status: 503 }));
267
+ finishError(503, "proxy runtime unavailable");
212
268
  }
213
269
  });
214
270
  const injection = config.injectHTML;
@@ -12,6 +12,7 @@ export declare class MessagePortByteStream implements ByteStream {
12
12
  private buffered;
13
13
  private readBuffered;
14
14
  private ended;
15
+ private writeClosed;
15
16
  private closed;
16
17
  constructor(port: MessagePort);
17
18
  read(options?: OperationOptions): Promise<Uint8Array | null>;
@@ -22,6 +23,7 @@ export declare class MessagePortByteStream implements ByteStream {
22
23
  private handle;
23
24
  private finish;
24
25
  private fail;
26
+ private settleWrite;
25
27
  }
26
28
  export type RegisterProxyAppWindowOptions = Readonly<{
27
29
  controllerOrigin: string;
@@ -18,6 +18,7 @@ export class MessagePortByteStream {
18
18
  buffered = 0;
19
19
  readBuffered = 0;
20
20
  ended = false;
21
+ writeClosed = false;
21
22
  closed = false;
22
23
  constructor(port) {
23
24
  this.port = port;
@@ -38,12 +39,17 @@ export class MessagePortByteStream {
38
39
  if (this.terminalError !== undefined)
39
40
  throw this.terminalError;
40
41
  return await new Promise((resolve, reject) => {
41
- const waiter = { resolve, reject };
42
+ const cleanup = () => options.signal?.removeEventListener("abort", onAbort);
43
+ const waiter = {
44
+ resolve: (value) => { cleanup(); resolve(value); },
45
+ reject: (error) => { cleanup(); reject(error); },
46
+ };
42
47
  const onAbort = () => {
43
48
  const index = this.readWaiters.indexOf(waiter);
44
- if (index >= 0)
45
- this.readWaiters.splice(index, 1);
46
- reject(new SessionError("canceled"));
49
+ if (index < 0)
50
+ return;
51
+ this.readWaiters.splice(index, 1);
52
+ waiter.reject(new SessionError("canceled"));
47
53
  };
48
54
  options.signal?.addEventListener("abort", onAbort, { once: true });
49
55
  this.readWaiters.push(waiter);
@@ -52,6 +58,8 @@ export class MessagePortByteStream {
52
58
  async write(data, options = {}) {
53
59
  if (this.closed || this.terminalError !== undefined)
54
60
  throw this.terminalError ?? new SessionError("closed");
61
+ if (this.writeClosed)
62
+ throw new SessionError("operation_failed");
55
63
  if (options.signal?.aborted === true)
56
64
  throw new SessionError("canceled");
57
65
  if (data.length === 0)
@@ -63,24 +71,27 @@ export class MessagePortByteStream {
63
71
  const copy = data.slice();
64
72
  this.buffered += copy.length;
65
73
  await new Promise((resolve, reject) => {
74
+ const onAbort = () => this.settleWrite(id, new SessionError("canceled"));
66
75
  this.writeWaiters.set(id, {
67
- resolve: () => { this.buffered -= copy.length; resolve(); },
68
- reject: (error) => { this.buffered -= copy.length; reject(error); },
76
+ bytes: copy.length,
77
+ ...(options.signal === undefined ? {} : { signal: options.signal, onAbort }),
78
+ resolve,
79
+ reject,
69
80
  });
81
+ options.signal?.addEventListener("abort", onAbort, { once: true });
70
82
  try {
71
83
  this.port.postMessage({ type: "chunk", id, data: copy.buffer }, [copy.buffer]);
72
84
  }
73
85
  catch {
74
- this.writeWaiters.delete(id);
75
- this.buffered -= copy.length;
76
- reject(new SessionError("operation_failed"));
86
+ this.settleWrite(id, new SessionError("operation_failed"));
77
87
  }
78
88
  });
79
89
  return data.length;
80
90
  }
81
91
  async closeWrite() {
82
- if (this.closed)
92
+ if (this.closed || this.writeClosed)
83
93
  return;
94
+ this.writeClosed = true;
84
95
  this.port.postMessage({ type: "end" });
85
96
  }
86
97
  async reset() {
@@ -100,6 +111,10 @@ export class MessagePortByteStream {
100
111
  return;
101
112
  const message = value;
102
113
  if (message.type === "chunk") {
114
+ if (this.ended || this.closed) {
115
+ this.fail(new SessionError("operation_failed"));
116
+ return;
117
+ }
103
118
  if (!Number.isSafeInteger(message.id) || !(message.data instanceof ArrayBuffer) || message.data.byteLength > MAX_BRIDGE_CHUNK_BYTES) {
104
119
  this.fail(new SessionError("operation_failed"));
105
120
  return;
@@ -122,14 +137,12 @@ export class MessagePortByteStream {
122
137
  return;
123
138
  }
124
139
  if (message.type === "ack" && Number.isSafeInteger(message.id)) {
125
- const waiter = this.writeWaiters.get(message.id);
126
- if (waiter !== undefined) {
127
- this.writeWaiters.delete(message.id);
128
- waiter.resolve();
129
- }
140
+ this.settleWrite(message.id);
130
141
  return;
131
142
  }
132
143
  if (message.type === "end") {
144
+ if (this.ended)
145
+ return;
133
146
  this.ended = true;
134
147
  for (const waiter of this.readWaiters.splice(0))
135
148
  waiter.resolve(null);
@@ -149,9 +162,8 @@ export class MessagePortByteStream {
149
162
  this.readBuffered = 0;
150
163
  for (const waiter of this.readWaiters.splice(0))
151
164
  waiter.resolve(null);
152
- for (const waiter of this.writeWaiters.values())
153
- waiter.reject(new SessionError("closed"));
154
- this.writeWaiters.clear();
165
+ for (const id of [...this.writeWaiters.keys()])
166
+ this.settleWrite(id, new SessionError("closed"));
155
167
  this.port.close();
156
168
  }
157
169
  fail(error) {
@@ -163,11 +175,22 @@ export class MessagePortByteStream {
163
175
  this.readBuffered = 0;
164
176
  for (const waiter of this.readWaiters.splice(0))
165
177
  waiter.reject(error);
166
- for (const waiter of this.writeWaiters.values())
167
- waiter.reject(error);
168
- this.writeWaiters.clear();
178
+ for (const id of [...this.writeWaiters.keys()])
179
+ this.settleWrite(id, error);
169
180
  this.port.close();
170
181
  }
182
+ settleWrite(id, error) {
183
+ const waiter = this.writeWaiters.get(id);
184
+ if (waiter === undefined)
185
+ return;
186
+ this.writeWaiters.delete(id);
187
+ waiter.signal?.removeEventListener("abort", waiter.onAbort);
188
+ this.buffered = Math.max(0, this.buffered - waiter.bytes);
189
+ if (error === undefined)
190
+ waiter.resolve();
191
+ else
192
+ waiter.reject(error);
193
+ }
171
194
  }
172
195
  function bridgeLimits(maxWsFrameBytes, maxWsBufferedAmountBytes) {
173
196
  const wsFrame = maxWsFrameBytes ?? SDK_DEFAULTS.proxy.maxWsFrameBytes;
@@ -219,20 +242,39 @@ export function registerProxyAppWindow(options) {
219
242
  openWebSocketStream: async (path, openOptions = {}) => {
220
243
  if (disposed)
221
244
  throw new SessionError("closed");
245
+ if (openOptions.signal?.aborted === true)
246
+ throw new SessionError("canceled");
222
247
  const channel = new MessageChannel();
223
248
  const response = new Promise((resolve, reject) => {
224
- const timer = setTimeout(() => { channel.port1.close(); reject(new SessionError("timeout")); }, 10_000);
249
+ let settled = false;
250
+ const finish = (error, opened) => {
251
+ if (settled)
252
+ return;
253
+ settled = true;
254
+ clearTimeout(timer);
255
+ openOptions.signal?.removeEventListener("abort", abort);
256
+ if (error !== undefined) {
257
+ channel.port1.close();
258
+ reject(error);
259
+ }
260
+ else
261
+ resolve(opened);
262
+ };
263
+ const abort = () => {
264
+ channel.port1.postMessage({ type: "reset" });
265
+ finish(new SessionError("canceled"));
266
+ };
267
+ const timer = setTimeout(() => finish(new SessionError("timeout")), 10_000);
225
268
  channel.port1.onmessage = (event) => {
226
269
  if (event.data?.type !== WEBSOCKET_ACK_MESSAGE)
227
270
  return;
228
- clearTimeout(timer);
229
271
  if (event.data.ok !== true) {
230
- channel.port1.close();
231
- reject(new SessionError("operation_failed"));
272
+ finish(new SessionError("operation_failed"));
232
273
  return;
233
274
  }
234
- resolve(Object.freeze({ stream: new MessagePortByteStream(channel.port1), protocol: typeof event.data.protocol === "string" ? event.data.protocol : "" }));
275
+ finish(undefined, Object.freeze({ stream: new MessagePortByteStream(channel.port1), protocol: typeof event.data.protocol === "string" ? event.data.protocol : "" }));
235
276
  };
277
+ openOptions.signal?.addEventListener("abort", abort, { once: true });
236
278
  });
237
279
  controller.postMessage({
238
280
  type: WEBSOCKET_OPEN_MESSAGE,
@@ -241,9 +283,6 @@ export function registerProxyAppWindow(options) {
241
283
  protocols: openOptions.protocols ?? [],
242
284
  ...(nonce === undefined ? {} : { capabilityNonce: nonce }),
243
285
  }, origin, [channel.port2]);
244
- if (openOptions.signal !== undefined) {
245
- openOptions.signal.addEventListener("abort", () => channel.port1.close(), { once: true });
246
- }
247
286
  return await response;
248
287
  },
249
288
  dispose: () => { disposed = true; },
@@ -253,33 +292,65 @@ export function registerProxyAppWindow(options) {
253
292
  dispose: () => { disposed = true; },
254
293
  });
255
294
  }
256
- async function bridgeStreams(runtimeStream, port) {
295
+ async function bridgeStreams(runtimeStream, port, signal) {
257
296
  const bridge = new MessagePortByteStream(port);
297
+ const controller = new AbortController();
298
+ let resetTask;
299
+ const resetBoth = () => {
300
+ resetTask ??= Promise.allSettled([
301
+ Promise.resolve().then(async () => await runtimeStream.reset()),
302
+ Promise.resolve().then(async () => await bridge.reset()),
303
+ ]);
304
+ return resetTask;
305
+ };
306
+ const abort = () => {
307
+ controller.abort(signal?.reason ?? new SessionError("canceled"));
308
+ void resetBoth();
309
+ };
310
+ if (signal?.aborted === true)
311
+ abort();
312
+ else
313
+ signal?.addEventListener("abort", abort, { once: true });
258
314
  const left = (async () => {
259
315
  while (true) {
260
- const chunk = await runtimeStream.read();
316
+ const chunk = await runtimeStream.read({ signal: controller.signal });
261
317
  if (chunk === null) {
262
318
  await bridge.closeWrite();
263
319
  return;
264
320
  }
265
- await bridge.write(chunk);
321
+ await bridge.write(chunk, { signal: controller.signal });
266
322
  }
267
- })();
323
+ })().catch((error) => { controller.abort(error); void resetBoth(); throw error; });
268
324
  const right = (async () => {
269
325
  while (true) {
270
- const chunk = await bridge.read();
326
+ const chunk = await bridge.read({ signal: controller.signal });
271
327
  if (chunk === null) {
272
328
  await runtimeStream.closeWrite();
273
329
  return;
274
330
  }
275
331
  let offset = 0;
276
332
  while (offset < chunk.length)
277
- offset += await runtimeStream.write(chunk.subarray(offset));
333
+ offset += await runtimeStream.write(chunk.subarray(offset), { signal: controller.signal });
278
334
  }
279
- })();
280
- await Promise.all([left, right]);
281
- await runtimeStream.close();
282
- await bridge.close();
335
+ })().catch((error) => { controller.abort(error); void resetBoth(); throw error; });
336
+ try {
337
+ const settled = await Promise.allSettled([left, right]);
338
+ const failure = settled.find((result) => result.status === "rejected");
339
+ if (failure !== undefined) {
340
+ await resetBoth();
341
+ throw failure.reason;
342
+ }
343
+ try {
344
+ await Promise.all([runtimeStream.close(), bridge.close()]);
345
+ }
346
+ catch (error) {
347
+ await resetBoth();
348
+ throw error;
349
+ }
350
+ }
351
+ finally {
352
+ signal?.removeEventListener("abort", abort);
353
+ }
283
354
  }
284
355
  export function registerProxyControllerWindow(options) {
285
356
  const target = options.targetWindow ?? globalThis.window;
@@ -289,6 +360,7 @@ export function registerProxyControllerWindow(options) {
289
360
  }
290
361
  const nonce = capability(options.capabilityNonce);
291
362
  let disposed = false;
363
+ const active = new Set();
292
364
  const onMessage = (event) => {
293
365
  if (disposed || !allowed.has(event.origin) || (options.expectedSource !== undefined && event.source !== options.expectedSource))
294
366
  return;
@@ -303,22 +375,51 @@ export function registerProxyControllerWindow(options) {
303
375
  }
304
376
  if (event.data?.type === WEBSOCKET_OPEN_MESSAGE && event.data.version === 2) {
305
377
  void (async () => {
378
+ let canceled = false;
379
+ const openController = new AbortController();
380
+ active.add(openController);
381
+ port.onmessage = (message) => {
382
+ if (message.data?.type === "reset" ||
383
+ message.data?.type === "close") {
384
+ canceled = true;
385
+ openController.abort();
386
+ port.close();
387
+ }
388
+ };
306
389
  try {
307
390
  const opened = await options.runtime.openWebSocketStream(String(event.data.path ?? ""), {
308
391
  protocols: Array.isArray(event.data.protocols) ? event.data.protocols.map(String) : [],
392
+ signal: openController.signal,
309
393
  });
394
+ if (canceled) {
395
+ await opened.stream.reset();
396
+ return;
397
+ }
310
398
  port.postMessage({ type: WEBSOCKET_ACK_MESSAGE, ok: true, protocol: opened.protocol });
311
- await bridgeStreams(opened.stream, port);
399
+ await bridgeStreams(opened.stream, port, openController.signal);
312
400
  }
313
401
  catch {
314
- port.postMessage({ type: WEBSOCKET_ACK_MESSAGE, ok: false });
402
+ try {
403
+ port.postMessage({ type: WEBSOCKET_ACK_MESSAGE, ok: false });
404
+ }
405
+ catch { /* Port is already closed. */ }
315
406
  port.close();
316
407
  }
408
+ finally {
409
+ active.delete(openController);
410
+ }
317
411
  })();
318
412
  }
319
413
  };
320
414
  target.addEventListener("message", onMessage);
321
- return Object.freeze({ dispose: () => { disposed = true; target.removeEventListener("message", onMessage); } });
415
+ return Object.freeze({
416
+ dispose: () => {
417
+ disposed = true;
418
+ target.removeEventListener("message", onMessage);
419
+ for (const controller of active)
420
+ controller.abort(new SessionError("closed"));
421
+ },
422
+ });
322
423
  }
323
424
  export async function registerProxyAppWindowWithServiceWorkerControl(options) {
324
425
  await registerServiceWorkerAndEnsureControl({