@floegence/flowersec-core 0.23.0 → 0.24.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.
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # @floegence/flowersec-core
2
2
 
3
- Flowersec core TypeScript library for the complete portable Flowersec client/server contract in Node.js, plus the browser and Service Worker runtime owned by TypeScript.
4
-
5
- Status: experimental; not audited.
3
+ The TypeScript SDK for Flowersec end-to-end encrypted direct and tunneled sessions. It implements the portable Node.js client and endpoint contract plus the browser and Service Worker runtime owned by TypeScript.
6
4
 
7
5
  ## Install
8
6
 
@@ -10,73 +8,27 @@ Status: experimental; not audited.
10
8
  npm install @floegence/flowersec-core
11
9
  ```
12
10
 
13
- ## Recommended usage
14
-
15
- Browser:
16
-
17
- ```ts
18
- import { connectBrowser } from "@floegence/flowersec-core/browser";
19
- import { requestConnectArtifact } from "@floegence/flowersec-core/controlplane";
20
-
21
- const artifact = await requestConnectArtifact({
22
- endpointId: "env_demo",
23
- });
24
-
25
- const client = await connectBrowser(artifact);
26
- await client.ping();
27
- client.close();
28
- ```
29
-
30
- Node.js:
31
-
32
- ```ts
33
- import { connectNode, createNodeReconnectConfig } from "@floegence/flowersec-core/node";
34
- import { requestConnectArtifact } from "@floegence/flowersec-core/controlplane";
35
- import { createControlplaneArtifactSource } from "@floegence/flowersec-core/reconnect";
36
-
37
- const artifact = await requestConnectArtifact({
38
- baseUrl: "https://your-app.example/api/flowersec",
39
- endpointId: "env_demo",
40
- });
41
-
42
- const client = await connectNode(artifact, {
43
- origin: "https://your-app.example",
44
- });
45
- await client.ping();
46
- const rttMs = await client.probeLiveness();
47
- client.close();
48
-
49
- const reconnectConfig = createNodeReconnectConfig({
50
- source: createControlplaneArtifactSource({
51
- baseUrl: "https://your-app.example/api/flowersec",
52
- endpointId: "env_demo",
53
- }),
54
- connect: {
55
- origin: "https://your-app.example",
56
- },
57
- });
58
- ```
11
+ The package is ESM-only and exposes environment-specific entrypoints for browser and Node.js applications.
59
12
 
60
- Browser `requestConnectArtifact(...)`, `requestEntryConnectArtifact(...)`, and `ControlplaneRequestError` remain available from `@floegence/flowersec-core/browser` as stable aliases.
13
+ ## Cookbook
61
14
 
62
- High-level connects use `RequireTLS` by default. `AllowPlaintextForLoopback` permits only literal loopback
63
- targets without DNS resolution. Deliberate non-loopback `ws://` connections must use
64
- `createNetworkPlaintextPolicy(...)` with exact canonical IP literals and
65
- `PlaintextRiskAcceptance.acceptPreE2ECredentialExposure`. The unrestricted `AllowPlaintext` preset is deprecated.
15
+ Start with the [TypeScript cookbook](https://github.com/floegence/flowersec/tree/main/examples/ts). It contains runnable browser direct/tunnel pages, Node.js clients, the shared demo server, the Service Worker proxy runtime, and manual protocol-stack references.
66
16
 
67
- ## Node endpoint and controlplane
17
+ ## Entrypoints
68
18
 
69
- `@floegence/flowersec-core/node` exports the high-level endpoint APIs for accepted direct WebSockets and server-role tunnel grants. `@floegence/flowersec-core/endpoint` provides portable session and RPC serving, while `@floegence/flowersec-core/controlplane` provides bounded artifact envelopes, FST2 tokens, issuer rotation, and channel initialization.
19
+ - Browser client: `@floegence/flowersec-core/browser`
20
+ - Node.js client and endpoint transport: `@floegence/flowersec-core/node`
21
+ - Controlplane artifact helpers: `@floegence/flowersec-core/controlplane`
22
+ - Endpoint session serving: `@floegence/flowersec-core/endpoint`
23
+ - RPC: `@floegence/flowersec-core/rpc`
24
+ - Reconnect: `@floegence/flowersec-core/reconnect`
25
+ - HTTP/WebSocket proxy and browser runtime: `@floegence/flowersec-core/proxy`
26
+ - Observability: `@floegence/flowersec-core/observability`
70
27
 
71
- ## Proxy
28
+ High-level WebSocket connections require TLS by default. Use `AllowPlaintextForLoopback` only for literal local development targets.
72
29
 
73
- `@floegence/flowersec-core/proxy` contains both portable HTTP/1 and WebSocket proxy protocols and the TypeScript-owned browser runtime. Node endpoint servers use `serveProxySession(...)`; browser applications use the Service Worker or controller bridge helpers without changing the portable stream contract.
30
+ ## Runtime Boundaries
74
31
 
75
- ## Docs
32
+ TypeScript owns browser and Service Worker integration. Shared tunnel, proxy gateway, and helper binaries remain Go-owned.
76
33
 
77
- - Frontend quickstart: `docs/FRONTEND_QUICKSTART.md`
78
- - Integration guide: `docs/INTEGRATION_GUIDE.md`
79
- - API surface contract: `docs/API_SURFACE.md`
80
- - Controlplane artifact fetch: `docs/CONTROLPLANE_ARTIFACT_FETCH.md`
81
- - Error model: `docs/ERROR_MODEL.md`
82
- - Migration guide: `docs/V0_20_MIGRATION.md`
34
+ Review the shared [API contract](../docs/API_CONTRACT.md), [protocol](../docs/PROTOCOL.md), [threat model](../docs/THREAT_MODEL.md), and [error model](../docs/ERROR_MODEL.md).
@@ -23,7 +23,7 @@ export type ConnectOptionsBase = Readonly<{
23
23
  maxHandshakePayload?: number;
24
24
  /** Maximum encrypted record size on the wire (0 uses default). */
25
25
  maxRecordBytes?: number;
26
- /** Maximum buffered plaintext bytes in the secure channel (0 uses default). */
26
+ /** Maximum queued inbound plaintext bytes in the secure channel (0 uses default). */
27
27
  maxBufferedBytes?: number;
28
28
  /** Maximum queued outbound plaintext bytes in the secure channel (default 4 MiB; 0 uses default). */
29
29
  maxOutboundBufferedBytes?: number;
@@ -39,9 +39,9 @@ export type ConnectOptionsBase = Readonly<{
39
39
  observer?: ClientObserverLike;
40
40
  /** Policy evaluated before any WebSocket network activity. */
41
41
  transportSecurityPolicy?: TransportSecurityPolicy;
42
- /** Experimental scope validators keyed by scope name. */
42
+ /** Scope validators keyed by scope name. */
43
43
  scopeResolvers?: ConnectScopeResolverMap;
44
- /** Experimental migration switch for optional scope failures. */
44
+ /** Explicit compatibility switch for optional scope failures. */
45
45
  relaxedOptionalScopeValidation?: boolean;
46
46
  /** Acknowledged Yamux liveness checks, or false to disable automatic checks. */
47
47
  liveness?: false | LivenessOptions;
@@ -303,10 +303,11 @@ export async function connectCore(args) {
303
303
  return await mux.probeLiveness(liveness.timeoutMs);
304
304
  }
305
305
  catch (e) {
306
- if (isYamuxPingTimeoutError(e)) {
306
+ const timedOut = isYamuxPingTimeoutError(e);
307
+ if (timedOut) {
307
308
  emitObserverDiagnostic(args.opts.observer, { path: args.path, stage: "yamux", code_domain: "event", code: "liveness_timeout", result: "fail" });
308
309
  }
309
- const error = new FlowersecError({ path: args.path, stage: "yamux", code: "ping_failed", message: "liveness probe failed", cause: e });
310
+ const error = new FlowersecError({ path: args.path, stage: "yamux", code: timedOut ? "timeout" : "ping_failed", message: "liveness probe failed", cause: e });
310
311
  reportTermination(error);
311
312
  throw error;
312
313
  }
@@ -355,8 +356,9 @@ export async function connectCore(args) {
355
356
  },
356
357
  probeLiveness,
357
358
  openStream: async (kind, opts = {}) => {
358
- if (kind == null || kind === "")
359
- throw new FlowersecError({ path: args.path, stage: "validate", code: "missing_stream_kind", message: "missing stream kind" });
359
+ const streamKind = kind?.trim() ?? "";
360
+ if (streamKind === "")
361
+ throw new FlowersecError({ path: args.path, stage: "rpc", code: "missing_stream_kind", message: "missing stream kind" });
360
362
  if (opts.signal?.aborted) {
361
363
  throw new FlowersecError({
362
364
  path: args.path,
@@ -415,7 +417,7 @@ export async function connectCore(args) {
415
417
  });
416
418
  }
417
419
  try {
418
- await writeStreamHello((b) => s.write(b), kind);
420
+ await writeStreamHello((b) => s.write(b), streamKind);
419
421
  }
420
422
  catch (err) {
421
423
  if (signal?.aborted) {
@@ -1,7 +1,7 @@
1
1
  import { type TokenPayload } from "./token.js";
2
2
  export declare class IssuerKeyset {
3
- private kid;
4
- private signingSeed;
3
+ private active;
4
+ private readonly verificationKeys;
5
5
  constructor(kid: string, signingSeed: Uint8Array);
6
6
  static random(kid: string): IssuerKeyset;
7
7
  currentKID(): string;
@@ -9,7 +9,10 @@ export declare class IssuerKeyset {
9
9
  sign(payload: Omit<TokenPayload, "kid"> & Readonly<{
10
10
  kid?: string;
11
11
  }>): string;
12
+ addVerificationKey(kid: string, publicKey: Uint8Array): void;
12
13
  rotate(kid: string, signingSeed: Uint8Array): void;
14
+ retireVerificationKey(kid: string): void;
13
15
  exportTunnelKeyset(): Uint8Array;
14
16
  dispose(): void;
17
+ private requireActive;
15
18
  }
@@ -2,13 +2,13 @@ import { ed25519 } from "@noble/curves/ed25519";
2
2
  import { base64urlEncode } from "../utils/base64url.js";
3
3
  import { signToken } from "./token.js";
4
4
  export class IssuerKeyset {
5
- kid;
6
- signingSeed;
5
+ active;
6
+ verificationKeys = new Map();
7
7
  constructor(kid, signingSeed) {
8
- this.kid = normalizeKid(kid);
9
- if (signingSeed.length !== 32)
10
- throw new TypeError("Ed25519 signing seed must be 32 bytes");
11
- this.signingSeed = signingSeed.slice();
8
+ const normalizedKID = normalizeKid(kid);
9
+ const retainedSeed = copySigningSeed(signingSeed);
10
+ this.active = { kid: normalizedKID, signingSeed: retainedSeed };
11
+ this.verificationKeys.set(normalizedKID, ed25519.getPublicKey(retainedSeed));
12
12
  }
13
13
  static random(kid) {
14
14
  const seed = crypto.getRandomValues(new Uint8Array(32));
@@ -20,30 +20,90 @@ export class IssuerKeyset {
20
20
  }
21
21
  }
22
22
  currentKID() {
23
- return this.kid;
23
+ return this.requireActive().kid;
24
24
  }
25
25
  publicKeys() {
26
- return new Map([[this.kid, ed25519.getPublicKey(this.signingSeed)]]);
26
+ this.requireActive();
27
+ return new Map([...this.verificationKeys.entries()]
28
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
29
+ .map(([kid, publicKey]) => [kid, publicKey.slice()]));
27
30
  }
28
31
  sign(payload) {
29
- return signToken(this.signingSeed, { ...payload, kid: this.kid });
32
+ const active = this.requireActive();
33
+ return signToken(active.signingSeed, { ...payload, kid: active.kid });
34
+ }
35
+ addVerificationKey(kid, publicKey) {
36
+ this.requireActive();
37
+ const normalizedKID = normalizeKid(kid);
38
+ const retainedKey = copyPublicKey(publicKey);
39
+ const existing = this.verificationKeys.get(normalizedKID);
40
+ if (existing != null) {
41
+ if (!equalBytes(existing, retainedKey))
42
+ throw new Error(`key ID ${normalizedKID} is already bound to a different public key`);
43
+ return;
44
+ }
45
+ this.verificationKeys.set(normalizedKID, retainedKey);
30
46
  }
31
47
  rotate(kid, signingSeed) {
48
+ const active = this.requireActive();
32
49
  const nextKID = normalizeKid(kid);
33
- if (signingSeed.length !== 32)
34
- throw new TypeError("Ed25519 signing seed must be 32 bytes");
35
- const nextSeed = signingSeed.slice();
36
- this.signingSeed.fill(0);
37
- this.kid = nextKID;
38
- this.signingSeed = nextSeed;
50
+ const nextSeed = copySigningSeed(signingSeed);
51
+ const nextPublicKey = ed25519.getPublicKey(nextSeed);
52
+ const prepublishedKey = this.verificationKeys.get(nextKID);
53
+ if (prepublishedKey == null) {
54
+ nextSeed.fill(0);
55
+ throw new Error(`key ID ${nextKID} must be prepublished before rotation`);
56
+ }
57
+ if (!equalBytes(prepublishedKey, nextPublicKey)) {
58
+ nextSeed.fill(0);
59
+ throw new Error(`key ID ${nextKID} is bound to a different public key`);
60
+ }
61
+ this.active = { kid: nextKID, signingSeed: nextSeed };
62
+ active.signingSeed.fill(0);
63
+ }
64
+ retireVerificationKey(kid) {
65
+ const active = this.requireActive();
66
+ const normalizedKID = normalizeKid(kid);
67
+ if (normalizedKID === active.kid)
68
+ throw new Error("the active signing key cannot be retired");
69
+ if (!this.verificationKeys.delete(normalizedKID))
70
+ throw new Error(`key ID ${normalizedKID} is not published`);
39
71
  }
40
72
  exportTunnelKeyset() {
41
73
  const keys = [...this.publicKeys()].map(([kid, publicKey]) => ({ kid, pubkey_b64u: base64urlEncode(publicKey) }));
42
74
  return new TextEncoder().encode(JSON.stringify({ keys }, null, 2));
43
75
  }
44
76
  dispose() {
45
- this.signingSeed.fill(0);
77
+ const active = this.active;
78
+ if (active == null)
79
+ return;
80
+ active.signingSeed.fill(0);
81
+ this.active = undefined;
82
+ this.verificationKeys.clear();
46
83
  }
84
+ requireActive() {
85
+ if (this.active == null)
86
+ throw new Error("issuer keyset is disposed");
87
+ return this.active;
88
+ }
89
+ }
90
+ function copySigningSeed(signingSeed) {
91
+ if (signingSeed.length !== 32)
92
+ throw new TypeError("Ed25519 signing seed must be 32 bytes");
93
+ return signingSeed.slice();
94
+ }
95
+ function copyPublicKey(publicKey) {
96
+ if (publicKey.length !== 32)
97
+ throw new TypeError("Ed25519 public key must be 32 bytes");
98
+ return publicKey.slice();
99
+ }
100
+ function equalBytes(left, right) {
101
+ if (left.length !== right.length)
102
+ return false;
103
+ let diff = 0;
104
+ for (let index = 0; index < left.length; index++)
105
+ diff |= left[index] ^ right[index];
106
+ return diff === 0;
47
107
  }
48
108
  function normalizeKid(kid) {
49
109
  const value = kid.trim();
@@ -8,6 +8,7 @@ export declare const SDK_DEFAULTS: Readonly<{
8
8
  maxHandshakePayloadBytes: number;
9
9
  maxRecordBytes: number;
10
10
  outboundRecordChunkBytes: number;
11
+ maxInboundBufferedBytes: number;
11
12
  maxOutboundBufferedBytes: number;
12
13
  }>;
13
14
  yamux: Readonly<{
@@ -15,6 +16,7 @@ export declare const SDK_DEFAULTS: Readonly<{
15
16
  maxInboundStreams: 32;
16
17
  maxFrameBytes: number;
17
18
  preferredOutboundFrameBytes: number;
19
+ maxStreamWriteQueueBytes: number;
18
20
  maxStreamReceiveBytes: number;
19
21
  maxSessionReceiveBytes: number;
20
22
  }>;
package/dist/defaults.js CHANGED
@@ -8,6 +8,7 @@ export const SDK_DEFAULTS = Object.freeze({
8
8
  maxHandshakePayloadBytes: 8 * 1024,
9
9
  maxRecordBytes: 1024 * 1024,
10
10
  outboundRecordChunkBytes: 64 * 1024,
11
+ maxInboundBufferedBytes: 4 * 1024 * 1024,
11
12
  maxOutboundBufferedBytes: 4 * 1024 * 1024,
12
13
  }),
13
14
  yamux: Object.freeze({
@@ -15,6 +16,7 @@ export const SDK_DEFAULTS = Object.freeze({
15
16
  maxInboundStreams: 32,
16
17
  maxFrameBytes: 256 * 1024,
17
18
  preferredOutboundFrameBytes: 64 * 1024,
19
+ maxStreamWriteQueueBytes: 4 * 1024 * 1024,
18
20
  maxStreamReceiveBytes: 256 * 1024,
19
21
  maxSessionReceiveBytes: 16 * 1024 * 1024,
20
22
  }),
@@ -16,7 +16,7 @@ export type HandshakeClientOptions = Readonly<{
16
16
  maxRecordBytes: number;
17
17
  /** Preferred plaintext bytes per outbound record. */
18
18
  outboundRecordChunkBytes?: number;
19
- /** Maximum buffered plaintext bytes for the secure channel. */
19
+ /** Maximum queued inbound plaintext bytes for the secure channel. */
20
20
  maxBufferedBytes?: number;
21
21
  /** Maximum queued outbound plaintext bytes for the secure channel. */
22
22
  maxOutboundBufferedBytes?: number;
@@ -44,7 +44,7 @@ export type HandshakeServerOptions = Readonly<{
44
44
  maxRecordBytes: number;
45
45
  /** Preferred plaintext bytes per outbound record. */
46
46
  outboundRecordChunkBytes?: number;
47
- /** Maximum buffered plaintext bytes for the secure channel. */
47
+ /** Maximum queued inbound plaintext bytes for the secure channel. */
48
48
  maxBufferedBytes?: number;
49
49
  /** Maximum queued outbound plaintext bytes for the secure channel. */
50
50
  maxOutboundBufferedBytes?: number;
@@ -31,7 +31,7 @@ export declare class SecureChannel {
31
31
  private readonly transport;
32
32
  private readonly maxRecordBytes;
33
33
  private readonly outboundRecordChunkBytes;
34
- private readonly maxBufferedBytes;
34
+ private readonly maxInboundBufferedBytes;
35
35
  private readonly maxOutboundBufferedBytes;
36
36
  private sendKey;
37
37
  private recvKey;
@@ -50,7 +50,7 @@ export declare class SecureChannel {
50
50
  private sendQueueBytes;
51
51
  private sendClosed;
52
52
  private sendErr;
53
- private readonly recvQueue;
53
+ private recvQueue;
54
54
  private recvQueueHead;
55
55
  private recvQueueBytes;
56
56
  private recvWaiters;
@@ -1,6 +1,7 @@
1
1
  import { RECORD_FLAG_APP, RECORD_FLAG_PING, RECORD_FLAG_REKEY } from "./constants.js";
2
2
  import { decryptRecord, encryptRecord, maxPlaintextBytes } from "./record.js";
3
3
  import { deriveRekeyKey } from "./kdf.js";
4
+ import { SDK_DEFAULTS } from "../defaults.js";
4
5
  const maxRecordSeq = (1n << 64n) - 1n;
5
6
  class RecordSeqExhaustedError extends Error {
6
7
  constructor() {
@@ -15,8 +16,7 @@ export class SecureChannel {
15
16
  // Maximum allowed bytes per record frame.
16
17
  maxRecordBytes;
17
18
  outboundRecordChunkBytes;
18
- // Upper bound for buffered plaintext in memory.
19
- maxBufferedBytes;
19
+ maxInboundBufferedBytes;
20
20
  maxOutboundBufferedBytes;
21
21
  // Active encryption keys and nonce prefixes for the current epoch.
22
22
  sendKey;
@@ -52,16 +52,16 @@ export class SecureChannel {
52
52
  this.transport = args.transport;
53
53
  this.maxRecordBytes = args.maxRecordBytes;
54
54
  const maxPlain = Math.max(1, maxPlaintextBytes(this.maxRecordBytes));
55
- this.outboundRecordChunkBytes = args.outboundRecordChunkBytes ?? Math.min(64 * 1024, maxPlain);
55
+ this.outboundRecordChunkBytes = args.outboundRecordChunkBytes ?? Math.min(SDK_DEFAULTS.e2ee.outboundRecordChunkBytes, maxPlain);
56
56
  if (!Number.isSafeInteger(this.outboundRecordChunkBytes) || this.outboundRecordChunkBytes <= 0 || this.outboundRecordChunkBytes > maxPlain) {
57
57
  throw new RangeError("outboundRecordChunkBytes must be a positive integer within the record plaintext limit");
58
58
  }
59
- this.maxBufferedBytes = Math.max(0, args.maxBufferedBytes ?? 4 * (1 << 20));
60
- const maxOutboundBufferedBytes = args.maxOutboundBufferedBytes ?? 4 * (1 << 20);
59
+ this.maxInboundBufferedBytes = Math.max(0, args.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes);
60
+ const maxOutboundBufferedBytes = args.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes;
61
61
  if (!Number.isSafeInteger(maxOutboundBufferedBytes) || maxOutboundBufferedBytes < 0) {
62
62
  throw new RangeError("maxOutboundBufferedBytes must be a non-negative safe integer");
63
63
  }
64
- this.maxOutboundBufferedBytes = maxOutboundBufferedBytes === 0 ? 4 * (1 << 20) : maxOutboundBufferedBytes;
64
+ this.maxOutboundBufferedBytes = maxOutboundBufferedBytes === 0 ? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes : maxOutboundBufferedBytes;
65
65
  this.sendKey = args.sendKey;
66
66
  this.recvKey = args.recvKey;
67
67
  this.sendNoncePrefix = args.sendNoncePrefix;
@@ -87,7 +87,11 @@ export class SecureChannel {
87
87
  if (this.recvQueueHead < this.recvQueue.length) {
88
88
  const b = this.recvQueue[this.recvQueueHead];
89
89
  this.recvQueueHead++;
90
- if (this.recvQueueHead > 1024 && this.recvQueueHead * 2 > this.recvQueue.length) {
90
+ if (this.recvQueueHead === this.recvQueue.length) {
91
+ this.recvQueue = [];
92
+ this.recvQueueHead = 0;
93
+ }
94
+ else if (this.recvQueueHead > 1024 && this.recvQueueHead * 2 > this.recvQueue.length) {
91
95
  this.recvQueue.splice(0, this.recvQueueHead);
92
96
  this.recvQueueHead = 0;
93
97
  }
@@ -171,7 +175,11 @@ export class SecureChannel {
171
175
  return null;
172
176
  const req = this.sendQueue[this.sendQueueHead];
173
177
  this.sendQueueHead++;
174
- if (this.sendQueueHead > 1024 && this.sendQueueHead * 2 > this.sendQueue.length) {
178
+ if (this.sendQueueHead === this.sendQueue.length) {
179
+ this.sendQueue = [];
180
+ this.sendQueueHead = 0;
181
+ }
182
+ else if (this.sendQueueHead > 1024 && this.sendQueueHead * 2 > this.sendQueue.length) {
175
183
  this.sendQueue.splice(0, this.sendQueueHead);
176
184
  this.sendQueueHead = 0;
177
185
  }
@@ -182,7 +190,11 @@ export class SecureChannel {
182
190
  return undefined;
183
191
  const w = this.sendWaiters[this.sendWaitersHead];
184
192
  this.sendWaitersHead++;
185
- if (this.sendWaitersHead > 1024 && this.sendWaitersHead * 2 > this.sendWaiters.length) {
193
+ if (this.sendWaitersHead === this.sendWaiters.length) {
194
+ this.sendWaiters = [];
195
+ this.sendWaitersHead = 0;
196
+ }
197
+ else if (this.sendWaitersHead > 1024 && this.sendWaitersHead * 2 > this.sendWaiters.length) {
186
198
  this.sendWaiters.splice(0, this.sendWaitersHead);
187
199
  this.sendWaitersHead = 0;
188
200
  }
@@ -284,7 +296,7 @@ export class SecureChannel {
284
296
  }
285
297
  this.recvSeq = seq + 1n;
286
298
  if (flags === RECORD_FLAG_APP) {
287
- if (this.maxBufferedBytes > 0 && this.recvQueueBytes + plaintext.length > this.maxBufferedBytes) {
299
+ if (this.maxInboundBufferedBytes > 0 && this.recvQueueBytes + plaintext.length > this.maxInboundBufferedBytes) {
288
300
  throw new Error("recv buffer exceeded");
289
301
  }
290
302
  this.recvQueue.push(plaintext);
@@ -8,6 +8,7 @@ import { decodeHandshakeFrame } from "../e2ee/framing.js";
8
8
  import { HANDSHAKE_TYPE_INIT, PROTOCOL_VERSION } from "../e2ee/constants.js";
9
9
  import { readStreamHello, writeStreamHello } from "../streamhello/streamHello.js";
10
10
  import { ByteReader } from "../yamux/byteReader.js";
11
+ import { isYamuxPingTimeoutError } from "../yamux/errors.js";
11
12
  import { YamuxSession } from "../yamux/session.js";
12
13
  import { RpcServer } from "../rpc/server.js";
13
14
  import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
@@ -43,9 +44,10 @@ export class Session {
43
44
  return session;
44
45
  }
45
46
  async openStream(kind, options = {}) {
47
+ const streamKind = normalizeStreamKind(kind, this.path);
46
48
  const stream = await this.mux.openStream(options);
47
49
  try {
48
- await writeStreamHello((bytes) => stream.write(bytes), normalizeStreamKind(kind, this.path));
50
+ await writeStreamHello((bytes) => stream.write(bytes), streamKind);
49
51
  return stream;
50
52
  }
51
53
  catch (error) {
@@ -82,8 +84,19 @@ export class Session {
82
84
  return;
83
85
  }
84
86
  }
85
- probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
86
- return this.mux.probeLiveness(timeoutMs);
87
+ async probeLiveness(timeoutMs = SDK_DEFAULTS.transport.handshakeTimeoutMs) {
88
+ try {
89
+ return await this.mux.probeLiveness(timeoutMs);
90
+ }
91
+ catch (error) {
92
+ throw new FlowersecError({
93
+ path: this.path,
94
+ stage: "yamux",
95
+ code: isYamuxPingTimeoutError(error) ? "timeout" : "ping_failed",
96
+ message: "endpoint liveness probe failed",
97
+ cause: error,
98
+ });
99
+ }
87
100
  }
88
101
  async rekey() {
89
102
  try {
@@ -247,7 +260,7 @@ async function establishSession(path, transport, handshake, options, endpointIns
247
260
  maxHandshakePayload: options.maxHandshakePayload ?? SDK_DEFAULTS.e2ee.maxHandshakePayloadBytes,
248
261
  maxRecordBytes: options.maxRecordBytes ?? SDK_DEFAULTS.e2ee.maxRecordBytes,
249
262
  outboundRecordChunkBytes: options.outboundRecordChunkBytes ?? SDK_DEFAULTS.e2ee.outboundRecordChunkBytes,
250
- maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
263
+ maxBufferedBytes: options.maxBufferedBytes ?? SDK_DEFAULTS.e2ee.maxInboundBufferedBytes,
251
264
  maxOutboundBufferedBytes: options.maxOutboundBufferedBytes ?? SDK_DEFAULTS.e2ee.maxOutboundBufferedBytes,
252
265
  timeoutMs: options.handshakeTimeoutMs ?? SDK_DEFAULTS.transport.handshakeTimeoutMs,
253
266
  ...(options.signal === undefined ? {} : { signal: options.signal }),
@@ -327,7 +340,7 @@ function randomEndpointInstanceId() {
327
340
  function normalizeStreamKind(kind, path) {
328
341
  const value = kind.trim();
329
342
  if (value === "")
330
- throw new FlowersecError({ path, stage: "validate", code: "missing_stream_kind", message: "missing stream kind" });
343
+ throw new FlowersecError({ path, stage: "rpc", code: "missing_stream_kind", message: "missing stream kind" });
331
344
  return value;
332
345
  }
333
346
  function unwrapServerGrant(input) {
@@ -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.24.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",