@floegence/flowersec-core 0.19.10 → 0.20.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.
Files changed (44) hide show
  1. package/README.md +8 -3
  2. package/dist/browser/index.d.ts +2 -0
  3. package/dist/browser/index.js +1 -0
  4. package/dist/browser/reconnectConfig.d.ts +8 -36
  5. package/dist/browser/reconnectConfig.js +13 -70
  6. package/dist/client-connect/connectCore.d.ts +17 -5
  7. package/dist/client-connect/connectCore.js +131 -32
  8. package/dist/client-connect/transportSecurity.d.ts +19 -0
  9. package/dist/client-connect/transportSecurity.js +114 -0
  10. package/dist/client.d.ts +2 -0
  11. package/dist/e2ee/handshake.d.ts +4 -0
  12. package/dist/e2ee/handshake.js +2 -0
  13. package/dist/e2ee/secureChannel.d.ts +4 -0
  14. package/dist/e2ee/secureChannel.js +18 -9
  15. package/dist/facade.d.ts +5 -0
  16. package/dist/facade.js +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +1 -0
  19. package/dist/node/index.d.ts +2 -0
  20. package/dist/node/index.js +1 -0
  21. package/dist/node/reconnectConfig.d.ts +8 -34
  22. package/dist/node/reconnectConfig.js +13 -71
  23. package/dist/node/wsFactory.js +3 -0
  24. package/dist/observability/observer.d.ts +5 -2
  25. package/dist/observability/observer.js +3 -0
  26. package/dist/reconnect/artifactControlplane.d.ts +9 -6
  27. package/dist/reconnect/artifactControlplane.js +28 -27
  28. package/dist/reconnect/index.d.ts +2 -0
  29. package/dist/reconnect/index.js +7 -0
  30. package/dist/rpc/server.d.ts +29 -4
  31. package/dist/rpc/server.js +142 -34
  32. package/dist/tunnel-client/connect.js +6 -6
  33. package/dist/utils/errors.d.ts +1 -1
  34. package/dist/ws-client/binaryTransport.d.ts +20 -5
  35. package/dist/ws-client/binaryTransport.js +110 -8
  36. package/dist/yamux/byteReader.d.ts +1 -0
  37. package/dist/yamux/byteReader.js +26 -0
  38. package/dist/yamux/errors.d.ts +7 -0
  39. package/dist/yamux/errors.js +15 -0
  40. package/dist/yamux/session.d.ts +31 -1
  41. package/dist/yamux/session.js +191 -12
  42. package/dist/yamux/stream.d.ts +10 -3
  43. package/dist/yamux/stream.js +61 -19
  44. package/package.json +1 -1
package/README.md CHANGED
@@ -32,6 +32,7 @@ Node.js:
32
32
  ```ts
33
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",
@@ -42,13 +43,14 @@ const client = await connectNode(artifact, {
42
43
  origin: "https://your-app.example",
43
44
  });
44
45
  await client.ping();
46
+ const rttMs = await client.probeLiveness();
45
47
  client.close();
46
48
 
47
49
  const reconnectConfig = createNodeReconnectConfig({
48
- artifactControlplane: {
50
+ source: createControlplaneArtifactSource({
49
51
  baseUrl: "https://your-app.example/api/flowersec",
50
52
  endpointId: "env_demo",
51
- },
53
+ }),
52
54
  connect: {
53
55
  origin: "https://your-app.example",
54
56
  },
@@ -57,6 +59,9 @@ const reconnectConfig = createNodeReconnectConfig({
57
59
 
58
60
  Browser `requestConnectArtifact(...)`, `requestEntryConnectArtifact(...)`, and `ControlplaneRequestError` remain available from `@floegence/flowersec-core/browser` as stable aliases.
59
61
 
62
+ High-level connects use `RequireTLS` by default. `AllowPlaintextForLoopback` permits only literal loopback
63
+ targets without DNS resolution; `AllowPlaintext` is an explicit acceptance of pre-E2EE metadata exposure.
64
+
60
65
  ## Docs
61
66
 
62
67
  - Frontend quickstart: `docs/FRONTEND_QUICKSTART.md`
@@ -64,4 +69,4 @@ Browser `requestConnectArtifact(...)`, `requestEntryConnectArtifact(...)`, and `
64
69
  - API surface contract: `docs/API_SURFACE.md`
65
70
  - Controlplane artifact fetch: `docs/CONTROLPLANE_ARTIFACT_FETCH.md`
66
71
  - Error model: `docs/ERROR_MODEL.md`
67
- - Migration guide: `docs/V0_19_MIGRATION.md`
72
+ - Migration guide: `docs/V0_20_MIGRATION.md`
@@ -1,5 +1,7 @@
1
1
  export type { ConnectBrowserOptions, DirectConnectBrowserOptions, TunnelConnectBrowserOptions } from "./connect.js";
2
2
  export { connectBrowser, connectDirectBrowser, connectTunnelBrowser } from "./connect.js";
3
+ export { AllowPlaintext, AllowPlaintextForLoopback, RequireTLS, } from "../client-connect/transportSecurity.js";
4
+ export type { TransportSecurityPolicy, TransportSecurityPolicyInput, TransportSecurityPolicyPreset, } from "../client-connect/transportSecurity.js";
3
5
  export type { ConnectArtifact, CorrelationContext, CorrelationKV, DirectClientConnectArtifact, ScopeMetadataEntry, TunnelClientConnectArtifact, } from "../connect/artifact.js";
4
6
  export { assertConnectArtifact } from "../connect/artifact.js";
5
7
  export type { ConnectArtifactRequestConfig, ControlplaneConfig, EntryConnectArtifactRequestConfig, EntryControlplaneConfig, } from "./controlplane.js";
@@ -1,4 +1,5 @@
1
1
  export { connectBrowser, connectDirectBrowser, connectTunnelBrowser } from "./connect.js";
2
+ export { AllowPlaintext, AllowPlaintextForLoopback, RequireTLS, } from "../client-connect/transportSecurity.js";
2
3
  export { assertConnectArtifact } from "../connect/artifact.js";
3
4
  export { ControlplaneRequestError, requestChannelGrant, requestConnectArtifact, requestEntryChannelGrant, requestEntryConnectArtifact, } from "./controlplane.js";
4
5
  export { createBrowserReconnectConfig, createDirectBrowserReconnectConfig, createTunnelBrowserReconnectConfig, } from "./reconnectConfig.js";
@@ -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,7 +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";
6
+ import { type TransportSecurityPolicy } from "./transportSecurity.js";
7
+ export type LivenessOptions = Readonly<{
8
+ intervalMs?: number;
9
+ timeoutMs?: number;
10
+ }>;
5
11
  export type ConnectOptionsBase = Readonly<{
6
12
  /** Explicit Origin value (required). In browsers this must match window.location.origin. */
7
13
  origin: string;
@@ -19,18 +25,24 @@ export type ConnectOptionsBase = Readonly<{
19
25
  maxRecordBytes?: number;
20
26
  /** Maximum buffered plaintext bytes in the secure channel (0 uses default). */
21
27
  maxBufferedBytes?: number;
22
- /** Maximum queued websocket bytes before backpressure (0 uses default). */
23
- 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>;
24
34
  /** Optional factory for creating the WebSocket instance. */
25
35
  wsFactory?: (url: string, origin: string) => WebSocketLike;
26
36
  /** Optional observer for client metrics. */
27
37
  observer?: ClientObserverLike;
38
+ /** Policy evaluated before any WebSocket network activity. */
39
+ transportSecurityPolicy?: TransportSecurityPolicy;
28
40
  /** Experimental scope validators keyed by scope name. */
29
41
  scopeResolvers?: ConnectScopeResolverMap;
30
42
  /** Experimental migration switch for optional scope failures. */
31
43
  relaxedOptionalScopeValidation?: boolean;
32
- /** Encrypted keepalive ping interval in milliseconds (0 disables). */
33
- keepaliveIntervalMs?: number;
44
+ /** Acknowledged Yamux liveness checks, or false to disable automatic checks. */
45
+ liveness?: false | LivenessOptions;
34
46
  }>;
35
47
  export type ConnectCoreArgs = Readonly<{
36
48
  path: "tunnel" | "direct";
@@ -1,15 +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";
14
+ import { enforceTransportSecurity } from "./transportSecurity.js";
15
+ import { maxPlaintextBytes } from "../e2ee/record.js";
16
+ import { isYamuxResourceExhaustedError } from "../yamux/errors.js";
13
17
  export async function connectCore(args) {
14
18
  const observer = normalizeObserver(args.opts.observer, { path: args.path });
15
19
  const signal = args.opts.signal;
@@ -31,6 +35,9 @@ export async function connectCore(args) {
31
35
  if (origin === "") {
32
36
  throw new FlowersecError({ path: args.path, stage: "validate", code: "missing_origin", message: "missing origin" });
33
37
  }
38
+ const invalidOption = (message) => {
39
+ throw new FlowersecError({ path: args.path, stage: "validate", code: "invalid_option", message });
40
+ };
34
41
  if (args.path === "tunnel" && args.attach == null) {
35
42
  throw new FlowersecError({
36
43
  path: args.path,
@@ -44,9 +51,6 @@ export async function connectCore(args) {
44
51
  const code = args.path === "tunnel" ? "missing_tunnel_url" : "missing_ws_url";
45
52
  throw new FlowersecError({ path: args.path, stage: "validate", code, message: "missing websocket url" });
46
53
  }
47
- const invalidOption = (message) => {
48
- throw new FlowersecError({ path: args.path, stage: "validate", code: "invalid_option", message });
49
- };
50
54
  const connectTimeoutMs = args.opts.connectTimeoutMs ?? 10_000;
51
55
  if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs < 0) {
52
56
  invalidOption("connectTimeoutMs must be a non-negative number");
@@ -55,10 +59,6 @@ export async function connectCore(args) {
55
59
  if (!Number.isFinite(handshakeTimeoutMs) || handshakeTimeoutMs < 0) {
56
60
  invalidOption("handshakeTimeoutMs must be a non-negative number");
57
61
  }
58
- const keepaliveIntervalMs = args.opts.keepaliveIntervalMs ?? 0;
59
- if (!Number.isFinite(keepaliveIntervalMs) || keepaliveIntervalMs < 0) {
60
- invalidOption("keepaliveIntervalMs must be a non-negative number");
61
- }
62
62
  const clientFeatures = args.opts.clientFeatures ?? 0;
63
63
  if (!Number.isSafeInteger(clientFeatures) || clientFeatures < 0 || clientFeatures > 0xffffffff) {
64
64
  invalidOption("clientFeatures must be a uint32");
@@ -75,10 +75,22 @@ export async function connectCore(args) {
75
75
  if (!Number.isSafeInteger(maxBufferedBytes) || maxBufferedBytes < 0) {
76
76
  invalidOption("maxBufferedBytes must be a non-negative integer");
77
77
  }
78
- const maxWsQueuedBytes = args.opts.maxWsQueuedBytes ?? 0;
79
- if (!Number.isSafeInteger(maxWsQueuedBytes) || maxWsQueuedBytes < 0) {
80
- 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");
81
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
+ });
82
94
  const channelId = prepareChannelId(args.channelId, args.path);
83
95
  let psk;
84
96
  try {
@@ -111,7 +123,7 @@ export async function connectCore(args) {
111
123
  // Install close/error/message listeners before waiting for "open" to avoid a gap where a peer close
112
124
  // (for example a tunnel attach rejection with a reason token) can be missed and misclassified as a handshake timeout.
113
125
  const transport = new WebSocketBinaryTransport(ws, {
114
- ...(maxWsQueuedBytes > 0 ? { maxQueuedBytes: maxWsQueuedBytes } : {}),
126
+ ...(args.opts.webSocketLimits === undefined ? {} : { webSocketLimits: args.opts.webSocketLimits }),
115
127
  observer,
116
128
  });
117
129
  try {
@@ -167,7 +179,8 @@ export async function connectCore(args) {
167
179
  psk,
168
180
  clientFeatures,
169
181
  maxHandshakePayload: maxHandshakePayload > 0 ? maxHandshakePayload : 8 * 1024,
170
- maxRecordBytes: maxRecordBytes > 0 ? maxRecordBytes : (1 << 20),
182
+ maxRecordBytes: effectiveMaxRecordBytes,
183
+ outboundRecordChunkBytes,
171
184
  ...(maxBufferedBytes > 0 ? { maxBufferedBytes } : {}),
172
185
  timeoutMs: handshakeTimeoutMs,
173
186
  ...(signal !== undefined ? { signal } : {}),
@@ -207,7 +220,20 @@ export async function connectCore(args) {
207
220
  write: (b) => secure.write(b),
208
221
  close: () => secure.close(),
209
222
  };
210
- 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
+ });
211
237
  let rpcStream;
212
238
  try {
213
239
  rpcStream = await mux.openStream();
@@ -249,16 +275,39 @@ export async function connectCore(args) {
249
275
  throw new FlowersecError({ path: args.path, stage: "secure", code: "ping_failed", message: "ping failed", cause: e });
250
276
  }
251
277
  };
252
- let keepaliveTimer;
253
- let keepaliveInFlight = false;
254
- const stopKeepalive = () => {
255
- 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)
256
305
  return;
257
- clearInterval(keepaliveTimer);
258
- keepaliveTimer = undefined;
306
+ clearInterval(livenessTimer);
307
+ livenessTimer = undefined;
259
308
  };
260
309
  const closeAll = () => {
261
- stopKeepalive();
310
+ stopLiveness();
262
311
  try {
263
312
  rpc.close();
264
313
  }
@@ -278,20 +327,20 @@ export async function connectCore(args) {
278
327
  // ignore
279
328
  }
280
329
  };
281
- if (keepaliveIntervalMs > 0) {
282
- keepaliveTimer = setInterval(() => {
283
- if (keepaliveInFlight)
330
+ if (liveness.intervalMs > 0) {
331
+ livenessTimer = setInterval(() => {
332
+ if (livenessInFlight)
284
333
  return;
285
- keepaliveInFlight = true;
286
- ping()
334
+ livenessInFlight = true;
335
+ probeLiveness()
287
336
  .catch(() => {
288
- closeAll();
337
+ stopLiveness();
289
338
  })
290
339
  .finally(() => {
291
- keepaliveInFlight = false;
340
+ livenessInFlight = false;
292
341
  });
293
- }, keepaliveIntervalMs);
294
- keepaliveTimer?.unref?.();
342
+ }, liveness.intervalMs);
343
+ livenessTimer?.unref?.();
295
344
  }
296
345
  return {
297
346
  path: args.path,
@@ -300,6 +349,7 @@ export async function connectCore(args) {
300
349
  mux,
301
350
  rpc,
302
351
  ping,
352
+ probeLiveness,
303
353
  openStream: async (kind, opts = {}) => {
304
354
  if (kind == null || kind === "")
305
355
  throw new FlowersecError({ path: args.path, stage: "validate", code: "missing_stream_kind", message: "missing stream kind" });
@@ -327,7 +377,8 @@ export async function connectCore(args) {
327
377
  s = await mux.openStream();
328
378
  }
329
379
  catch (e) {
330
- 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 });
331
382
  }
332
383
  if (signal != null) {
333
384
  abortListener = () => {
@@ -416,3 +467,51 @@ export async function connectCore(args) {
416
467
  throw e;
417
468
  }
418
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
+ }
@@ -0,0 +1,19 @@
1
+ import type { ClientPath } from "../client.js";
2
+ import { type ClientObserverLike } from "../observability/observer.js";
3
+ export declare const RequireTLS: "require_tls";
4
+ export declare const AllowPlaintextForLoopback: "allow_plaintext_for_loopback";
5
+ export declare const AllowPlaintext: "allow_plaintext";
6
+ export type TransportSecurityPolicyInput = Readonly<{
7
+ path: ClientPath;
8
+ scheme: "ws" | "wss";
9
+ host: string;
10
+ runtime: "browser" | "node" | "other";
11
+ }>;
12
+ export type TransportSecurityPolicyPreset = typeof RequireTLS | typeof AllowPlaintextForLoopback | typeof AllowPlaintext;
13
+ export type TransportSecurityPolicy = TransportSecurityPolicyPreset | ((input: TransportSecurityPolicyInput) => boolean | Promise<boolean>);
14
+ export declare function enforceTransportSecurity(args: Readonly<{
15
+ rawUrl: string;
16
+ path: ClientPath;
17
+ policy?: TransportSecurityPolicy;
18
+ observer?: ClientObserverLike;
19
+ }>): Promise<void>;
@@ -0,0 +1,114 @@
1
+ import { FlowersecError } from "../utils/errors.js";
2
+ import { emitObserverDiagnostic } from "../observability/observer.js";
3
+ export const RequireTLS = "require_tls";
4
+ export const AllowPlaintextForLoopback = "allow_plaintext_for_loopback";
5
+ export const AllowPlaintext = "allow_plaintext";
6
+ export async function enforceTransportSecurity(args) {
7
+ let target;
8
+ try {
9
+ target = parseWebSocketTarget(args.rawUrl);
10
+ }
11
+ catch (cause) {
12
+ throw denied(args.path, cause);
13
+ }
14
+ const input = {
15
+ path: args.path,
16
+ scheme: target.scheme,
17
+ host: target.host,
18
+ runtime: detectRuntime(),
19
+ };
20
+ let allowed = false;
21
+ const policy = args.policy ?? RequireTLS;
22
+ try {
23
+ if (typeof policy === "function") {
24
+ allowed = await policy(input);
25
+ }
26
+ else {
27
+ allowed = evaluatePreset(policy, target);
28
+ }
29
+ }
30
+ catch (cause) {
31
+ throw denied(args.path, cause);
32
+ }
33
+ if (!allowed)
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
+ }
44
+ }
45
+ function denied(path, cause) {
46
+ return new FlowersecError({
47
+ path,
48
+ stage: "validate",
49
+ code: "transport_policy_denied",
50
+ message: "transport security policy denied websocket URL",
51
+ ...(cause === undefined ? {} : { cause }),
52
+ });
53
+ }
54
+ function evaluatePreset(policy, target) {
55
+ switch (policy) {
56
+ case RequireTLS:
57
+ return target.scheme === "wss";
58
+ case AllowPlaintextForLoopback:
59
+ return target.scheme === "wss" || isLiteralLoopbackHost(target.host);
60
+ case AllowPlaintext:
61
+ return true;
62
+ }
63
+ }
64
+ function parseWebSocketTarget(rawUrl) {
65
+ const raw = rawUrl.trim();
66
+ const match = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)(?:[/?#]|$)/.exec(raw);
67
+ const scheme = match?.[1]?.toLowerCase();
68
+ const authority = match?.[2] ?? "";
69
+ if ((scheme !== "ws" && scheme !== "wss") || authority === "" || authority.includes("@")) {
70
+ throw new Error("invalid websocket URL");
71
+ }
72
+ let host;
73
+ if (authority.startsWith("[")) {
74
+ const end = authority.indexOf("]");
75
+ if (end <= 1 || (authority.slice(end + 1) !== "" && !/^:\d+$/.test(authority.slice(end + 1)))) {
76
+ throw new Error("invalid websocket URL");
77
+ }
78
+ host = authority.slice(1, end).toLowerCase();
79
+ }
80
+ else {
81
+ const pieces = authority.split(":");
82
+ if (pieces.length > 2 || (pieces.length === 2 && !/^\d+$/.test(pieces[1] ?? ""))) {
83
+ throw new Error("invalid websocket URL");
84
+ }
85
+ host = (pieces[0] ?? "").toLowerCase();
86
+ }
87
+ if (host === "")
88
+ throw new Error("invalid websocket URL");
89
+ return { scheme, host };
90
+ }
91
+ function isLiteralLoopbackHost(host) {
92
+ if (host === "localhost" || host === "::1")
93
+ return true;
94
+ const parts = host.split(".");
95
+ if (parts.length !== 4)
96
+ return false;
97
+ const octets = [];
98
+ for (const part of parts) {
99
+ if (!/^(0|[1-9]\d{0,2})$/.test(part))
100
+ return false;
101
+ const value = Number(part);
102
+ if (value > 255)
103
+ return false;
104
+ octets.push(value);
105
+ }
106
+ return octets[0] === 127;
107
+ }
108
+ function detectRuntime() {
109
+ if (typeof window !== "undefined" && typeof window.document !== "undefined")
110
+ return "browser";
111
+ if (typeof process !== "undefined" && process.versions?.node != null)
112
+ return "node";
113
+ return "other";
114
+ }
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<{