@floegence/flowersec-core 0.20.1 → 0.20.2

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.
@@ -24,6 +24,7 @@ export class YamuxSession {
24
24
  // Maximum allowed DATA frame length.
25
25
  limits;
26
26
  onDiagnostic;
27
+ onTerminal;
27
28
  // True when this side is the yamux client (odd local stream IDs).
28
29
  client;
29
30
  // Next stream ID to allocate (odd/even based on role).
@@ -39,25 +40,21 @@ export class YamuxSession {
39
40
  activeProbe;
40
41
  constructor(conn, opts) {
41
42
  this.conn = conn;
42
- this.reader = new ByteReader(async () => {
43
- try {
44
- return await this.conn.read();
45
- }
46
- catch {
47
- return null;
48
- }
49
- });
43
+ this.reader = new ByteReader(() => this.conn.read());
50
44
  this.onIncomingStream = opts.onIncomingStream;
51
45
  this.limits = normalizeYamuxLimits({ ...opts.limits, ...(opts.maxFrameBytes === undefined ? {} : { maxFrameBytes: opts.maxFrameBytes }) });
52
46
  this.onDiagnostic = opts.onDiagnostic;
47
+ this.onTerminal = opts.onTerminal;
53
48
  this.client = opts.client;
54
49
  this.nextStreamId = opts.client ? 1 : 2;
55
50
  void this.readLoop();
56
51
  }
57
52
  // openStream allocates a new stream and performs the SYN handshake.
58
- async openStream() {
53
+ async openStream(opts = {}) {
59
54
  if (this.closed)
60
55
  throw new Error("session closed");
56
+ if (opts.signal?.aborted)
57
+ throw abortError(opts.signal);
61
58
  if (this.streams.size >= this.limits.maxActiveStreams) {
62
59
  const error = new YamuxResourceExhaustedError("active_streams", this.streams.size, this.limits.maxActiveStreams);
63
60
  this.diagnostic({ code: "resource_limit_reached", resource: error.resource, current: error.current, limit: error.limit });
@@ -68,7 +65,9 @@ export class YamuxSession {
68
65
  const s = new YamuxStream(this, id, "init");
69
66
  this.streams.set(id, s);
70
67
  try {
71
- await s.open();
68
+ await raceAbort(s.open(), opts.signal, () => {
69
+ void s.reset(abortError(opts.signal));
70
+ });
72
71
  return s;
73
72
  }
74
73
  catch (error) {
@@ -82,7 +81,13 @@ export class YamuxSession {
82
81
  }
83
82
  // writeRaw writes a raw yamux frame to the underlying connection.
84
83
  async writeRaw(chunk) {
85
- await this.conn.write(chunk);
84
+ try {
85
+ await this.conn.write(chunk);
86
+ }
87
+ catch (error) {
88
+ this.fail(error);
89
+ throw error;
90
+ }
86
91
  }
87
92
  outboundFrameBytes() {
88
93
  return this.limits.preferredOutboundFrameBytes;
@@ -119,8 +124,9 @@ export class YamuxSession {
119
124
  return await new Promise((resolve, reject) => {
120
125
  const timer = setTimeout(() => {
121
126
  this.pingWaiters.delete(opaque);
122
- reject(new Error("yamux ping timeout"));
123
- this.close();
127
+ const error = new Error("yamux ping timeout");
128
+ reject(error);
129
+ this.fail(error);
124
130
  }, timeoutMs);
125
131
  timer?.unref?.();
126
132
  this.pingWaiters.set(opaque, { startedAt, resolve, reject, timer });
@@ -132,14 +138,15 @@ export class YamuxSession {
132
138
  this.pingWaiters.delete(opaque);
133
139
  clearTimeout(waiter.timer);
134
140
  reject(err);
141
+ this.fail(err);
135
142
  });
136
143
  });
137
144
  }
138
145
  // sendRst sends a reset frame and removes the stream.
139
146
  async sendRst(id) {
140
147
  const hdr = encodeHeader({ type: TYPE_WINDOW_UPDATE, flags: FLAG_RST, streamId: id, length: 0 });
148
+ this.onStreamClosed(id);
141
149
  if (this.closed) {
142
- this.onStreamClosed(id);
143
150
  return;
144
151
  }
145
152
  try {
@@ -148,7 +155,6 @@ export class YamuxSession {
148
155
  catch {
149
156
  // Best-effort reset; ignore errors when the session is closing.
150
157
  }
151
- this.onStreamClosed(id);
152
158
  }
153
159
  // notifySendWindow wakes any writers waiting on window credit.
154
160
  notifySendWindow(streamId) {
@@ -191,6 +197,21 @@ export class YamuxSession {
191
197
  }
192
198
  // close terminates the session and resets all streams.
193
199
  close() {
200
+ this.closeInternal();
201
+ }
202
+ fail(error) {
203
+ if (this.closed)
204
+ return;
205
+ const terminalError = error instanceof Error ? error : new Error(String(error));
206
+ this.closeInternal();
207
+ try {
208
+ this.onTerminal?.(terminalError);
209
+ }
210
+ catch {
211
+ // Lifecycle callbacks must not affect session shutdown.
212
+ }
213
+ }
214
+ closeInternal() {
194
215
  if (this.closed)
195
216
  return;
196
217
  this.closed = true;
@@ -219,16 +240,16 @@ export class YamuxSession {
219
240
  const hdrBytes = await this.reader.readExactly(HEADER_LEN);
220
241
  const h = decodeHeader(hdrBytes, 0);
221
242
  if (h.version !== YAMUX_VERSION) {
222
- this.close();
243
+ this.fail(new Error("unsupported yamux version"));
223
244
  return;
224
245
  }
225
246
  if (h.type === TYPE_DATA) {
226
247
  if (h.length > this.limits.maxFrameBytes) {
227
- this.close();
248
+ this.fail(new Error("yamux frame exceeds limit"));
228
249
  return;
229
250
  }
230
251
  if (h.streamId === 0) {
231
- this.close();
252
+ this.fail(new Error("yamux data frame has stream id zero"));
232
253
  return;
233
254
  }
234
255
  const existing = this.streams.get(h.streamId);
@@ -255,7 +276,7 @@ export class YamuxSession {
255
276
  }
256
277
  if (this.sessionReceiveBytes + h.length > this.limits.maxSessionReceiveBytes) {
257
278
  this.diagnostic({ code: "resource_limit_reached", resource: "session_receive_bytes", current: this.sessionReceiveBytes + h.length, limit: this.limits.maxSessionReceiveBytes });
258
- this.close();
279
+ this.fail(new Error("yamux session receive limit reached"));
259
280
  return;
260
281
  }
261
282
  const data = h.length > 0 ? await this.reader.readExactly(h.length) : new Uint8Array();
@@ -275,15 +296,15 @@ export class YamuxSession {
275
296
  continue;
276
297
  }
277
298
  if (h.type === TYPE_GO_AWAY) {
278
- this.close();
299
+ this.fail(new Error("yamux peer sent go away"));
279
300
  return;
280
301
  }
281
- this.close();
302
+ this.fail(new Error("unsupported yamux frame type"));
282
303
  return;
283
304
  }
284
305
  }
285
- catch {
286
- this.close();
306
+ catch (error) {
307
+ this.fail(error);
287
308
  }
288
309
  }
289
310
  async handlePing(flags, opaque) {
@@ -303,7 +324,7 @@ export class YamuxSession {
303
324
  }
304
325
  async handleDataFrame(streamId, flags, data) {
305
326
  if (streamId === 0) {
306
- this.close();
327
+ this.fail(new Error("yamux data frame has stream id zero"));
307
328
  return false;
308
329
  }
309
330
  let s = this.streams.get(streamId);
@@ -332,7 +353,7 @@ export class YamuxSession {
332
353
  }
333
354
  async handleWindowUpdateFrame(streamId, flags, delta) {
334
355
  if (streamId === 0) {
335
- this.close();
356
+ this.fail(new Error("yamux window update has stream id zero"));
336
357
  return;
337
358
  }
338
359
  let s = this.streams.get(streamId);
@@ -358,7 +379,7 @@ export class YamuxSession {
358
379
  }
359
380
  }
360
381
  if (!s.onWindowUpdate(delta, flags)) {
361
- this.close();
382
+ this.fail(new Error("yamux send window overflow"));
362
383
  }
363
384
  }
364
385
  isInboundStreamIdValid(streamId) {
@@ -376,6 +397,36 @@ export class YamuxSession {
376
397
  catch { /* Diagnostics cannot affect transport behavior. */ }
377
398
  }
378
399
  }
400
+ function abortError(signal) {
401
+ const reason = signal.reason;
402
+ if (reason instanceof Error)
403
+ return reason;
404
+ if (typeof reason === "string" && reason !== "")
405
+ return new Error(reason);
406
+ return new Error("yamux open stream aborted");
407
+ }
408
+ async function raceAbort(promise, signal, onAbort) {
409
+ if (signal == null)
410
+ return await promise;
411
+ if (signal.aborted) {
412
+ onAbort();
413
+ throw abortError(signal);
414
+ }
415
+ return await new Promise((resolve, reject) => {
416
+ const abort = () => {
417
+ onAbort();
418
+ reject(abortError(signal));
419
+ };
420
+ signal.addEventListener("abort", abort, { once: true });
421
+ void promise.then((value) => {
422
+ signal.removeEventListener("abort", abort);
423
+ resolve(value);
424
+ }, (error) => {
425
+ signal.removeEventListener("abort", abort);
426
+ reject(error);
427
+ });
428
+ });
429
+ }
379
430
  function normalizeYamuxLimits(input) {
380
431
  const maxFrameBytes = input?.maxFrameBytes ?? DEFAULT_YAMUX_LIMITS.maxFrameBytes;
381
432
  const limits = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.20.1",
3
+ "version": "0.20.2",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {