@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.
@@ -4,6 +4,7 @@ export type WebSocketPatchOptions = Readonly<{
4
4
  shouldProxy?: (url: URL) => boolean;
5
5
  maxWsFrameBytes?: number;
6
6
  maxWsBufferedAmountBytes?: number;
7
+ closeHandshakeTimeoutMs?: number;
7
8
  }>;
8
9
  export declare function installWebSocketPatch(options: WebSocketPatchOptions): Readonly<{
9
10
  uninstall(): void;
@@ -64,6 +64,7 @@ export function installWebSocketPatch(options) {
64
64
  const runtimeLimits = options.runtime.limits;
65
65
  const maxFrameBytes = limit("maxWsFrameBytes", options.maxWsFrameBytes, runtimeLimits.maxWsFrameBytes ?? 1024 * 1024);
66
66
  const maxBufferedBytes = limit("maxWsBufferedAmountBytes", options.maxWsBufferedAmountBytes, runtimeLimits.maxWsBufferedAmountBytes ?? 4 * 1024 * 1024);
67
+ const closeHandshakeTimeoutMs = limit("closeHandshakeTimeoutMs", options.closeHandshakeTimeoutMs, 5_000);
67
68
  const shouldProxy = options.shouldProxy ?? ((url) => {
68
69
  const location = globalThis.location;
69
70
  if (location?.hostname === undefined || location.hostname === "")
@@ -95,6 +96,7 @@ export function installWebSocketPatch(options) {
95
96
  abort = new AbortController();
96
97
  stream;
97
98
  writes = Promise.resolve();
99
+ closeTimer;
98
100
  constructor(input, protocols) {
99
101
  const url = new URL(String(input), globalThis.location?.href);
100
102
  if (!shouldProxy(url))
@@ -152,12 +154,17 @@ export function installWebSocketPatch(options) {
152
154
  const reasonBytes = encoder.encode(reason);
153
155
  if (reasonBytes.length > 123)
154
156
  throw new DOMException("WebSocket close reason is too long", "SyntaxError");
157
+ if (this.readyState === ProxyWebSocket.CONNECTING || this.stream === undefined) {
158
+ this.fail();
159
+ return;
160
+ }
155
161
  this.readyState = ProxyWebSocket.CLOSING;
156
162
  const payload = code === undefined ? new Uint8Array() : new Uint8Array([...u16be(code), ...reasonBytes]);
157
- this.writes = this.writes.then(async () => {
158
- if (this.stream !== undefined)
159
- await writeFrame(this.stream, 8, payload, maxFrameBytes);
160
- }).catch(() => undefined).finally(() => this.abort.abort());
163
+ const stream = this.stream;
164
+ this.closeTimer = setTimeout(() => this.fail(), closeHandshakeTimeoutMs);
165
+ this.writes = this.writes
166
+ .then(async () => await writeFrame(stream, 8, payload, maxFrameBytes))
167
+ .catch(() => this.fail());
161
168
  }
162
169
  async connect(url, protocols) {
163
170
  try {
@@ -183,10 +190,19 @@ export function installWebSocketPatch(options) {
183
190
  this.writes = this.writes.then(async () => await writeFrame(stream, 10, frame.payload, maxFrameBytes)).catch(() => this.fail());
184
191
  }
185
192
  else if (frame.opcode === 8) {
193
+ if (frame.payload.length === 1)
194
+ throw new Error("invalid WebSocket close frame");
186
195
  const code = frame.payload.length >= 2 ? readU16(frame.payload) : 1000;
187
196
  const reason = frame.payload.length > 2 ? decoder.decode(frame.payload.subarray(2)) : "";
188
- this.readyState = ProxyWebSocket.CLOSED;
189
- this.emit("close", new CloseEvent("close", { code, reason, wasClean: true }));
197
+ const peerInitiated = this.readyState === ProxyWebSocket.OPEN;
198
+ this.readyState = ProxyWebSocket.CLOSING;
199
+ if (this.closeTimer !== undefined)
200
+ clearTimeout(this.closeTimer);
201
+ if (peerInitiated) {
202
+ this.writes = this.writes.then(async () => await writeFrame(stream, 8, frame.payload, maxFrameBytes));
203
+ }
204
+ await this.writes;
205
+ this.completeClose(code, reason);
190
206
  return;
191
207
  }
192
208
  else if (frame.opcode === 1) {
@@ -211,12 +227,29 @@ export function installWebSocketPatch(options) {
211
227
  fail() {
212
228
  if (this.readyState === ProxyWebSocket.CLOSED)
213
229
  return;
230
+ if (this.closeTimer !== undefined)
231
+ clearTimeout(this.closeTimer);
214
232
  this.readyState = ProxyWebSocket.CLOSED;
215
233
  this.bufferedAmount = 0;
216
234
  this.emit("error", new Event("error"));
217
235
  this.emit("close", new CloseEvent("close", { code: 1006, reason: "proxy WebSocket failed", wasClean: false }));
236
+ const stream = this.stream;
237
+ this.stream = undefined;
238
+ this.abort.abort();
239
+ void stream?.reset().catch(() => undefined);
240
+ }
241
+ completeClose(code, reason) {
242
+ if (this.readyState === ProxyWebSocket.CLOSED)
243
+ return;
244
+ if (this.closeTimer !== undefined)
245
+ clearTimeout(this.closeTimer);
246
+ this.readyState = ProxyWebSocket.CLOSED;
247
+ this.bufferedAmount = 0;
248
+ this.emit("close", new CloseEvent("close", { code, reason, wasClean: true }));
249
+ const stream = this.stream;
218
250
  this.stream = undefined;
219
251
  this.abort.abort();
252
+ void stream?.close().catch(() => stream.reset().catch(() => undefined));
220
253
  }
221
254
  }
222
255
  globalThis.WebSocket = ProxyWebSocket;
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../framing/jsonframe.js";
2
- import { assertRpcEnvelope } from "./validate.js";
2
+ import { assertRpcEnvelope, assertRpcTypeId } from "./validate.js";
3
3
  // Guard against precision loss when encoding request IDs as numbers.
4
4
  const MAX_SAFE_REQUEST_ID = BigInt(Number.MAX_SAFE_INTEGER);
5
5
  // RpcClient sends request/response envelopes and dispatches notifications.
@@ -25,12 +25,13 @@ export class RpcClient {
25
25
  async call(typeId, payload, signal) {
26
26
  if (this.closed)
27
27
  throw new Error("rpc client closed");
28
+ const validatedTypeId = assertRpcTypeId(typeId);
28
29
  if (this.nextId > MAX_SAFE_REQUEST_ID)
29
30
  throw new Error("request id overflow");
30
31
  const requestId = this.nextId;
31
32
  this.nextId += 1n;
32
33
  const env = {
33
- type_id: typeId >>> 0,
34
+ type_id: validatedTypeId,
34
35
  request_id: Number(requestId),
35
36
  response_to: 0,
36
37
  payload
@@ -39,7 +40,7 @@ export class RpcClient {
39
40
  this.pending.set(requestId, { resolve, reject });
40
41
  });
41
42
  try {
42
- await writeJsonFrame(this.write, env);
43
+ await writeJsonFrame(this.write, env, DEFAULT_MAX_JSON_FRAME_BYTES);
43
44
  }
44
45
  catch (e) {
45
46
  this.pending.delete(requestId);
@@ -71,7 +72,7 @@ export class RpcClient {
71
72
  }
72
73
  // onNotify registers a handler for incoming notifications.
73
74
  onNotify(typeId, handler) {
74
- const tid = typeId >>> 0;
75
+ const tid = assertRpcTypeId(typeId);
75
76
  const set = this.notifyHandlers.get(tid) ?? new Set();
76
77
  set.add(handler);
77
78
  this.notifyHandlers.set(tid, set);
@@ -86,13 +87,14 @@ export class RpcClient {
86
87
  async notify(typeId, payload) {
87
88
  if (this.closed)
88
89
  throw new Error("rpc client closed");
90
+ const validatedTypeId = assertRpcTypeId(typeId);
89
91
  const env = {
90
- type_id: typeId >>> 0,
92
+ type_id: validatedTypeId,
91
93
  request_id: 0,
92
94
  response_to: 0,
93
95
  payload
94
96
  };
95
- await writeJsonFrame(this.write, env);
97
+ await writeJsonFrame(this.write, env, DEFAULT_MAX_JSON_FRAME_BYTES);
96
98
  }
97
99
  async readLoop() {
98
100
  try {
@@ -101,7 +103,7 @@ export class RpcClient {
101
103
  if (v.response_to === 0) {
102
104
  // Notification: response_to=0 and request_id=0.
103
105
  if (v.request_id === 0) {
104
- const set = this.notifyHandlers.get(v.type_id >>> 0);
106
+ const set = this.notifyHandlers.get(v.type_id);
105
107
  if (set != null) {
106
108
  for (const h of set) {
107
109
  try {
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../framing/jsonframe.js";
2
- import { assertRpcEnvelope, assertRpcError } from "./validate.js";
2
+ import { assertRpcEnvelope, assertRpcError, assertRpcTypeId } from "./validate.js";
3
3
  import { SDK_DEFAULTS } from "../defaults.js";
4
4
  const DEFAULT_RPC_SERVER_OPTIONS = Object.freeze({
5
5
  maxConcurrentRequests: SDK_DEFAULTS.rpc.maxConcurrentRequests,
@@ -10,13 +10,13 @@ export class RpcRouter {
10
10
  handlers = new Map();
11
11
  notifyHandlers = new Map();
12
12
  register(typeId, handler) {
13
- this.handlers.set(typeId >>> 0, handler);
13
+ this.handlers.set(assertRpcTypeId(typeId), handler);
14
14
  }
15
15
  handler(typeId) {
16
- return this.handlers.get(typeId >>> 0);
16
+ return this.handlers.get(assertRpcTypeId(typeId));
17
17
  }
18
18
  onNotify(typeId, handler) {
19
- const normalized = typeId >>> 0;
19
+ const normalized = assertRpcTypeId(typeId);
20
20
  const handlers = this.notifyHandlers.get(normalized) ?? new Set();
21
21
  handlers.add(handler);
22
22
  this.notifyHandlers.set(normalized, handlers);
@@ -27,7 +27,7 @@ export class RpcRouter {
27
27
  };
28
28
  }
29
29
  async dispatchNotification(typeId, payload) {
30
- const normalized = typeId >>> 0;
30
+ const normalized = assertRpcTypeId(typeId);
31
31
  const requestHandler = this.handlers.get(normalized);
32
32
  if (requestHandler !== undefined)
33
33
  await requestHandler(payload);
@@ -76,7 +76,7 @@ export class RpcServer {
76
76
  if (this.closed)
77
77
  throw new Error("rpc server closed");
78
78
  await this.writeEnvelope({
79
- type_id: typeId >>> 0,
79
+ type_id: assertRpcTypeId(typeId),
80
80
  request_id: 0,
81
81
  response_to: 0,
82
82
  payload,
@@ -93,6 +93,7 @@ export class RpcServer {
93
93
  return await new Promise(() => undefined);
94
94
  }));
95
95
  let failure;
96
+ const aborted = abortPromise(signal);
96
97
  try {
97
98
  while (!this.closed) {
98
99
  if (signal?.aborted)
@@ -101,6 +102,7 @@ export class RpcServer {
101
102
  readJsonFrame(this.transport.readExactly, DEFAULT_MAX_JSON_FRAME_BYTES),
102
103
  this.terminalSignal.then((error) => { throw error; }),
103
104
  workerFailure,
105
+ ...(aborted === undefined ? [] : [aborted.promise]),
104
106
  ]);
105
107
  const v = assertRpcEnvelope(next);
106
108
  if (v.response_to !== 0)
@@ -126,6 +128,9 @@ export class RpcServer {
126
128
  failure = err;
127
129
  this.terminalError = err;
128
130
  }
131
+ finally {
132
+ aborted?.cleanup();
133
+ }
129
134
  let closeError;
130
135
  try {
131
136
  this.close(failure ?? this.terminalError ?? new Error("rpc server closed"));
@@ -173,19 +178,23 @@ export class RpcServer {
173
178
  if (work == null)
174
179
  return;
175
180
  const v = work.envelope;
176
- const h = this.router.handler(v.type_id);
177
- let out;
178
- if (h == null)
179
- out = { payload: null, error: { code: 404, message: "handler not found" } };
180
- else {
181
- try {
182
- out = await h(v.payload);
183
- }
184
- catch {
185
- out = { payload: null, error: { code: 500, message: "internal error" } };
186
- }
187
- }
188
181
  try {
182
+ const h = this.router.handler(v.type_id);
183
+ let out;
184
+ if (h == null)
185
+ out = { payload: null, error: { code: 404, message: "handler not found" } };
186
+ else {
187
+ const outcome = await Promise.race([
188
+ Promise.resolve().then(() => h(v.payload)).then((value) => ({ kind: "completed", value }), () => ({
189
+ kind: "completed",
190
+ value: { payload: null, error: { code: 500, message: "internal error" } },
191
+ })),
192
+ this.terminalSignal.then(() => ({ kind: "terminated" })),
193
+ ]);
194
+ if (outcome.kind === "terminated")
195
+ return;
196
+ out = outcome.value;
197
+ }
189
198
  if (this.closed)
190
199
  return;
191
200
  await this.writeResponse(v, out);
@@ -201,7 +210,12 @@ export class RpcServer {
201
210
  if (work == null)
202
211
  return;
203
212
  const v = work.envelope;
204
- await this.router.dispatchNotification(v.type_id, v.payload);
213
+ const completed = await Promise.race([
214
+ Promise.resolve().then(() => this.router.dispatchNotification(v.type_id, v.payload)).then(() => true),
215
+ this.terminalSignal.then(() => false),
216
+ ]);
217
+ if (!completed)
218
+ return;
205
219
  }
206
220
  }
207
221
  async nextWork(queue, waiters) {
@@ -236,7 +250,7 @@ export class RpcServer {
236
250
  await this.writeEnvelope(resp);
237
251
  }
238
252
  async writeEnvelope(envelope) {
239
- const write = this.writeChain.then(() => writeJsonFrame(this.transport.write, envelope));
253
+ const write = this.writeChain.then(() => writeJsonFrame(this.transport.write, envelope, DEFAULT_MAX_JSON_FRAME_BYTES));
240
254
  this.writeChain = write;
241
255
  try {
242
256
  await write;
@@ -247,6 +261,16 @@ export class RpcServer {
247
261
  }
248
262
  }
249
263
  }
264
+ function abortPromise(signal) {
265
+ if (signal === undefined || signal.aborted)
266
+ return undefined;
267
+ let onAbort;
268
+ const promise = new Promise((_resolve, reject) => {
269
+ onAbort = () => reject(signal.reason ?? new Error("aborted"));
270
+ signal.addEventListener("abort", onAbort, { once: true });
271
+ });
272
+ return { promise, cleanup: () => signal.removeEventListener("abort", onAbort) };
273
+ }
250
274
  function positiveInteger(value, name) {
251
275
  if (!Number.isSafeInteger(value) || value <= 0)
252
276
  throw new RangeError(`${name} must be a positive integer`);
@@ -1,3 +1,4 @@
1
1
  import type { RpcEnvelope, RpcError } from "./wire.js";
2
2
  export declare function assertRpcEnvelope(v: unknown): RpcEnvelope;
3
+ export declare function assertRpcTypeId(value: unknown): number;
3
4
  export declare function assertRpcError(value: unknown): RpcError;
@@ -6,21 +6,33 @@ const strictDecoder = new TextDecoder("utf-8", { fatal: true });
6
6
  // The wire format is JSON, so JS numbers are used. For u64 we enforce the safe integer range
7
7
  // to avoid silent precision loss on request/response correlation.
8
8
  export function assertRpcEnvelope(v) {
9
- if (typeof v !== "object" || v == null)
9
+ if (typeof v !== "object" || v == null || Array.isArray(v))
10
10
  throw new Error("bad rpc envelope");
11
11
  const o = v;
12
- if (!isSafeU32Number(o.type_id))
13
- throw new Error("bad rpc envelope: type_id");
12
+ const keys = Object.keys(o);
13
+ if (keys.some((key) => key !== "type_id" && key !== "request_id" && key !== "response_to" && key !== "payload" && key !== "error")) {
14
+ throw new Error("bad rpc envelope: shape");
15
+ }
16
+ if (!Object.prototype.hasOwnProperty.call(o, "payload"))
17
+ throw new Error("bad rpc envelope: payload");
18
+ assertRpcTypeId(o.type_id);
14
19
  if (!isSafeU64Number(o.request_id))
15
20
  throw new Error("bad rpc envelope: request_id");
16
21
  if (!isSafeU64Number(o.response_to))
17
22
  throw new Error("bad rpc envelope: response_to");
18
- // payload: unknown (JSON)
19
- if (o.error != null) {
23
+ if (o.request_id !== 0 && o.response_to !== 0)
24
+ throw new Error("bad rpc envelope: request/response shape");
25
+ if (o.error != null && o.response_to === 0)
26
+ throw new Error("bad rpc envelope: error shape");
27
+ if (o.error != null)
20
28
  assertRpcError(o.error);
21
- }
22
29
  return o;
23
30
  }
31
+ export function assertRpcTypeId(value) {
32
+ if (!isSafeU32Number(value) || value === 0)
33
+ throw new RangeError("RPC typeId must be a non-zero u32 integer");
34
+ return value;
35
+ }
24
36
  export function assertRpcError(value) {
25
37
  if (typeof value !== "object" || value == null)
26
38
  throw new Error("bad rpc envelope: error");
@@ -327,6 +327,7 @@ class WebTransportDatagramAdapter {
327
327
  this.terminalError = error;
328
328
  this.incoming.fail(error);
329
329
  void this.writable.abort(error).catch(() => undefined);
330
+ void this.readable.cancel(error).catch(() => undefined);
330
331
  }
331
332
  assertOpen() {
332
333
  if (this.terminalError !== undefined)
@@ -416,15 +417,26 @@ class IncomingWebTransportStreamQueue {
416
417
  }
417
418
  }
418
419
  class IncomingDatagramQueue {
420
+ static MAX_VALUES = 256;
421
+ static MAX_BYTES = 4 * 1024 * 1024;
419
422
  values = [];
423
+ bufferedBytes = 0;
420
424
  waiters = new Set();
421
425
  terminalError;
422
426
  push(value) {
423
427
  if (this.terminalError !== undefined)
424
428
  return;
425
429
  const waiter = this.waiters.values().next().value;
426
- if (waiter === undefined)
430
+ if (waiter === undefined) {
431
+ // DATAGRAMs are lossy by contract. Drop an incoming value once the
432
+ // bounded queue budget is exhausted, while keeping the native reader
433
+ // drained so an unconsumed peer cannot grow memory without bound.
434
+ if (this.values.length >= IncomingDatagramQueue.MAX_VALUES ||
435
+ this.bufferedBytes + value.byteLength > IncomingDatagramQueue.MAX_BYTES)
436
+ return;
427
437
  this.values.push(value);
438
+ this.bufferedBytes += value.byteLength;
439
+ }
428
440
  else
429
441
  waiter.deliver(value);
430
442
  }
@@ -434,8 +446,10 @@ class IncomingDatagramQueue {
434
446
  if (this.terminalError !== undefined)
435
447
  return Promise.reject(this.terminalError);
436
448
  const value = this.values.shift();
437
- if (value !== undefined)
449
+ if (value !== undefined) {
450
+ this.bufferedBytes -= value.byteLength;
438
451
  return Promise.resolve(value);
452
+ }
439
453
  return new Promise((resolve, reject) => {
440
454
  let settled = false;
441
455
  const cleanup = () => {
@@ -470,6 +484,7 @@ class IncomingDatagramQueue {
470
484
  return;
471
485
  this.terminalError = error;
472
486
  this.values.length = 0;
487
+ this.bufferedBytes = 0;
473
488
  for (const waiter of [...this.waiters])
474
489
  waiter.fail(error);
475
490
  }
@@ -1,5 +1,6 @@
1
1
  import { SessionError } from "./contract.js";
2
2
  import { createStreamMetadataV2, streamMetadataValuesV2 } from "./streamMetadata.js";
3
+ import { assertRpcTypeId } from "../rpc/validate.js";
3
4
  /** @internal */
4
5
  export function projectSessionV2(session) {
5
6
  const notificationOwner = {
@@ -132,6 +133,7 @@ function projectRpcPeerV2(peer, notificationOwner) {
132
133
  return Object.freeze({
133
134
  async call(typeId, payload, decodeResponse, options) {
134
135
  try {
136
+ assertRpcTypeId(typeId);
135
137
  assertJsonValue(payload);
136
138
  const result = await peer.call(typeId, payload, options?.signal);
137
139
  if (result.error !== undefined) {
@@ -146,6 +148,7 @@ function projectRpcPeerV2(peer, notificationOwner) {
146
148
  },
147
149
  async notify(typeId, payload, options) {
148
150
  try {
151
+ assertRpcTypeId(typeId);
149
152
  assertJsonValue(payload);
150
153
  if (options?.signal?.aborted)
151
154
  throw options.signal.reason ?? new DOMException("The operation was aborted", "AbortError");
@@ -156,6 +159,7 @@ function projectRpcPeerV2(peer, notificationOwner) {
156
159
  }
157
160
  },
158
161
  onNotify(typeId, decodePayload, handler) {
162
+ assertRpcTypeId(typeId);
159
163
  if (notificationOwner.closed)
160
164
  return () => undefined;
161
165
  const unsubscribe = peer.onNotify(typeId, (payload) => {
@@ -132,7 +132,7 @@ export declare class SessionV2 implements SessionV2Contract {
132
132
  private peerResponderFrozen;
133
133
  private responderChanged;
134
134
  private idleWatchdogStarted;
135
- private idleTimer;
135
+ private idleTimerCancel;
136
136
  private readonly terminationState;
137
137
  private readonly rpcRouter;
138
138
  constructor(carrier: CarrierSessionV2, control: CarrierStreamV2, controlReader: ExactReader, config: SessionConfigV2, material: HandshakeMaterial);
@@ -137,7 +137,7 @@ export class SessionV2 {
137
137
  peerResponderFrozen = false;
138
138
  responderChanged = deferred();
139
139
  idleWatchdogStarted = false;
140
- idleTimer;
140
+ idleTimerCancel;
141
141
  terminationState = deferred();
142
142
  rpcRouter;
143
143
  constructor(carrier, control, controlReader, config, material) {
@@ -1017,10 +1017,9 @@ export class SessionV2 {
1017
1017
  const timeoutMs = sessionIdleTimeoutMs(this.config);
1018
1018
  if (timeoutMs === 0)
1019
1019
  return;
1020
- if (this.idleTimer !== undefined)
1021
- clearTimeout(this.idleTimer);
1022
- this.idleTimer = setTimeout(() => {
1023
- this.idleTimer = undefined;
1020
+ this.idleTimerCancel?.();
1021
+ this.idleTimerCancel = scheduleLongTimeout(() => {
1022
+ this.idleTimerCancel = undefined;
1024
1023
  this.fail(new SessionV2Error("timeout", "Flowersec v2 session idle timeout exceeded"));
1025
1024
  }, timeoutMs);
1026
1025
  }
@@ -1034,10 +1033,8 @@ export class SessionV2 {
1034
1033
  this.terminalError = error;
1035
1034
  this.lifecycle = "closed";
1036
1035
  this.terminationState.resolve({ error });
1037
- if (this.idleTimer !== undefined) {
1038
- clearTimeout(this.idleTimer);
1039
- this.idleTimer = undefined;
1040
- }
1036
+ this.idleTimerCancel?.();
1037
+ this.idleTimerCancel = undefined;
1041
1038
  this.incoming.fail(error);
1042
1039
  this.outboundPermits.fail(error);
1043
1040
  this.inboundPermits.fail(error);
@@ -1227,6 +1224,10 @@ class EncryptedStreamV2 {
1227
1224
  if (this.terminalError !== undefined)
1228
1225
  return false;
1229
1226
  this.terminalError = error;
1227
+ const pendingSendRekey = this.pendingSendRekey;
1228
+ this.pendingSendRekey = undefined;
1229
+ pendingSendRekey?.armed.reject(error);
1230
+ pendingSendRekey?.done.reject(error);
1230
1231
  this.opened.reject(error);
1231
1232
  this.data.fail(error);
1232
1233
  return true;
@@ -1988,12 +1989,38 @@ function createSessionDeadline(config, phase) {
1988
1989
  }
1989
1990
  function defaultDeadlineFactory(timeoutMs, phase) {
1990
1991
  const controller = new AbortController();
1991
- const timer = setTimeout(() => {
1992
+ const cancelTimer = scheduleLongTimeout(() => {
1992
1993
  controller.abort(new SessionV2Error("timeout", `${phase} deadline exceeded`));
1993
1994
  }, timeoutMs);
1994
1995
  return {
1995
1996
  signal: controller.signal,
1996
- cancel: () => clearTimeout(timer),
1997
+ cancel: cancelTimer,
1998
+ };
1999
+ }
2000
+ const MAX_NATIVE_TIMEOUT_MS = 2_147_483_647;
2001
+ // Node and browsers clamp setTimeout delays above a signed 32-bit integer.
2002
+ // Keep the public timeout range intact by scheduling the remaining duration in
2003
+ // bounded segments against a monotonic wall-clock deadline.
2004
+ function scheduleLongTimeout(callback, timeoutMs) {
2005
+ const clock = () => typeof performance !== "undefined" ? performance.now() : Date.now();
2006
+ const deadline = clock() + timeoutMs;
2007
+ let timer;
2008
+ let canceled = false;
2009
+ const schedule = () => {
2010
+ if (canceled)
2011
+ return;
2012
+ const remaining = deadline - clock();
2013
+ if (remaining <= 0) {
2014
+ callback();
2015
+ return;
2016
+ }
2017
+ timer = setTimeout(schedule, Math.min(remaining, MAX_NATIVE_TIMEOUT_MS));
2018
+ };
2019
+ schedule();
2020
+ return () => {
2021
+ canceled = true;
2022
+ if (timer !== undefined)
2023
+ clearTimeout(timer);
1997
2024
  };
1998
2025
  }
1999
2026
  function combineSignals(...signals) {
@@ -12,6 +12,8 @@ export declare class YamuxStream {
12
12
  private readWaiters;
13
13
  private error;
14
14
  private resetTask;
15
+ private closeTask;
16
+ private closeRequested;
15
17
  private writeChain;
16
18
  private writeQueueBytes;
17
19
  private finalized;
@@ -23,6 +25,7 @@ export declare class YamuxStream {
23
25
  write(data: Uint8Array): Promise<void>;
24
26
  private writeSerial;
25
27
  close(): Promise<void>;
28
+ private closeSerial;
26
29
  reset(err?: Error): Promise<void>;
27
30
  abort(err?: Error): void;
28
31
  private fail;
@@ -24,6 +24,8 @@ export class YamuxStream {
24
24
  // Terminal error (reset/overflow) for the stream.
25
25
  error = null;
26
26
  resetTask;
27
+ closeTask;
28
+ closeRequested = false;
27
29
  writeChain = Promise.resolve();
28
30
  writeQueueBytes = 0;
29
31
  finalized = false;
@@ -89,6 +91,8 @@ export class YamuxStream {
89
91
  }
90
92
  // write sends DATA frames, respecting the send window.
91
93
  async write(data) {
94
+ if (this.closeRequested)
95
+ throw new Error("stream closed");
92
96
  this.ensureWritable();
93
97
  const byteCount = data.byteLength;
94
98
  const nextQueueBytes = this.writeQueueBytes + byteCount;
@@ -129,10 +133,19 @@ export class YamuxStream {
129
133
  }
130
134
  }
131
135
  // close sends FIN and transitions to local close.
132
- async close() {
133
- if (this.state === "closed")
134
- return;
135
- if (this.state === "reset")
136
+ close() {
137
+ if (this.closeTask !== undefined)
138
+ return this.closeTask;
139
+ if (this.state === "closed" || this.state === "reset" || this.state === "localClose")
140
+ return Promise.resolve();
141
+ this.closeRequested = true;
142
+ const task = this.writeChain.then(async () => await this.closeSerial());
143
+ this.writeChain = task.catch(() => undefined);
144
+ this.closeTask = task;
145
+ return task;
146
+ }
147
+ async closeSerial() {
148
+ if (this.state === "closed" || this.state === "reset" || this.state === "localClose")
136
149
  return;
137
150
  const wasRemoteClose = this.state === "remoteClose";
138
151
  const flags = this.sendFlags() | FLAG_FIN;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "2.5.2",
3
+ "version": "2.5.4",
4
4
  "description": "Flowersec core TypeScript library for carrier-neutral encrypted sessions and multiplexed streams.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -77,7 +77,7 @@
77
77
  "ws": "^8.21.2"
78
78
  },
79
79
  "optionalDependencies": {
80
- "@floegence/flowersec-node-native": "2.5.2"
80
+ "@floegence/flowersec-node-native": "2.5.4"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@playwright/test": "1.62.1",
@@ -95,5 +95,5 @@
95
95
  "vite": "^8.2.1",
96
96
  "vitest": "4.1.10"
97
97
  },
98
- "flowersecSourceCommit": "2aca169b79bc65a1e1ff51ca5356d0ed5dcbf18a"
98
+ "flowersecSourceCommit": "026cb52d116d2a04de50d0f0621fff57c7657120"
99
99
  }
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:3a83166e-0cec-5e55-814a-79ffaf005d24",
4
+ "serialNumber": "urn:uuid:9db05d70-0170-5669-8eb4-881e06b18415",
5
5
  "version": 1,
6
6
  "metadata": {
7
7
  "component": {
8
8
  "type": "library",
9
9
  "name": "@floegence/flowersec-core",
10
- "version": "2.5.2",
11
- "purl": "pkg:npm/%40floegence/flowersec-core@2.5.2",
12
- "bom-ref": "pkg:npm/%40floegence/flowersec-core@2.5.2"
10
+ "version": "2.5.4",
11
+ "purl": "pkg:npm/%40floegence/flowersec-core@2.5.4",
12
+ "bom-ref": "pkg:npm/%40floegence/flowersec-core@2.5.4"
13
13
  },
14
14
  "properties": [
15
15
  {
16
16
  "name": "flowersec:source-inventory-sha256",
17
- "value": "8d179ba65d4598bdeb169b3dbcc8732a8e0aa8fc724f228eacbb117ad49ce149"
17
+ "value": "308ce3a2dd421245b940297e16fbb6cae9cc3d79e4835164e4304b5f7acd5ca8"
18
18
  }
19
19
  ]
20
20
  },
@@ -190,7 +190,7 @@
190
190
  ],
191
191
  "dependencies": [
192
192
  {
193
- "ref": "pkg:npm/%40floegence/flowersec-core@2.5.2",
193
+ "ref": "pkg:npm/%40floegence/flowersec-core@2.5.4",
194
194
  "dependsOn": [
195
195
  "pkg:npm/%40noble/ciphers@2.3.0",
196
196
  "pkg:npm/%40noble/curves@2.3.0",