@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
@@ -1,16 +1,35 @@
1
1
  import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../framing/jsonframe.js";
2
2
  import { assertRpcEnvelope } from "./validate.js";
3
+ const DEFAULT_RPC_SERVER_OPTIONS = Object.freeze({
4
+ maxConcurrentRequests: 32,
5
+ maxQueuedRequests: 128,
6
+ maxQueuedNotifications: 128,
7
+ });
3
8
  // RpcServer dispatches request envelopes to registered handlers.
4
9
  export class RpcServer {
5
- readExactly;
6
- write;
10
+ transport;
7
11
  // Registered handlers keyed by type ID.
8
12
  handlers = new Map();
9
13
  // Closed flag to stop the serve loop.
10
14
  closed = false;
11
- constructor(readExactly, write) {
12
- this.readExactly = readExactly;
13
- this.write = write;
15
+ options;
16
+ requests = [];
17
+ notifications = [];
18
+ requestWaiters = [];
19
+ notificationWaiters = [];
20
+ writeChain = Promise.resolve();
21
+ terminalError;
22
+ terminalSignal;
23
+ signalTerminal;
24
+ transportClosed = false;
25
+ constructor(transport, options = {}) {
26
+ this.transport = transport;
27
+ this.terminalSignal = new Promise((resolve) => { this.signalTerminal = resolve; });
28
+ this.options = {
29
+ maxConcurrentRequests: positiveInteger(options.maxConcurrentRequests ?? DEFAULT_RPC_SERVER_OPTIONS.maxConcurrentRequests, "maxConcurrentRequests"),
30
+ maxQueuedRequests: nonNegativeInteger(options.maxQueuedRequests ?? DEFAULT_RPC_SERVER_OPTIONS.maxQueuedRequests, "maxQueuedRequests"),
31
+ maxQueuedNotifications: nonNegativeInteger(options.maxQueuedNotifications ?? DEFAULT_RPC_SERVER_OPTIONS.maxQueuedNotifications, "maxQueuedNotifications"),
32
+ };
14
33
  }
15
34
  // register binds a handler to a type ID.
16
35
  register(typeId, h) {
@@ -18,50 +37,139 @@ export class RpcServer {
18
37
  }
19
38
  // serve handles request/response frames until closed or aborted.
20
39
  async serve(signal) {
21
- while (!this.closed) {
22
- if (signal?.aborted)
23
- throw signal.reason ?? new Error("aborted");
24
- const v = assertRpcEnvelope(await readJsonFrame(this.readExactly, DEFAULT_MAX_JSON_FRAME_BYTES));
25
- if (v.response_to !== 0)
26
- continue;
27
- if (v.request_id === 0) {
28
- const h = this.handlers.get(v.type_id >>> 0);
29
- if (h != null) {
30
- try {
31
- await h(v.payload);
32
- }
33
- catch {
34
- // Keep the serve loop alive on notification handler errors.
40
+ const supervise = (worker) => worker.catch((err) => {
41
+ this.fail(err);
42
+ });
43
+ const workers = Array.from({ length: this.options.maxConcurrentRequests }, () => supervise(this.requestWorker()));
44
+ workers.push(supervise(this.notificationWorker()));
45
+ try {
46
+ while (!this.closed) {
47
+ if (signal?.aborted)
48
+ throw signal.reason ?? new Error("aborted");
49
+ const next = await Promise.race([
50
+ readJsonFrame(this.transport.readExactly, DEFAULT_MAX_JSON_FRAME_BYTES),
51
+ this.terminalSignal.then((error) => { throw error; }),
52
+ ]);
53
+ const v = assertRpcEnvelope(next);
54
+ if (v.response_to !== 0)
55
+ continue;
56
+ if (v.request_id === 0) {
57
+ if (this.notifications.length >= this.options.maxQueuedNotifications) {
58
+ throw new Error("rpc notification queue exhausted");
35
59
  }
60
+ this.notifications.push({ envelope: v });
61
+ this.wakeOne(this.notificationWaiters);
62
+ continue;
36
63
  }
37
- continue;
64
+ if (this.requests.length >= this.options.maxQueuedRequests) {
65
+ await this.writeResponse(v, { payload: null, error: { code: 429, message: "server overloaded" } });
66
+ continue;
67
+ }
68
+ this.requests.push({ envelope: v });
69
+ this.wakeOne(this.requestWaiters);
38
70
  }
71
+ }
72
+ catch (err) {
73
+ this.terminalError = err;
74
+ this.close(err);
75
+ throw err;
76
+ }
77
+ finally {
78
+ this.close(this.terminalError ?? new Error("rpc server closed"));
79
+ void Promise.allSettled(workers);
80
+ }
81
+ }
82
+ // close stops the serve loop and closes the underlying RPC stream.
83
+ close(error = new Error("rpc server closed")) {
84
+ if (!this.closed) {
85
+ this.closed = true;
86
+ this.signalTerminal(error);
87
+ for (const wake of this.requestWaiters.splice(0))
88
+ wake();
89
+ for (const wake of this.notificationWaiters.splice(0))
90
+ wake();
91
+ }
92
+ if (!this.transportClosed) {
93
+ this.transportClosed = true;
94
+ this.transport.close(error);
95
+ }
96
+ }
97
+ fail(error) {
98
+ if (this.terminalError !== undefined)
99
+ return;
100
+ this.terminalError = error;
101
+ this.close(error);
102
+ }
103
+ async requestWorker() {
104
+ while (!this.closed) {
105
+ const work = await this.nextWork(this.requests, this.requestWaiters);
106
+ if (work == null)
107
+ return;
108
+ const v = work.envelope;
39
109
  const h = this.handlers.get(v.type_id >>> 0);
40
110
  let out;
41
- if (h == null) {
111
+ if (h == null)
42
112
  out = { payload: null, error: { code: 404, message: "handler not found" } };
43
- }
44
113
  else {
45
114
  try {
46
115
  out = await h(v.payload);
47
116
  }
48
117
  catch {
49
- // Keep the serve loop alive on request handler errors.
50
118
  out = { payload: null, error: { code: 500, message: "internal error" } };
51
119
  }
52
120
  }
53
- const resp = {
54
- type_id: v.type_id,
55
- request_id: 0,
56
- response_to: v.request_id,
57
- payload: out.payload,
58
- ...(out.error != null ? { error: out.error } : {})
59
- };
60
- await writeJsonFrame(this.write, resp);
121
+ if (this.closed)
122
+ return;
123
+ await this.writeResponse(v, out);
61
124
  }
62
125
  }
63
- // close stops the serve loop.
64
- close() {
65
- this.closed = true;
126
+ async notificationWorker() {
127
+ while (!this.closed) {
128
+ const work = await this.nextWork(this.notifications, this.notificationWaiters);
129
+ if (work == null)
130
+ return;
131
+ const v = work.envelope;
132
+ const h = this.handlers.get(v.type_id >>> 0);
133
+ if (h == null)
134
+ continue;
135
+ try {
136
+ await h(v.payload);
137
+ }
138
+ catch { /* Notification failures are isolated. */ }
139
+ }
66
140
  }
141
+ async nextWork(queue, waiters) {
142
+ while (!this.closed) {
143
+ const work = queue.shift();
144
+ if (work != null)
145
+ return work;
146
+ await new Promise((resolve) => waiters.push(resolve));
147
+ }
148
+ return null;
149
+ }
150
+ wakeOne(waiters) {
151
+ waiters.shift()?.();
152
+ }
153
+ async writeResponse(request, out) {
154
+ const resp = {
155
+ type_id: request.type_id,
156
+ request_id: 0,
157
+ response_to: request.request_id,
158
+ payload: out.payload,
159
+ ...(out.error != null ? { error: out.error } : {}),
160
+ };
161
+ const write = this.writeChain.then(() => writeJsonFrame(this.transport.write, resp));
162
+ this.writeChain = write.catch(() => { });
163
+ await write;
164
+ }
165
+ }
166
+ function positiveInteger(value, name) {
167
+ if (!Number.isSafeInteger(value) || value <= 0)
168
+ throw new RangeError(`${name} must be a positive integer`);
169
+ return value;
170
+ }
171
+ function nonNegativeInteger(value, name) {
172
+ if (!Number.isSafeInteger(value) || value < 0)
173
+ throw new RangeError(`${name} must be a non-negative integer`);
174
+ return value;
67
175
  }
@@ -131,27 +131,27 @@ export async function connectTunnel(grant, opts) {
131
131
  endpoint_instance_id: endpointInstanceId
132
132
  };
133
133
  const attachJson = JSON.stringify(attach);
134
- const keepaliveIntervalMs = opts.keepaliveIntervalMs ?? defaultKeepaliveIntervalMs(idleTimeoutSeconds);
134
+ const liveness = opts.liveness ?? defaultLiveness(idleTimeoutSeconds);
135
135
  return await connectCore({
136
136
  path: "tunnel",
137
137
  wsUrl: tunnelUrl,
138
138
  channelId,
139
139
  e2eePskB64u,
140
140
  defaultSuite: checkedGrant.default_suite,
141
- opts: { ...opts, keepaliveIntervalMs },
141
+ opts: { ...opts, liveness },
142
142
  attach: { attachJson, endpointInstanceId }
143
143
  });
144
144
  }
145
- function defaultKeepaliveIntervalMs(idleTimeoutSeconds) {
145
+ function defaultLiveness(idleTimeoutSeconds) {
146
146
  if (!Number.isFinite(idleTimeoutSeconds) || idleTimeoutSeconds <= 0)
147
- return 0;
147
+ return false;
148
148
  const idleMs = Math.floor(idleTimeoutSeconds * 1000);
149
149
  if (idleMs <= 0)
150
- return 0;
150
+ return false;
151
151
  let interval = Math.floor(idleMs / 2);
152
152
  if (interval < 500)
153
153
  interval = 500;
154
154
  if (interval >= idleMs)
155
155
  interval = Math.floor(idleMs / 2);
156
- return interval;
156
+ return { intervalMs: interval, timeoutMs: Math.min(10_000, interval) };
157
157
  }
@@ -6,7 +6,7 @@ export declare class AbortError extends Error {
6
6
  }
7
7
  export type FlowersecPath = "auto" | "tunnel" | "direct";
8
8
  export type FlowersecStage = "validate" | "connect" | "attach" | "handshake" | "secure" | "yamux" | "rpc" | "close";
9
- export type FlowersecErrorCode = "timeout" | "canceled" | "invalid_version" | "invalid_input" | "invalid_option" | "invalid_endpoint_instance_id" | "invalid_psk" | "invalid_suite" | "missing_grant" | "missing_connect_info" | "missing_conn" | "missing_handler" | "missing_stream_kind" | "role_mismatch" | "missing_tunnel_url" | "missing_ws_url" | "missing_origin" | "missing_channel_id" | "missing_token" | "missing_init_exp" | "timestamp_after_init_exp" | "timestamp_out_of_skew" | "auth_tag_mismatch" | "resolve_failed" | "random_failed" | "upgrade_failed" | "dial_failed" | "attach_failed" | "too_many_connections" | "expected_attach" | "invalid_attach" | "invalid_token" | "channel_mismatch" | "init_exp_mismatch" | "idle_timeout_mismatch" | "token_replay" | "tenant_mismatch" | "policy_denied" | "policy_error" | "replace_rate_limited" | "handshake_failed" | "ping_failed" | "mux_failed" | "accept_stream_failed" | "open_stream_failed" | "stream_hello_failed" | "rpc_failed" | "not_connected";
9
+ export type FlowersecErrorCode = "timeout" | "canceled" | "invalid_version" | "invalid_input" | "invalid_option" | "invalid_endpoint_instance_id" | "invalid_psk" | "invalid_suite" | "missing_grant" | "missing_connect_info" | "missing_conn" | "missing_handler" | "missing_stream_kind" | "role_mismatch" | "missing_tunnel_url" | "missing_ws_url" | "missing_origin" | "missing_channel_id" | "missing_token" | "missing_init_exp" | "timestamp_after_init_exp" | "timestamp_out_of_skew" | "auth_tag_mismatch" | "resolve_failed" | "transport_policy_denied" | "credential_commit_failed" | "random_failed" | "upgrade_failed" | "dial_failed" | "attach_failed" | "too_many_connections" | "expected_attach" | "invalid_attach" | "invalid_token" | "channel_mismatch" | "init_exp_mismatch" | "idle_timeout_mismatch" | "token_replay" | "tenant_mismatch" | "policy_denied" | "policy_error" | "replace_rate_limited" | "handshake_failed" | "ping_failed" | "mux_failed" | "accept_stream_failed" | "open_stream_failed" | "stream_hello_failed" | "rpc_failed" | "resource_exhausted" | "not_connected";
10
10
  export declare class FlowersecError extends Error {
11
11
  readonly code: FlowersecErrorCode;
12
12
  readonly stage: FlowersecStage;
@@ -7,28 +7,41 @@ export declare class WsCloseError extends Error {
7
7
  export type WebSocketLike = {
8
8
  binaryType: string;
9
9
  readyState: number;
10
+ /** Bytes accepted by send() but not yet transmitted by the implementation. */
11
+ readonly bufferedAmount: number;
10
12
  send(data: string | ArrayBuffer | Uint8Array): void;
11
13
  close(code?: number, reason?: string): void;
12
14
  addEventListener(type: "open" | "message" | "error" | "close", listener: (ev: any) => void): void;
13
15
  removeEventListener(type: "open" | "message" | "error" | "close", listener: (ev: any) => void): void;
14
16
  };
17
+ export type WebSocketLimits = Readonly<{
18
+ maxInboundQueuedBytes: number;
19
+ outboundLowWatermarkBytes: number;
20
+ outboundHighWatermarkBytes: number;
21
+ outboundHardLimitBytes: number;
22
+ outboundDrainTimeoutMs: number;
23
+ }>;
24
+ export declare const DEFAULT_WEB_SOCKET_LIMITS: WebSocketLimits;
25
+ export type WebSocketBinaryTransportOptions = Readonly<{
26
+ webSocketLimits?: Partial<WebSocketLimits>;
27
+ observer?: ClientObserverLike;
28
+ }>;
15
29
  export declare class WebSocketBinaryTransport {
16
30
  private readonly ws;
17
31
  private readonly observer;
18
32
  private readonly queue;
19
33
  private queueHead;
20
34
  private queueBytes;
21
- private readonly maxQueuedBytes;
35
+ private readonly limits;
22
36
  private waiters;
23
37
  private waitersHead;
24
38
  private waitersSettled;
25
39
  private messageChain;
26
40
  private error;
27
41
  private localCloseRequested;
28
- constructor(ws: WebSocketLike, opts?: Readonly<{
29
- maxQueuedBytes?: number;
30
- observer?: ClientObserverLike;
31
- }>);
42
+ private writeChain;
43
+ private pendingOutboundBytes;
44
+ constructor(ws: WebSocketLike, opts?: WebSocketBinaryTransportOptions);
32
45
  readBinary(opts?: Readonly<{
33
46
  signal?: AbortSignal;
34
47
  timeoutMs?: number;
@@ -42,6 +55,8 @@ export declare class WebSocketBinaryTransport {
42
55
  private readonly onError;
43
56
  private readonly onClose;
44
57
  private push;
58
+ private sendWithBackpressure;
59
+ private failAndClose;
45
60
  private shiftQueue;
46
61
  private shiftWaiter;
47
62
  private compactWaitersMaybe;
@@ -1,4 +1,4 @@
1
- import { normalizeObserver } from "../observability/observer.js";
1
+ import { emitObserverDiagnostic, normalizeObserver } from "../observability/observer.js";
2
2
  import { AbortError, TimeoutError, throwIfAborted } from "../utils/errors.js";
3
3
  export class WsCloseError extends Error {
4
4
  code;
@@ -17,6 +17,13 @@ export class WsCloseError extends Error {
17
17
  this.reason = reason;
18
18
  }
19
19
  }
20
+ export const DEFAULT_WEB_SOCKET_LIMITS = Object.freeze({
21
+ maxInboundQueuedBytes: 4 * (1 << 20),
22
+ outboundLowWatermarkBytes: 256 * 1024,
23
+ outboundHighWatermarkBytes: 1 << 20,
24
+ outboundHardLimitBytes: 4 * (1 << 20),
25
+ outboundDrainTimeoutMs: 10_000,
26
+ });
20
27
  // WebSocketBinaryTransport adapts WebSocket messages to binary reads/writes.
21
28
  export class WebSocketBinaryTransport {
22
29
  // Underlying WebSocket instance (browser or polyfill).
@@ -30,7 +37,7 @@ export class WebSocketBinaryTransport {
30
37
  // Current buffered byte count for backpressure.
31
38
  queueBytes = 0;
32
39
  // Maximum buffered bytes before closing the socket.
33
- maxQueuedBytes;
40
+ limits;
34
41
  // Pending readers waiting for the next frame.
35
42
  waiters = [];
36
43
  // Read cursor for waiters to avoid Array.shift() O(n).
@@ -43,10 +50,13 @@ export class WebSocketBinaryTransport {
43
50
  error = null;
44
51
  // Tracks whether the close is initiated locally to avoid double-reporting.
45
52
  localCloseRequested = false;
53
+ // Promise tail used to preserve write order and apply one shared backpressure lane.
54
+ writeChain = Promise.resolve();
55
+ pendingOutboundBytes = 0;
46
56
  constructor(ws, opts = {}) {
47
57
  this.ws = ws;
48
58
  this.observer = normalizeObserver(opts.observer);
49
- this.maxQueuedBytes = Math.max(0, opts.maxQueuedBytes ?? 4 * (1 << 20));
59
+ this.limits = normalizeWebSocketLimits(opts.webSocketLimits);
50
60
  this.ws.binaryType = "arraybuffer";
51
61
  this.ws.addEventListener("message", this.onMessage);
52
62
  this.ws.addEventListener("error", this.onError);
@@ -116,7 +126,26 @@ export class WebSocketBinaryTransport {
116
126
  throwIfAborted(opts.signal, "write aborted");
117
127
  if (this.error != null)
118
128
  throw this.error;
119
- this.ws.send(frame);
129
+ if (frame.byteLength > this.limits.outboundHardLimitBytes ||
130
+ this.pendingOutboundBytes + this.ws.bufferedAmount + frame.byteLength > this.limits.outboundHardLimitBytes) {
131
+ const err = new Error("ws send queue exceeds hard limit");
132
+ this.failAndClose(err, "send_buffer_exceeded");
133
+ throw err;
134
+ }
135
+ this.pendingOutboundBytes += frame.byteLength;
136
+ let handedToWebSocket = false;
137
+ const write = this.writeChain
138
+ .then(() => this.sendWithBackpressure(frame, opts.signal, () => {
139
+ handedToWebSocket = true;
140
+ this.pendingOutboundBytes = Math.max(0, this.pendingOutboundBytes - frame.byteLength);
141
+ }))
142
+ .finally(() => {
143
+ if (!handedToWebSocket) {
144
+ this.pendingOutboundBytes = Math.max(0, this.pendingOutboundBytes - frame.byteLength);
145
+ }
146
+ });
147
+ this.writeChain = write.catch(() => { });
148
+ await write;
120
149
  }
121
150
  // close tears down listeners and rejects pending readers.
122
151
  close() {
@@ -147,7 +176,7 @@ export class WebSocketBinaryTransport {
147
176
  return;
148
177
  }
149
178
  if (data instanceof ArrayBuffer) {
150
- if (this.maxQueuedBytes > 0 && this.queueBytes + data.byteLength > this.maxQueuedBytes) {
179
+ if (this.queueBytes + data.byteLength > this.limits.maxInboundQueuedBytes) {
151
180
  this.fail(new Error("ws recv buffer exceeded"), "recv_buffer_exceeded");
152
181
  this.localCloseRequested = true;
153
182
  this.observer.onWsClose("local");
@@ -159,7 +188,7 @@ export class WebSocketBinaryTransport {
159
188
  }
160
189
  if (ArrayBuffer.isView(data)) {
161
190
  const view = data;
162
- if (this.maxQueuedBytes > 0 && this.queueBytes + view.byteLength > this.maxQueuedBytes) {
191
+ if (this.queueBytes + view.byteLength > this.limits.maxInboundQueuedBytes) {
163
192
  this.fail(new Error("ws recv buffer exceeded"), "recv_buffer_exceeded");
164
193
  this.localCloseRequested = true;
165
194
  this.observer.onWsClose("local");
@@ -170,7 +199,7 @@ export class WebSocketBinaryTransport {
170
199
  return;
171
200
  }
172
201
  if (typeof Blob !== "undefined" && data instanceof Blob) {
173
- if (this.maxQueuedBytes > 0 && this.queueBytes + data.size > this.maxQueuedBytes) {
202
+ if (this.queueBytes + data.size > this.limits.maxInboundQueuedBytes) {
174
203
  this.fail(new Error("ws recv buffer exceeded"), "recv_buffer_exceeded");
175
204
  this.localCloseRequested = true;
176
205
  this.observer.onWsClose("local");
@@ -216,7 +245,7 @@ export class WebSocketBinaryTransport {
216
245
  w.resolve(b);
217
246
  return;
218
247
  }
219
- if (this.maxQueuedBytes > 0 && this.queueBytes + b.length > this.maxQueuedBytes) {
248
+ if (this.queueBytes + b.length > this.limits.maxInboundQueuedBytes) {
220
249
  this.fail(new Error("ws recv buffer exceeded"), "recv_buffer_exceeded");
221
250
  this.localCloseRequested = true;
222
251
  this.observer.onWsClose("local");
@@ -226,6 +255,58 @@ export class WebSocketBinaryTransport {
226
255
  this.queue.push(b);
227
256
  this.queueBytes += b.length;
228
257
  }
258
+ async sendWithBackpressure(frame, signal, onHandedToWebSocket) {
259
+ throwIfAborted(signal, "write aborted");
260
+ if (this.error != null)
261
+ throw this.error;
262
+ if (frame.byteLength > this.limits.outboundHardLimitBytes) {
263
+ const err = new Error("ws send frame exceeds hard limit");
264
+ this.failAndClose(err, "send_buffer_exceeded");
265
+ throw err;
266
+ }
267
+ const startedAt = Date.now();
268
+ const mustDrain = this.ws.bufferedAmount + frame.byteLength > this.limits.outboundHighWatermarkBytes ||
269
+ this.ws.bufferedAmount + frame.byteLength > this.limits.outboundHardLimitBytes;
270
+ while (mustDrain) {
271
+ throwIfAborted(signal, "write aborted");
272
+ if (this.error != null)
273
+ throw this.error;
274
+ if (Date.now() - startedAt >= this.limits.outboundDrainTimeoutMs) {
275
+ const err = new TimeoutError("ws send buffer drain timeout");
276
+ this.failAndClose(err, "send_buffer_timeout");
277
+ throw err;
278
+ }
279
+ await new Promise((resolve) => setTimeout(resolve, 10));
280
+ if (this.ws.bufferedAmount <= this.limits.outboundLowWatermarkBytes &&
281
+ this.ws.bufferedAmount + frame.byteLength <= this.limits.outboundHardLimitBytes) {
282
+ break;
283
+ }
284
+ }
285
+ this.ws.send(frame);
286
+ onHandedToWebSocket();
287
+ if (this.ws.bufferedAmount > this.limits.outboundHardLimitBytes) {
288
+ const err = new Error("ws send buffer exceeded");
289
+ this.failAndClose(err, "send_buffer_exceeded");
290
+ throw err;
291
+ }
292
+ }
293
+ failAndClose(err, reason) {
294
+ emitObserverDiagnostic(this.observer, {
295
+ stage: "transport",
296
+ code_domain: "event",
297
+ code: reason === "send_buffer_timeout" ? "queue_pressure" : "resource_limit_reached",
298
+ result: "fail",
299
+ resource: "websocket_outbound_bytes",
300
+ current: this.ws.bufferedAmount + this.pendingOutboundBytes,
301
+ limit: this.limits.outboundHardLimitBytes,
302
+ });
303
+ this.fail(err, reason);
304
+ if (!this.localCloseRequested) {
305
+ this.localCloseRequested = true;
306
+ this.observer.onWsClose("local");
307
+ this.ws.close();
308
+ }
309
+ }
229
310
  shiftQueue() {
230
311
  if (this.queueHead >= this.queue.length)
231
312
  return undefined;
@@ -299,3 +380,24 @@ export class WebSocketBinaryTransport {
299
380
  }
300
381
  }
301
382
  }
383
+ function normalizeWebSocketLimits(input) {
384
+ const limits = {
385
+ maxInboundQueuedBytes: input?.maxInboundQueuedBytes ?? DEFAULT_WEB_SOCKET_LIMITS.maxInboundQueuedBytes,
386
+ outboundLowWatermarkBytes: input?.outboundLowWatermarkBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundLowWatermarkBytes,
387
+ outboundHighWatermarkBytes: input?.outboundHighWatermarkBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundHighWatermarkBytes,
388
+ outboundHardLimitBytes: input?.outboundHardLimitBytes ?? DEFAULT_WEB_SOCKET_LIMITS.outboundHardLimitBytes,
389
+ outboundDrainTimeoutMs: input?.outboundDrainTimeoutMs ?? DEFAULT_WEB_SOCKET_LIMITS.outboundDrainTimeoutMs,
390
+ };
391
+ for (const [name, value] of Object.entries(limits)) {
392
+ if (!Number.isSafeInteger(value) || value < 0)
393
+ throw new TypeError(`${name} must be a non-negative integer`);
394
+ }
395
+ if (limits.maxInboundQueuedBytes === 0 || limits.outboundHardLimitBytes === 0 || limits.outboundDrainTimeoutMs === 0) {
396
+ throw new TypeError("websocket hard limits and drain timeout must be positive");
397
+ }
398
+ if (limits.outboundLowWatermarkBytes > limits.outboundHighWatermarkBytes ||
399
+ limits.outboundHighWatermarkBytes > limits.outboundHardLimitBytes) {
400
+ throw new TypeError("websocket outbound watermarks must satisfy low <= high <= hard");
401
+ }
402
+ return Object.freeze(limits);
403
+ }
@@ -6,5 +6,6 @@ export declare class ByteReader {
6
6
  private buffered;
7
7
  constructor(readChunk: () => Promise<Uint8Array | null>);
8
8
  readExactly(n: number): Promise<Uint8Array>;
9
+ discardExactly(n: number): Promise<void>;
9
10
  bufferedBytes(): number;
10
11
  }
@@ -44,6 +44,32 @@ export class ByteReader {
44
44
  }
45
45
  return out;
46
46
  }
47
+ // discardExactly consumes bytes without allocating a contiguous output buffer.
48
+ async discardExactly(n) {
49
+ if (n < 0)
50
+ throw new Error("invalid length");
51
+ let remaining = n;
52
+ while (remaining > 0) {
53
+ if (this.buffered === 0) {
54
+ const chunk = await this.readChunk();
55
+ if (chunk == null)
56
+ throw new StreamEOFError();
57
+ if (chunk.length === 0)
58
+ continue;
59
+ this.chunks.push(chunk);
60
+ this.buffered += chunk.length;
61
+ }
62
+ const head = this.chunks[this.chunkHead];
63
+ const take = Math.min(remaining, head.length - this.headOff);
64
+ this.headOff += take;
65
+ this.buffered -= take;
66
+ remaining -= take;
67
+ if (this.headOff === head.length) {
68
+ this.chunkHead++;
69
+ this.headOff = 0;
70
+ }
71
+ }
72
+ }
47
73
  // bufferedBytes returns the number of bytes currently buffered.
48
74
  bufferedBytes() {
49
75
  return this.buffered;
@@ -2,3 +2,10 @@ export declare class StreamEOFError extends Error {
2
2
  constructor(message?: string);
3
3
  }
4
4
  export declare function isStreamEOFError(e: unknown): e is StreamEOFError;
5
+ export declare class YamuxResourceExhaustedError extends Error {
6
+ readonly resource: string;
7
+ readonly current: number;
8
+ readonly limit: number;
9
+ constructor(resource: string, current: number, limit: number);
10
+ }
11
+ export declare function isYamuxResourceExhaustedError(error: unknown): error is YamuxResourceExhaustedError;
@@ -8,3 +8,18 @@ export class StreamEOFError extends Error {
8
8
  export function isStreamEOFError(e) {
9
9
  return e instanceof StreamEOFError;
10
10
  }
11
+ export class YamuxResourceExhaustedError extends Error {
12
+ resource;
13
+ current;
14
+ limit;
15
+ constructor(resource, current, limit) {
16
+ super(`yamux ${resource} limit reached (${current}/${limit})`);
17
+ this.name = "YamuxResourceExhaustedError";
18
+ this.resource = resource;
19
+ this.current = current;
20
+ this.limit = limit;
21
+ }
22
+ }
23
+ export function isYamuxResourceExhaustedError(error) {
24
+ return error instanceof YamuxResourceExhaustedError;
25
+ }
@@ -1,4 +1,19 @@
1
1
  import { YamuxStream } from "./stream.js";
2
+ export type YamuxDiagnostic = Readonly<{
3
+ code: "stream_rejected" | "resource_limit_reached";
4
+ resource: string;
5
+ current: number;
6
+ limit: number;
7
+ }>;
8
+ export type YamuxLimits = Readonly<{
9
+ maxActiveStreams: number;
10
+ maxInboundStreams: number;
11
+ maxFrameBytes: number;
12
+ preferredOutboundFrameBytes: number;
13
+ maxStreamReceiveBytes: number;
14
+ maxSessionReceiveBytes: number;
15
+ }>;
16
+ export declare const DEFAULT_YAMUX_LIMITS: YamuxLimits;
2
17
  export type ByteDuplex = {
3
18
  /** Reads the next chunk from the underlying connection. */
4
19
  read(): Promise<Uint8Array>;
@@ -14,21 +29,35 @@ export type YamuxSessionOptions = Readonly<{
14
29
  onIncomingStream?: (s: YamuxStream) => void;
15
30
  /** Maximum frame payload bytes accepted per stream frame. */
16
31
  maxFrameBytes?: number;
32
+ /** Generic stream and receive-memory limits. */
33
+ limits?: Partial<YamuxLimits>;
34
+ /** Optional generic resource diagnostic callback. */
35
+ onDiagnostic?: (event: YamuxDiagnostic) => void;
17
36
  }>;
18
37
  export declare class YamuxSession {
19
38
  private readonly conn;
20
39
  private readonly reader;
21
40
  private readonly streams;
22
41
  private readonly onIncomingStream;
23
- private readonly maxFrameBytes;
42
+ private readonly limits;
43
+ private readonly onDiagnostic;
24
44
  private readonly client;
25
45
  private nextStreamId;
26
46
  private closed;
27
47
  private readonly sendWindowWaiters;
48
+ private readonly inboundStreams;
49
+ private sessionReceiveBytes;
50
+ private nextPingId;
51
+ private readonly pingWaiters;
52
+ private activeProbe;
28
53
  constructor(conn: ByteDuplex, opts: YamuxSessionOptions);
29
54
  openStream(): Promise<YamuxStream>;
30
55
  getStream(id: number): YamuxStream | undefined;
31
56
  writeRaw(chunk: Uint8Array): Promise<void>;
57
+ outboundFrameBytes(): number;
58
+ releaseReceiveBytes(bytes: number): void;
59
+ probeLiveness(timeoutMs?: number): Promise<number>;
60
+ private startLivenessProbe;
32
61
  sendRst(id: number): Promise<void>;
33
62
  notifySendWindow(streamId: number): void;
34
63
  waitForSendWindow(streamId: number): Promise<void>;
@@ -41,4 +70,5 @@ export declare class YamuxSession {
41
70
  private handleDataFrame;
42
71
  private handleWindowUpdateFrame;
43
72
  private isInboundStreamIdValid;
73
+ private diagnostic;
44
74
  }