@floegence/flowersec-core 0.19.11 → 0.20.1

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.
Files changed (41) hide show
  1. package/README.md +7 -7
  2. package/dist/browser/reconnectConfig.d.ts +8 -36
  3. package/dist/browser/reconnectConfig.js +13 -70
  4. package/dist/client-connect/connectCore.d.ts +14 -5
  5. package/dist/client-connect/connectCore.js +130 -38
  6. package/dist/client-connect/transportSecurity.js +13 -15
  7. package/dist/client.d.ts +2 -0
  8. package/dist/e2ee/handshake.d.ts +4 -0
  9. package/dist/e2ee/handshake.js +2 -0
  10. package/dist/e2ee/secureChannel.d.ts +4 -0
  11. package/dist/e2ee/secureChannel.js +18 -9
  12. package/dist/facade.d.ts +3 -0
  13. package/dist/node/reconnectConfig.d.ts +8 -34
  14. package/dist/node/reconnectConfig.js +13 -71
  15. package/dist/node/wsFactory.js +3 -0
  16. package/dist/observability/observer.d.ts +5 -2
  17. package/dist/observability/observer.js +3 -0
  18. package/dist/proxy/integration.d.ts +2 -0
  19. package/dist/proxy/integration.js +6 -0
  20. package/dist/proxy/runtime.d.ts +4 -0
  21. package/dist/proxy/runtime.js +145 -3
  22. package/dist/proxy/runtimeScope.d.ts +4 -0
  23. package/dist/reconnect/artifactControlplane.d.ts +9 -6
  24. package/dist/reconnect/artifactControlplane.js +28 -27
  25. package/dist/reconnect/index.d.ts +2 -0
  26. package/dist/reconnect/index.js +7 -0
  27. package/dist/rpc/server.d.ts +29 -4
  28. package/dist/rpc/server.js +142 -34
  29. package/dist/tunnel-client/connect.js +6 -6
  30. package/dist/utils/errors.d.ts +1 -1
  31. package/dist/ws-client/binaryTransport.d.ts +20 -5
  32. package/dist/ws-client/binaryTransport.js +110 -8
  33. package/dist/yamux/byteReader.d.ts +1 -0
  34. package/dist/yamux/byteReader.js +26 -0
  35. package/dist/yamux/errors.d.ts +7 -0
  36. package/dist/yamux/errors.js +15 -0
  37. package/dist/yamux/session.d.ts +31 -1
  38. package/dist/yamux/session.js +191 -12
  39. package/dist/yamux/stream.d.ts +10 -3
  40. package/dist/yamux/stream.js +61 -19
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -30,8 +30,9 @@ client.close();
30
30
  Node.js:
31
31
 
32
32
  ```ts
33
- import { connectNode, createNodeReconnectConfig, RequireTLS } from "@floegence/flowersec-core/node";
33
+ import { connectNode, createNodeReconnectConfig } from "@floegence/flowersec-core/node";
34
34
  import { requestConnectArtifact } from "@floegence/flowersec-core/controlplane";
35
+ import { createControlplaneArtifactSource } from "@floegence/flowersec-core/reconnect";
35
36
 
36
37
  const artifact = await requestConnectArtifact({
37
38
  baseUrl: "https://your-app.example/api/flowersec",
@@ -40,26 +41,25 @@ const artifact = await requestConnectArtifact({
40
41
 
41
42
  const client = await connectNode(artifact, {
42
43
  origin: "https://your-app.example",
43
- transportSecurityPolicy: RequireTLS,
44
44
  });
45
45
  await client.ping();
46
+ const rttMs = await client.probeLiveness();
46
47
  client.close();
47
48
 
48
49
  const reconnectConfig = createNodeReconnectConfig({
49
- artifactControlplane: {
50
+ source: createControlplaneArtifactSource({
50
51
  baseUrl: "https://your-app.example/api/flowersec",
51
52
  endpointId: "env_demo",
52
- },
53
+ }),
53
54
  connect: {
54
55
  origin: "https://your-app.example",
55
- transportSecurityPolicy: RequireTLS,
56
56
  },
57
57
  });
58
58
  ```
59
59
 
60
60
  Browser `requestConnectArtifact(...)`, `requestEntryConnectArtifact(...)`, and `ControlplaneRequestError` remain available from `@floegence/flowersec-core/browser` as stable aliases.
61
61
 
62
- Use `RequireTLS` for remote deployments. `AllowPlaintextForLoopback` permits only literal loopback
62
+ High-level connects use `RequireTLS` by default. `AllowPlaintextForLoopback` permits only literal loopback
63
63
  targets without DNS resolution; `AllowPlaintext` is an explicit acceptance of pre-E2EE metadata exposure.
64
64
 
65
65
  ## Docs
@@ -69,4 +69,4 @@ targets without DNS resolution; `AllowPlaintext` is an explicit acceptance of pr
69
69
  - API surface contract: `docs/API_SURFACE.md`
70
70
  - Controlplane artifact fetch: `docs/CONTROLPLANE_ARTIFACT_FETCH.md`
71
71
  - Error model: `docs/ERROR_MODEL.md`
72
- - Migration guide: `docs/V0_19_MIGRATION.md`
72
+ - Migration guide: `docs/V0_20_MIGRATION.md`
@@ -1,43 +1,15 @@
1
- import type { ConnectArtifact } from "../connect/artifact.js";
2
- import type { ChannelInitGrant } from "../gen/flowersec/controlplane/v1.gen.js";
3
- import type { DirectConnectInfo } from "../gen/flowersec/direct/v1.gen.js";
4
1
  import type { ClientObserverLike } from "../observability/observer.js";
5
2
  import type { AutoReconnectConfig, ConnectConfig as ReconnectConnectConfig } from "../reconnect/index.js";
6
- import { type ArtifactAwareReconnectConfig, type ArtifactFactoryArgs } from "../reconnect/artifactControlplane.js";
7
- import type { RequestConnectArtifactInput, RequestEntryConnectArtifactInput } from "../controlplane/index.js";
3
+ import { type ArtifactSource } from "../reconnect/artifactControlplane.js";
8
4
  import type { DirectConnectBrowserOptions, TunnelConnectBrowserOptions } from "./connect.js";
9
- import { type ControlplaneConfig } from "./controlplane.js";
10
- type SharedReconnectOptions = Readonly<{
5
+ export type BrowserReconnectConfig = Readonly<{
6
+ source: ArtifactSource;
7
+ connect?: Omit<TunnelConnectBrowserOptions, "observer" | "signal"> | Omit<DirectConnectBrowserOptions, "observer" | "signal">;
11
8
  observer?: ClientObserverLike;
12
9
  autoReconnect?: AutoReconnectConfig;
13
10
  }>;
14
- type TunnelReconnectConnectOptions = Omit<TunnelConnectBrowserOptions, "observer" | "signal">;
15
- type DirectReconnectConnectOptions = Omit<DirectConnectBrowserOptions, "observer" | "signal">;
16
- type ArtifactAwareTunnelReconnectConfig = ArtifactAwareReconnectConfig & Readonly<{
17
- artifact?: ConnectArtifact;
18
- getArtifact?: (args: ArtifactFactoryArgs) => Promise<ConnectArtifact>;
19
- artifactControlplane?: RequestConnectArtifactInput | RequestEntryConnectArtifactInput;
20
- }>;
21
- type ArtifactAwareDirectReconnectConfig = ArtifactAwareReconnectConfig & Readonly<{
22
- artifact?: ConnectArtifact;
23
- getArtifact?: (args: ArtifactFactoryArgs) => Promise<ConnectArtifact>;
24
- artifactControlplane?: RequestConnectArtifactInput | RequestEntryConnectArtifactInput;
25
- }>;
26
- export type TunnelBrowserReconnectConfig = SharedReconnectOptions & ArtifactAwareTunnelReconnectConfig & Readonly<{
27
- mode?: "tunnel";
28
- connect?: TunnelReconnectConnectOptions;
29
- grant?: ChannelInitGrant;
30
- getGrant?: () => Promise<ChannelInitGrant>;
31
- controlplane?: ControlplaneConfig;
32
- }>;
33
- export type DirectBrowserReconnectConfig = SharedReconnectOptions & ArtifactAwareDirectReconnectConfig & Readonly<{
34
- mode: "direct";
35
- connect?: DirectReconnectConnectOptions;
36
- directInfo?: DirectConnectInfo;
37
- getDirectInfo?: () => Promise<DirectConnectInfo>;
38
- }>;
39
- export type BrowserReconnectConfig = TunnelBrowserReconnectConfig | DirectBrowserReconnectConfig;
40
- export declare function createTunnelBrowserReconnectConfig(config: TunnelBrowserReconnectConfig): ReconnectConnectConfig;
41
- export declare function createDirectBrowserReconnectConfig(config: DirectBrowserReconnectConfig): ReconnectConnectConfig;
11
+ export type TunnelBrowserReconnectConfig = BrowserReconnectConfig;
12
+ export type DirectBrowserReconnectConfig = BrowserReconnectConfig;
42
13
  export declare function createBrowserReconnectConfig(config: BrowserReconnectConfig): ReconnectConnectConfig;
43
- export {};
14
+ export declare const createTunnelBrowserReconnectConfig: typeof createBrowserReconnectConfig;
15
+ export declare const createDirectBrowserReconnectConfig: typeof createBrowserReconnectConfig;
@@ -1,77 +1,20 @@
1
- import { resolveConnectArtifact, updateTraceId, } from "../reconnect/artifactControlplane.js";
2
- import { connectBrowser, connectDirectBrowser, connectTunnelBrowser } from "./connect.js";
3
- import { requestChannelGrant, } from "./controlplane.js";
4
- async function resolveTunnelGrant(config) {
5
- if (config.getGrant)
6
- return await config.getGrant();
7
- if (config.grant)
8
- return config.grant;
9
- if (config.controlplane)
10
- return await requestChannelGrant(config.controlplane);
11
- throw new Error("Tunnel reconnect config requires `getGrant`, `grant`, or `controlplane`");
12
- }
13
- async function resolveDirectInfo(config) {
14
- if (config.getDirectInfo)
15
- return await config.getDirectInfo();
16
- if (config.directInfo)
17
- return config.directInfo;
18
- throw new Error("Direct reconnect config requires `getDirectInfo` or `directInfo`");
19
- }
20
- export function createTunnelBrowserReconnectConfig(config) {
21
- let traceId = config.artifact?.correlation?.trace_id;
22
- return {
23
- ...(config.observer === undefined ? {} : { observer: config.observer }),
24
- ...(config.autoReconnect === undefined ? {} : { autoReconnect: config.autoReconnect }),
25
- connectOnce: async ({ signal, observer }) => {
26
- if (config.getArtifact || config.artifact || config.artifactControlplane) {
27
- const artifact = await resolveConnectArtifact(config, traceId, signal);
28
- if (artifact.transport !== "tunnel") {
29
- throw new Error("Tunnel reconnect config requires a tunnel ConnectArtifact");
30
- }
31
- traceId = updateTraceId(traceId, artifact);
32
- return await connectBrowser(artifact, {
33
- ...(config.connect ?? {}),
34
- signal,
35
- observer,
36
- });
37
- }
38
- return await connectTunnelBrowser(await resolveTunnelGrant(config), {
39
- ...(config.connect ?? {}),
40
- signal,
41
- observer,
42
- });
43
- },
44
- };
45
- }
46
- export function createDirectBrowserReconnectConfig(config) {
47
- let traceId = config.artifact?.correlation?.trace_id;
1
+ import { createArtifactResolver, updateTraceId } from "../reconnect/artifactControlplane.js";
2
+ import { connectBrowser } from "./connect.js";
3
+ export function createBrowserReconnectConfig(config) {
4
+ if (config.source.kind === "once" && config.autoReconnect?.enabled) {
5
+ throw new Error("automatic reconnect requires a refreshable artifact source");
6
+ }
7
+ let traceId = config.source.kind === "once" ? config.source.artifact.correlation?.trace_id : undefined;
8
+ const acquire = createArtifactResolver(config.source);
48
9
  return {
49
10
  ...(config.observer === undefined ? {} : { observer: config.observer }),
50
11
  ...(config.autoReconnect === undefined ? {} : { autoReconnect: config.autoReconnect }),
51
12
  connectOnce: async ({ signal, observer }) => {
52
- if (config.getArtifact || config.artifact || config.artifactControlplane) {
53
- const artifact = await resolveConnectArtifact(config, traceId, signal);
54
- if (artifact.transport !== "direct") {
55
- throw new Error("Direct reconnect config requires a direct ConnectArtifact");
56
- }
57
- traceId = updateTraceId(traceId, artifact);
58
- return await connectBrowser(artifact, {
59
- ...(config.connect ?? {}),
60
- signal,
61
- observer,
62
- });
63
- }
64
- return await connectDirectBrowser(await resolveDirectInfo(config), {
65
- ...(config.connect ?? {}),
66
- signal,
67
- observer,
68
- });
13
+ const artifact = await acquire({ ...(traceId === undefined ? {} : { traceId }), signal });
14
+ traceId = updateTraceId(traceId, artifact);
15
+ return await connectBrowser(artifact, { ...(config.connect ?? {}), signal, observer });
69
16
  },
70
17
  };
71
18
  }
72
- export function createBrowserReconnectConfig(config) {
73
- if (config.mode === "direct") {
74
- return createDirectBrowserReconnectConfig(config);
75
- }
76
- return createTunnelBrowserReconnectConfig(config);
77
- }
19
+ export const createTunnelBrowserReconnectConfig = createBrowserReconnectConfig;
20
+ export const createDirectBrowserReconnectConfig = createBrowserReconnectConfig;
@@ -1,8 +1,13 @@
1
+ import { type YamuxLimits } from "../yamux/session.js";
1
2
  import { type ClientObserverLike } from "../observability/observer.js";
2
- import { type WebSocketLike } from "../ws-client/binaryTransport.js";
3
+ import { type WebSocketLike, type WebSocketLimits } from "../ws-client/binaryTransport.js";
3
4
  import type { ClientInternal } from "../client.js";
4
5
  import type { ConnectScopeResolverMap } from "../connect/internalNormalize.js";
5
6
  import { type TransportSecurityPolicy } from "./transportSecurity.js";
7
+ export type LivenessOptions = Readonly<{
8
+ intervalMs?: number;
9
+ timeoutMs?: number;
10
+ }>;
6
11
  export type ConnectOptionsBase = Readonly<{
7
12
  /** Explicit Origin value (required). In browsers this must match window.location.origin. */
8
13
  origin: string;
@@ -20,8 +25,12 @@ export type ConnectOptionsBase = Readonly<{
20
25
  maxRecordBytes?: number;
21
26
  /** Maximum buffered plaintext bytes in the secure channel (0 uses default). */
22
27
  maxBufferedBytes?: number;
23
- /** Maximum queued websocket bytes before backpressure (0 uses default). */
24
- maxWsQueuedBytes?: number;
28
+ /** Preferred plaintext bytes per outbound encrypted record (default 64 KiB). */
29
+ outboundRecordChunkBytes?: number;
30
+ /** WebSocket inbound and outbound queue limits. */
31
+ webSocketLimits?: Partial<WebSocketLimits>;
32
+ /** Yamux stream, frame, and receive-memory limits. */
33
+ yamuxLimits?: Partial<YamuxLimits>;
25
34
  /** Optional factory for creating the WebSocket instance. */
26
35
  wsFactory?: (url: string, origin: string) => WebSocketLike;
27
36
  /** Optional observer for client metrics. */
@@ -32,8 +41,8 @@ export type ConnectOptionsBase = Readonly<{
32
41
  scopeResolvers?: ConnectScopeResolverMap;
33
42
  /** Experimental migration switch for optional scope failures. */
34
43
  relaxedOptionalScopeValidation?: boolean;
35
- /** Encrypted keepalive ping interval in milliseconds (0 disables). */
36
- keepaliveIntervalMs?: number;
44
+ /** Acknowledged Yamux liveness checks, or false to disable automatic checks. */
45
+ liveness?: false | LivenessOptions;
37
46
  }>;
38
47
  export type ConnectCoreArgs = Readonly<{
39
48
  path: "tunnel" | "direct";
@@ -1,16 +1,19 @@
1
1
  import { clientHandshake } from "../e2ee/handshake.js";
2
2
  import { ByteReader } from "../yamux/byteReader.js";
3
3
  import { YamuxSession } from "../yamux/session.js";
4
+ import { DEFAULT_YAMUX_LIMITS } from "../yamux/session.js";
4
5
  import { RpcClient } from "../rpc/client.js";
5
6
  import { writeStreamHello } from "../streamhello/streamHello.js";
6
- import { normalizeObserver, nowSeconds } from "../observability/observer.js";
7
+ import { emitObserverDiagnostic, normalizeObserver, nowSeconds } from "../observability/observer.js";
7
8
  import { base64urlDecode } from "../utils/base64url.js";
8
9
  import { AbortError, FlowersecError, throwIfAborted } from "../utils/errors.js";
9
- import { WebSocketBinaryTransport, WsCloseError } from "../ws-client/binaryTransport.js";
10
+ import { DEFAULT_WEB_SOCKET_LIMITS, WebSocketBinaryTransport, WsCloseError } from "../ws-client/binaryTransport.js";
10
11
  import { OriginMismatchError, WsFactoryRequiredError, classifyConnectError, classifyHandshakeError, createWebSocket, waitOpen, withAbortAndTimeout, } from "./common.js";
11
12
  import { prepareChannelId } from "./contract.js";
12
13
  import { isTunnelAttachCloseReason } from "./tunnelAttachCloseReason.js";
13
14
  import { enforceTransportSecurity } from "./transportSecurity.js";
15
+ import { maxPlaintextBytes } from "../e2ee/record.js";
16
+ import { isYamuxResourceExhaustedError } from "../yamux/errors.js";
14
17
  export async function connectCore(args) {
15
18
  const observer = normalizeObserver(args.opts.observer, { path: args.path });
16
19
  const signal = args.opts.signal;
@@ -32,6 +35,9 @@ export async function connectCore(args) {
32
35
  if (origin === "") {
33
36
  throw new FlowersecError({ path: args.path, stage: "validate", code: "missing_origin", message: "missing origin" });
34
37
  }
38
+ const invalidOption = (message) => {
39
+ throw new FlowersecError({ path: args.path, stage: "validate", code: "invalid_option", message });
40
+ };
35
41
  if (args.path === "tunnel" && args.attach == null) {
36
42
  throw new FlowersecError({
37
43
  path: args.path,
@@ -45,15 +51,6 @@ export async function connectCore(args) {
45
51
  const code = args.path === "tunnel" ? "missing_tunnel_url" : "missing_ws_url";
46
52
  throw new FlowersecError({ path: args.path, stage: "validate", code, message: "missing websocket url" });
47
53
  }
48
- await enforceTransportSecurity({
49
- rawUrl: wsUrl,
50
- path: args.path,
51
- ...(args.opts.transportSecurityPolicy === undefined ? {} : { policy: args.opts.transportSecurityPolicy }),
52
- ...(args.opts.observer === undefined ? {} : { observer: args.opts.observer }),
53
- });
54
- const invalidOption = (message) => {
55
- throw new FlowersecError({ path: args.path, stage: "validate", code: "invalid_option", message });
56
- };
57
54
  const connectTimeoutMs = args.opts.connectTimeoutMs ?? 10_000;
58
55
  if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs < 0) {
59
56
  invalidOption("connectTimeoutMs must be a non-negative number");
@@ -62,10 +59,6 @@ export async function connectCore(args) {
62
59
  if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
63
60
  invalidOption("handshakeTimeoutMs must be a non-negative number");
64
61
  }
65
- const keepaliveIntervalMs = args.opts.keepaliveIntervalMs ?? 0;
66
- if (!Number.isFinite(keepaliveIntervalMs) || keepaliveIntervalMs < 0) {
67
- invalidOption("keepaliveIntervalMs must be a non-negative number");
68
- }
69
62
  const clientFeatures = args.opts.clientFeatures ?? 0;
70
63
  if (!Number.isSafeInteger(clientFeatures) || clientFeatures < 0 || clientFeatures > 0xffffffff) {
71
64
  invalidOption("clientFeatures must be a uint32");
@@ -82,10 +75,22 @@ export async function connectCore(args) {
82
75
  if (!Number.isSafeInteger(maxBufferedBytes) || maxBufferedBytes < 0) {
83
76
  invalidOption("maxBufferedBytes must be a non-negative integer");
84
77
  }
85
- const maxWsQueuedBytes = args.opts.maxWsQueuedBytes ?? 0;
86
- if (!Number.isSafeInteger(maxWsQueuedBytes) || maxWsQueuedBytes < 0) {
87
- invalidOption("maxWsQueuedBytes must be a non-negative integer");
78
+ const effectiveMaxRecordBytes = maxRecordBytes > 0 ? maxRecordBytes : (1 << 20);
79
+ const outboundRecordChunkBytes = args.opts.outboundRecordChunkBytes ?? 64 * 1024;
80
+ if (!Number.isSafeInteger(outboundRecordChunkBytes) || outboundRecordChunkBytes <= 0 || outboundRecordChunkBytes > maxPlaintextBytes(effectiveMaxRecordBytes)) {
81
+ invalidOption("outboundRecordChunkBytes must be a positive integer within maxRecordBytes");
88
82
  }
83
+ validateLimitObject(args.opts.webSocketLimits, "webSocketLimits", invalidOption);
84
+ validateLimitObject(args.opts.yamuxLimits, "yamuxLimits", invalidOption);
85
+ validateWebSocketLimitRelationships(args.opts.webSocketLimits, invalidOption);
86
+ validateYamuxLimitRelationships(args.opts.yamuxLimits, invalidOption);
87
+ const liveness = normalizeLiveness(args.opts.liveness, invalidOption);
88
+ await enforceTransportSecurity({
89
+ rawUrl: wsUrl,
90
+ path: args.path,
91
+ ...(args.opts.transportSecurityPolicy === undefined ? {} : { policy: args.opts.transportSecurityPolicy }),
92
+ ...(args.opts.observer === undefined ? {} : { observer: args.opts.observer }),
93
+ });
89
94
  const channelId = prepareChannelId(args.channelId, args.path);
90
95
  let psk;
91
96
  try {
@@ -118,7 +123,7 @@ export async function connectCore(args) {
118
123
  // Install close/error/message listeners before waiting for "open" to avoid a gap where a peer close
119
124
  // (for example a tunnel attach rejection with a reason token) can be missed and misclassified as a handshake timeout.
120
125
  const transport = new WebSocketBinaryTransport(ws, {
121
- ...(maxWsQueuedBytes > 0 ? { maxQueuedBytes: maxWsQueuedBytes } : {}),
126
+ ...(args.opts.webSocketLimits === undefined ? {} : { webSocketLimits: args.opts.webSocketLimits }),
122
127
  observer,
123
128
  });
124
129
  try {
@@ -174,7 +179,8 @@ export async function connectCore(args) {
174
179
  psk,
175
180
  clientFeatures,
176
181
  maxHandshakePayload: maxHandshakePayload > 0 ? maxHandshakePayload : 8 * 1024,
177
- maxRecordBytes: maxRecordBytes > 0 ? maxRecordBytes : (1 << 20),
182
+ maxRecordBytes: effectiveMaxRecordBytes,
183
+ outboundRecordChunkBytes,
178
184
  ...(maxBufferedBytes > 0 ? { maxBufferedBytes } : {}),
179
185
  timeoutMs: handshakeTimeoutMs,
180
186
  ...(signal !== undefined ? { signal } : {}),
@@ -214,7 +220,20 @@ export async function connectCore(args) {
214
220
  write: (b) => secure.write(b),
215
221
  close: () => secure.close(),
216
222
  };
217
- const mux = new YamuxSession(conn, { client: true });
223
+ const mux = new YamuxSession(conn, {
224
+ client: true,
225
+ ...(args.opts.yamuxLimits === undefined ? {} : { limits: args.opts.yamuxLimits }),
226
+ onDiagnostic: (event) => emitObserverDiagnostic(args.opts.observer, {
227
+ path: args.path,
228
+ stage: "yamux",
229
+ code_domain: "event",
230
+ code: event.code,
231
+ result: "fail",
232
+ resource: event.resource,
233
+ current: event.current,
234
+ limit: event.limit,
235
+ }),
236
+ });
218
237
  let rpcStream;
219
238
  try {
220
239
  rpcStream = await mux.openStream();
@@ -256,16 +275,39 @@ export async function connectCore(args) {
256
275
  throw new FlowersecError({ path: args.path, stage: "secure", code: "ping_failed", message: "ping failed", cause: e });
257
276
  }
258
277
  };
259
- let keepaliveTimer;
260
- let keepaliveInFlight = false;
261
- const stopKeepalive = () => {
262
- if (keepaliveTimer === undefined)
278
+ const probeLiveness = async () => {
279
+ try {
280
+ return await mux.probeLiveness(liveness.timeoutMs);
281
+ }
282
+ catch (e) {
283
+ if (String(e?.message).includes("ping timeout")) {
284
+ emitObserverDiagnostic(args.opts.observer, { path: args.path, stage: "yamux", code_domain: "event", code: "liveness_timeout", result: "fail" });
285
+ }
286
+ try {
287
+ rpc.close();
288
+ }
289
+ catch { /* ignore */ }
290
+ try {
291
+ mux.close();
292
+ }
293
+ catch { /* ignore */ }
294
+ try {
295
+ secure.close();
296
+ }
297
+ catch { /* ignore */ }
298
+ throw new FlowersecError({ path: args.path, stage: "yamux", code: "ping_failed", message: "liveness probe failed", cause: e });
299
+ }
300
+ };
301
+ let livenessTimer;
302
+ let livenessInFlight = false;
303
+ const stopLiveness = () => {
304
+ if (livenessTimer === undefined)
263
305
  return;
264
- clearInterval(keepaliveTimer);
265
- keepaliveTimer = undefined;
306
+ clearInterval(livenessTimer);
307
+ livenessTimer = undefined;
266
308
  };
267
309
  const closeAll = () => {
268
- stopKeepalive();
310
+ stopLiveness();
269
311
  try {
270
312
  rpc.close();
271
313
  }
@@ -285,20 +327,20 @@ export async function connectCore(args) {
285
327
  // ignore
286
328
  }
287
329
  };
288
- if (keepaliveIntervalMs > 0) {
289
- keepaliveTimer = setInterval(() => {
290
- if (keepaliveInFlight)
330
+ if (liveness.intervalMs > 0) {
331
+ livenessTimer = setInterval(() => {
332
+ if (livenessInFlight)
291
333
  return;
292
- keepaliveInFlight = true;
293
- ping()
334
+ livenessInFlight = true;
335
+ probeLiveness()
294
336
  .catch(() => {
295
- closeAll();
337
+ stopLiveness();
296
338
  })
297
339
  .finally(() => {
298
- keepaliveInFlight = false;
340
+ livenessInFlight = false;
299
341
  });
300
- }, keepaliveIntervalMs);
301
- keepaliveTimer?.unref?.();
342
+ }, liveness.intervalMs);
343
+ livenessTimer?.unref?.();
302
344
  }
303
345
  return {
304
346
  path: args.path,
@@ -307,6 +349,7 @@ export async function connectCore(args) {
307
349
  mux,
308
350
  rpc,
309
351
  ping,
352
+ probeLiveness,
310
353
  openStream: async (kind, opts = {}) => {
311
354
  if (kind == null || kind === "")
312
355
  throw new FlowersecError({ path: args.path, stage: "validate", code: "missing_stream_kind", message: "missing stream kind" });
@@ -334,7 +377,8 @@ export async function connectCore(args) {
334
377
  s = await mux.openStream();
335
378
  }
336
379
  catch (e) {
337
- throw new FlowersecError({ path: args.path, stage: "yamux", code: "open_stream_failed", message: "open stream failed", cause: e });
380
+ const exhausted = isYamuxResourceExhaustedError(e);
381
+ throw new FlowersecError({ path: args.path, stage: "yamux", code: exhausted ? "resource_exhausted" : "open_stream_failed", message: exhausted ? "yamux stream limit reached" : "open stream failed", cause: e });
338
382
  }
339
383
  if (signal != null) {
340
384
  abortListener = () => {
@@ -423,3 +467,51 @@ export async function connectCore(args) {
423
467
  throw e;
424
468
  }
425
469
  }
470
+ function validateLimitObject(input, name, invalid) {
471
+ if (input === undefined)
472
+ return;
473
+ for (const [key, value] of Object.entries(input)) {
474
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0))
475
+ invalid(`${name}.${key} must be a positive integer`);
476
+ }
477
+ }
478
+ function normalizeLiveness(input, invalid) {
479
+ if (input === false || input === undefined)
480
+ return { intervalMs: 0, timeoutMs: 10_000 };
481
+ const intervalMs = input.intervalMs ?? 0;
482
+ const timeoutMs = input.timeoutMs ?? (intervalMs > 0 ? Math.min(10_000, intervalMs) : 10_000);
483
+ if (!Number.isFinite(intervalMs) || intervalMs < 0)
484
+ invalid("liveness.intervalMs must be a non-negative number");
485
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
486
+ invalid("liveness.timeoutMs must be a positive number");
487
+ return { intervalMs, timeoutMs };
488
+ }
489
+ function validateWebSocketLimitRelationships(input, invalid) {
490
+ if (input === undefined)
491
+ return;
492
+ const low = input.outboundLowWatermarkBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundLowWatermarkBytes;
493
+ const high = input.outboundHighWatermarkBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundHighWatermarkBytes;
494
+ const hard = input.outboundHardLimitBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundHardLimitBytes;
495
+ if (low > high || high > hard)
496
+ invalid("webSocketLimits outbound watermarks must satisfy low <= high <= hard");
497
+ }
498
+ function validateYamuxLimitRelationships(input, invalid) {
499
+ if (input === undefined)
500
+ return;
501
+ const active = input.maxActiveStreams ?? DEFAULT_YAMUX_LIMITS.maxActiveStreams;
502
+ const inbound = input.maxInboundStreams ?? DEFAULT_YAMUX_LIMITS.maxInboundStreams;
503
+ const frame = input.maxFrameBytes ?? DEFAULT_YAMUX_LIMITS.maxFrameBytes;
504
+ const outbound = input.preferredOutboundFrameBytes ?? Math.min(DEFAULT_YAMUX_LIMITS.preferredOutboundFrameBytes, frame);
505
+ const streamReceive = input.maxStreamReceiveBytes ?? DEFAULT_YAMUX_LIMITS.maxStreamReceiveBytes;
506
+ const sessionReceive = input.maxSessionReceiveBytes ?? DEFAULT_YAMUX_LIMITS.maxSessionReceiveBytes;
507
+ if (inbound > active)
508
+ invalid("yamuxLimits.maxInboundStreams must not exceed maxActiveStreams");
509
+ if (outbound > frame)
510
+ invalid("yamuxLimits.preferredOutboundFrameBytes must not exceed maxFrameBytes");
511
+ if (frame > streamReceive)
512
+ invalid("yamuxLimits.maxFrameBytes must not exceed maxStreamReceiveBytes");
513
+ if (streamReceive < DEFAULT_YAMUX_LIMITS.maxStreamReceiveBytes)
514
+ invalid("yamuxLimits.maxStreamReceiveBytes must cover the 256 KiB initial stream window");
515
+ if (streamReceive > sessionReceive)
516
+ invalid("yamuxLimits.maxStreamReceiveBytes must not exceed maxSessionReceiveBytes");
517
+ }
@@ -17,25 +17,14 @@ export async function enforceTransportSecurity(args) {
17
17
  host: target.host,
18
18
  runtime: detectRuntime(),
19
19
  };
20
- if (args.policy === undefined) {
21
- if (target.scheme === "ws") {
22
- emitObserverDiagnostic(args.observer, {
23
- path: args.path,
24
- stage: "validate",
25
- code_domain: "event",
26
- code: "plaintext_transport",
27
- result: "skip",
28
- });
29
- }
30
- return;
31
- }
32
20
  let allowed = false;
21
+ const policy = args.policy ?? RequireTLS;
33
22
  try {
34
- if (typeof args.policy === "function") {
35
- allowed = await args.policy(input);
23
+ if (typeof policy === "function") {
24
+ allowed = await policy(input);
36
25
  }
37
26
  else {
38
- allowed = evaluatePreset(args.policy, target);
27
+ allowed = evaluatePreset(policy, target);
39
28
  }
40
29
  }
41
30
  catch (cause) {
@@ -43,6 +32,15 @@ export async function enforceTransportSecurity(args) {
43
32
  }
44
33
  if (!allowed)
45
34
  throw denied(args.path);
35
+ if (target.scheme === "ws") {
36
+ emitObserverDiagnostic(args.observer, {
37
+ path: args.path,
38
+ stage: "transport",
39
+ code_domain: "event",
40
+ code: "plaintext_transport",
41
+ result: "skip",
42
+ });
43
+ }
46
44
  }
47
45
  function denied(path, cause) {
48
46
  return new FlowersecError({
package/dist/client.d.ts CHANGED
@@ -11,6 +11,8 @@ export type Client = Readonly<{
11
11
  signal?: AbortSignal;
12
12
  }>) => Promise<YamuxStream>;
13
13
  ping: () => Promise<void>;
14
+ /** Performs an acknowledged Yamux round trip and returns RTT in milliseconds. */
15
+ probeLiveness: () => Promise<number>;
14
16
  close: () => void;
15
17
  }>;
16
18
  export type ClientInternal = Client & Readonly<{
@@ -14,6 +14,8 @@ export type HandshakeClientOptions = Readonly<{
14
14
  maxHandshakePayload: number;
15
15
  /** Maximum record size for encrypted frames after handshake. */
16
16
  maxRecordBytes: number;
17
+ /** Preferred plaintext bytes per outbound record. */
18
+ outboundRecordChunkBytes?: number;
17
19
  /** Maximum buffered plaintext bytes for the secure channel. */
18
20
  maxBufferedBytes?: number;
19
21
  /** Optional AbortSignal to cancel the handshake. */
@@ -38,6 +40,8 @@ export type HandshakeServerOptions = Readonly<{
38
40
  maxHandshakePayload: number;
39
41
  /** Maximum record size for encrypted frames after handshake. */
40
42
  maxRecordBytes: number;
43
+ /** Preferred plaintext bytes per outbound record. */
44
+ outboundRecordChunkBytes?: number;
41
45
  /** Maximum buffered plaintext bytes for the secure channel. */
42
46
  maxBufferedBytes?: number;
43
47
  /** Optional AbortSignal to cancel the handshake. */
@@ -159,6 +159,7 @@ export async function clientHandshake(transport, opts) {
159
159
  return new SecureChannel({
160
160
  transport,
161
161
  maxRecordBytes: opts.maxRecordBytes,
162
+ ...(opts.outboundRecordChunkBytes !== undefined ? { outboundRecordChunkBytes: opts.outboundRecordChunkBytes } : {}),
162
163
  ...(opts.maxBufferedBytes !== undefined ? { maxBufferedBytes: opts.maxBufferedBytes } : {}),
163
164
  sendKey: keys.c2sKey,
164
165
  recvKey: keys.s2cKey,
@@ -316,6 +317,7 @@ export async function serverHandshake(transport, cache, opts) {
316
317
  return new SecureChannel({
317
318
  transport,
318
319
  maxRecordBytes: opts.maxRecordBytes,
320
+ ...(opts.outboundRecordChunkBytes !== undefined ? { outboundRecordChunkBytes: opts.outboundRecordChunkBytes } : {}),
319
321
  ...(opts.maxBufferedBytes !== undefined ? { maxBufferedBytes: opts.maxBufferedBytes } : {}),
320
322
  sendKey: keys.s2cKey,
321
323
  recvKey: keys.c2sKey,
@@ -19,6 +19,8 @@ export type BinaryTransport = {
19
19
  export type SecureChannelOptions = Readonly<{
20
20
  /** Maximum encoded record size (header + ciphertext). */
21
21
  maxRecordBytes: number;
22
+ /** Preferred plaintext bytes per outbound record. */
23
+ outboundRecordChunkBytes?: number;
22
24
  /** Maximum queued plaintext bytes before backpressure/errors. */
23
25
  maxBufferedBytes?: number;
24
26
  }>;
@@ -26,6 +28,7 @@ type Direction = 1 | 2;
26
28
  export declare class SecureChannel {
27
29
  private readonly transport;
28
30
  private readonly maxRecordBytes;
31
+ private readonly outboundRecordChunkBytes;
29
32
  private readonly maxBufferedBytes;
30
33
  private sendKey;
31
34
  private recvKey;
@@ -52,6 +55,7 @@ export declare class SecureChannel {
52
55
  constructor(args: {
53
56
  transport: BinaryTransport;
54
57
  maxRecordBytes: number;
58
+ outboundRecordChunkBytes?: number;
55
59
  maxBufferedBytes?: number;
56
60
  sendKey: Uint8Array;
57
61
  recvKey: Uint8Array;