@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,6 +1,7 @@
1
1
  import { getClientTermination } from "../client-connect/termination.js";
2
2
  import { emitObserverDiagnostic, withObserverContext } from "../observability/observer.js";
3
3
  import { SDK_DEFAULTS } from "../defaults.js";
4
+ import { AbortError, FlowersecError } from "../utils/errors.js";
4
5
  export { createArtifactResolver, createControlplaneArtifactSource } from "./artifactControlplane.js";
5
6
  function normalizeAutoReconnect(cfg) {
6
7
  const settings = {
@@ -33,6 +34,26 @@ function backoffDelayMs(attemptIndex, cfg) {
33
34
  const jitter = cfg.jitterRatio <= 0 ? 0 : base * cfg.jitterRatio * (Math.random() * 2 - 1);
34
35
  return Math.max(0, Math.round(base + jitter));
35
36
  }
37
+ const TERMINAL_RECONNECT_CODES = new Set([
38
+ "invalid_input",
39
+ "invalid_option",
40
+ "role_mismatch",
41
+ "transport_policy_denied",
42
+ "invalid_psk",
43
+ "invalid_suite",
44
+ "missing_grant",
45
+ "missing_connect_info",
46
+ "missing_tunnel_url",
47
+ "missing_ws_url",
48
+ "missing_channel_id",
49
+ "missing_token",
50
+ "missing_init_exp",
51
+ ]);
52
+ function isTerminalConnectError(error) {
53
+ if (error instanceof AbortError || error.name === "AbortError")
54
+ return true;
55
+ return error instanceof FlowersecError && (error.code === "canceled" || TERMINAL_RECONNECT_CODES.has(error.code));
56
+ }
36
57
  function isSameConfig(a, b) {
37
58
  if (a == null)
38
59
  return false;
@@ -101,6 +122,30 @@ export function createReconnectManager() {
101
122
  resolve();
102
123
  }, ms);
103
124
  });
125
+ const finishWithError = (cfg, error, currentAttemptSeq) => {
126
+ setState({ status: "error", error, client: null });
127
+ emitObserverDiagnostic(withObserverContext(cfg.observer, { attemptSeq: currentAttemptSeq }), {
128
+ path: "auto",
129
+ stage: "reconnect",
130
+ code_domain: "event",
131
+ code: "reconnect_exhausted",
132
+ result: "fail",
133
+ });
134
+ };
135
+ const scheduleRetry = async (t, cfg, error, settings, failedAttemptIndex, currentAttemptSeq) => {
136
+ if (t !== token || active !== cfg)
137
+ return false;
138
+ setState({ status: "connecting", error, client: null });
139
+ emitObserverDiagnostic(withObserverContext(cfg.observer, { attemptSeq: currentAttemptSeq }), {
140
+ path: "auto",
141
+ stage: "reconnect",
142
+ code_domain: "event",
143
+ code: "reconnect_scheduled",
144
+ result: "retry",
145
+ });
146
+ await sleep(backoffDelayMs(failedAttemptIndex, settings));
147
+ return t === token && active === cfg;
148
+ };
104
149
  const disconnectInternal = () => {
105
150
  cancelRetrySleep();
106
151
  abortActiveAttempt();
@@ -145,8 +190,7 @@ export function createReconnectManager() {
145
190
  }
146
191
  token += 1;
147
192
  const nextToken = token;
148
- const reconnectPromise = startConnectLoop(nextToken, cfg);
149
- setState({ status: "connecting", error, client: null });
193
+ const reconnectPromise = startConnectLoop(nextToken, cfg, error);
150
194
  void reconnectPromise.catch(() => {
151
195
  // connectWithRetry updates state; keep errors observable via state().
152
196
  });
@@ -184,9 +228,17 @@ export function createReconnectManager() {
184
228
  attemptAbort = new AbortController();
185
229
  return await cfg.connectOnce({ signal: attemptAbort.signal, observer: createObserver(t, cfg, currentAttemptSeq) ?? {} });
186
230
  };
187
- const connectWithRetry = async (t, cfg) => {
231
+ const connectWithRetry = async (t, cfg, initialFailure) => {
188
232
  const ar = normalizeAutoReconnect(cfg.autoReconnect);
189
233
  let attempts = 0;
234
+ if (initialFailure != null) {
235
+ if (isTerminalConnectError(initialFailure)) {
236
+ finishWithError(cfg, initialFailure, attemptSeq);
237
+ throw initialFailure;
238
+ }
239
+ if (!await scheduleRetry(t, cfg, initialFailure, ar, 0, attemptSeq))
240
+ return;
241
+ }
190
242
  for (;;) {
191
243
  if (t !== token)
192
244
  return;
@@ -245,32 +297,17 @@ export function createReconnectManager() {
245
297
  return;
246
298
  if (active !== cfg)
247
299
  return;
248
- const canRetry = ar.enabled && attempts < ar.maxAttempts;
300
+ const canRetry = ar.enabled && attempts < ar.maxAttempts && !isTerminalConnectError(e);
249
301
  if (!canRetry) {
250
- setState({ status: "error", error: e, client: null });
251
- emitObserverDiagnostic(withObserverContext(cfg.observer, { attemptSeq }), {
252
- path: "auto",
253
- stage: "reconnect",
254
- code_domain: "event",
255
- code: "reconnect_exhausted",
256
- result: "fail",
257
- });
302
+ finishWithError(cfg, e, attemptSeq);
258
303
  throw e;
259
304
  }
260
- setState({ status: "connecting", error: e, client: null });
261
- const delay = backoffDelayMs(attempts - 1, ar);
262
- emitObserverDiagnostic(withObserverContext(cfg.observer, { attemptSeq }), {
263
- path: "auto",
264
- stage: "reconnect",
265
- code_domain: "event",
266
- code: "reconnect_scheduled",
267
- result: "retry",
268
- });
269
- await sleep(delay);
305
+ if (!await scheduleRetry(t, cfg, e, ar, attempts - 1, attemptSeq))
306
+ return;
270
307
  }
271
308
  }
272
309
  };
273
- const startConnectLoop = (t, cfg) => {
310
+ const startConnectLoop = (t, cfg, initialFailure) => {
274
311
  let resolveLoop;
275
312
  let rejectLoop;
276
313
  const loop = new Promise((resolve, reject) => {
@@ -283,7 +320,7 @@ export function createReconnectManager() {
283
320
  activeConnectPromise = null;
284
321
  });
285
322
  activeConnectPromise = promise;
286
- void connectWithRetry(t, cfg).then(resolveLoop, rejectLoop);
323
+ void connectWithRetry(t, cfg, initialFailure).then(resolveLoop, rejectLoop);
287
324
  return promise;
288
325
  };
289
326
  const connect = async (cfg) => {
@@ -300,7 +337,7 @@ export function createReconnectManager() {
300
337
  setState({ status: "error", error: closeError, client: null });
301
338
  throw closeError;
302
339
  }
303
- const connectPromise = startConnectLoop(t, cfg);
340
+ const connectPromise = startConnectLoop(t, cfg, null);
304
341
  setState({ status: "connecting", error: null, client: null });
305
342
  await connectPromise;
306
343
  };
@@ -8,4 +8,6 @@ export declare class ByteReader {
8
8
  readExactly(n: number): Promise<Uint8Array>;
9
9
  discardExactly(n: number): Promise<void>;
10
10
  bufferedBytes(): number;
11
+ private consumeAvailable;
12
+ private compactConsumedChunks;
11
13
  }
@@ -23,25 +23,7 @@ export class ByteReader {
23
23
  this.buffered += chunk.length;
24
24
  }
25
25
  const out = new Uint8Array(n);
26
- let outOff = 0;
27
- while (outOff < n) {
28
- const head = this.chunks[this.chunkHead];
29
- const avail = head.length - this.headOff;
30
- const need = n - outOff;
31
- const take = Math.min(avail, need);
32
- out.set(head.subarray(this.headOff, this.headOff + take), outOff);
33
- outOff += take;
34
- this.headOff += take;
35
- this.buffered -= take;
36
- if (this.headOff === head.length) {
37
- this.chunkHead++;
38
- this.headOff = 0;
39
- if (this.chunkHead > 1024 && this.chunkHead * 2 > this.chunks.length) {
40
- this.chunks.splice(0, this.chunkHead);
41
- this.chunkHead = 0;
42
- }
43
- }
44
- }
26
+ this.consumeAvailable(n, out);
45
27
  return out;
46
28
  }
47
29
  // discardExactly consumes bytes without allocating a contiguous output buffer.
@@ -59,19 +41,41 @@ export class ByteReader {
59
41
  this.chunks.push(chunk);
60
42
  this.buffered += chunk.length;
61
43
  }
44
+ remaining -= this.consumeAvailable(remaining);
45
+ }
46
+ }
47
+ // bufferedBytes returns the number of bytes currently buffered.
48
+ bufferedBytes() {
49
+ return this.buffered;
50
+ }
51
+ consumeAvailable(maxBytes, output) {
52
+ let consumed = 0;
53
+ while (consumed < maxBytes && this.buffered > 0) {
62
54
  const head = this.chunks[this.chunkHead];
63
- const take = Math.min(remaining, head.length - this.headOff);
55
+ const take = Math.min(maxBytes - consumed, head.length - this.headOff);
56
+ if (output != null)
57
+ output.set(head.subarray(this.headOff, this.headOff + take), consumed);
64
58
  this.headOff += take;
65
59
  this.buffered -= take;
66
- remaining -= take;
60
+ consumed += take;
67
61
  if (this.headOff === head.length) {
68
62
  this.chunkHead++;
69
63
  this.headOff = 0;
70
64
  }
71
65
  }
66
+ this.compactConsumedChunks();
67
+ return consumed;
72
68
  }
73
- // bufferedBytes returns the number of bytes currently buffered.
74
- bufferedBytes() {
75
- return this.buffered;
69
+ compactConsumedChunks() {
70
+ if (this.buffered === 0) {
71
+ this.chunks.length = 0;
72
+ this.chunkHead = 0;
73
+ this.headOff = 0;
74
+ return;
75
+ }
76
+ if (this.chunkHead > 1024 && this.chunkHead * 2 > this.chunks.length) {
77
+ this.chunks.splice(0, this.chunkHead);
78
+ this.chunkHead = 0;
79
+ }
76
80
  }
77
81
  }
@@ -11,7 +11,7 @@ export const DEFAULT_YAMUX_LIMITS = Object.freeze({
11
11
  preferredOutboundFrameBytes: SDK_DEFAULTS.yamux.preferredOutboundFrameBytes,
12
12
  maxStreamReceiveBytes: SDK_DEFAULTS.yamux.maxStreamReceiveBytes,
13
13
  maxSessionReceiveBytes: SDK_DEFAULTS.yamux.maxSessionReceiveBytes,
14
- maxStreamWriteQueueBytes: SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
14
+ maxStreamWriteQueueBytes: SDK_DEFAULTS.yamux.maxStreamWriteQueueBytes,
15
15
  });
16
16
  // YamuxSession multiplexes multiple streams over a single byte stream.
17
17
  export class YamuxSession {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -121,6 +121,8 @@
121
121
  "build": "tsc -p tsconfig.build.json",
122
122
  "bench": "vitest bench --run",
123
123
  "test": "npm run build && vitest run",
124
+ "test:browser": "npm run build && playwright test",
125
+ "ensure:browser": "node ./scripts/ensure-playwright-chromium.mjs",
124
126
  "test:coverage": "npm run build && vitest run --coverage",
125
127
  "lint": "eslint .",
126
128
  "verify:package": "node ./scripts/verify-package-exports.mjs",
@@ -136,6 +138,7 @@
136
138
  "devDependencies": {
137
139
  "@types/node": "^24.0.0",
138
140
  "@types/ws": "^8.5.12",
141
+ "@playwright/test": "1.58.2",
139
142
  "@typescript-eslint/eslint-plugin": "^8.18.0",
140
143
  "@typescript-eslint/parser": "^8.18.0",
141
144
  "@vitest/coverage-v8": "4.1.0",