@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
@@ -14,6 +14,7 @@ export class SecureChannel {
14
14
  transport;
15
15
  // Maximum allowed bytes per record frame.
16
16
  maxRecordBytes;
17
+ outboundRecordChunkBytes;
17
18
  // Upper bound for buffered plaintext in memory.
18
19
  maxBufferedBytes;
19
20
  // Active encryption keys and nonce prefixes for the current epoch.
@@ -48,6 +49,11 @@ export class SecureChannel {
48
49
  constructor(args) {
49
50
  this.transport = args.transport;
50
51
  this.maxRecordBytes = args.maxRecordBytes;
52
+ const maxPlain = Math.max(1, maxPlaintextBytes(this.maxRecordBytes));
53
+ this.outboundRecordChunkBytes = args.outboundRecordChunkBytes ?? Math.min(64 * 1024, maxPlain);
54
+ if (!Number.isSafeInteger(this.outboundRecordChunkBytes) || this.outboundRecordChunkBytes <= 0 || this.outboundRecordChunkBytes > maxPlain) {
55
+ throw new RangeError("outboundRecordChunkBytes must be a positive integer within the record plaintext limit");
56
+ }
51
57
  this.maxBufferedBytes = Math.max(0, args.maxBufferedBytes ?? 4 * (1 << 20));
52
58
  this.sendKey = args.sendKey;
53
59
  this.recvKey = args.recvKey;
@@ -64,13 +70,9 @@ export class SecureChannel {
64
70
  }
65
71
  // write splits payloads into record-sized chunks and queues them for send.
66
72
  async write(plaintext) {
67
- const maxPlain = Math.max(1, maxPlaintextBytes(this.maxRecordBytes) || plaintext.length);
68
- let off = 0;
69
- while (off < plaintext.length) {
70
- const chunk = plaintext.slice(off, Math.min(plaintext.length, off + maxPlain));
71
- await this.enqueueSend("app", chunk);
72
- off += chunk.length;
73
- }
73
+ if (plaintext.length === 0)
74
+ return;
75
+ await this.enqueueSend("app", plaintext.slice());
74
76
  }
75
77
  // read resolves with the next plaintext chunk or throws on errors/close.
76
78
  async read() {
@@ -216,8 +218,15 @@ export class SecureChannel {
216
218
  try {
217
219
  let frame;
218
220
  if (req.kind === "app") {
219
- const seq = this.reserveSendSeq();
220
- frame = encryptRecord(this.sendKey, this.sendNoncePrefix, RECORD_FLAG_APP, seq, req.payload ?? new Uint8Array(), this.maxRecordBytes);
221
+ const payload = req.payload ?? new Uint8Array();
222
+ for (let offset = 0; offset < payload.length; offset += this.outboundRecordChunkBytes) {
223
+ const chunk = payload.subarray(offset, Math.min(payload.length, offset + this.outboundRecordChunkBytes));
224
+ const seq = this.reserveSendSeq();
225
+ frame = encryptRecord(this.sendKey, this.sendNoncePrefix, RECORD_FLAG_APP, seq, chunk, this.maxRecordBytes);
226
+ await this.transport.writeBinary(frame);
227
+ }
228
+ req.resolve();
229
+ continue;
221
230
  }
222
231
  else if (req.kind === "ping") {
223
232
  const seq = this.reserveSendSeq();
package/dist/facade.d.ts CHANGED
@@ -12,6 +12,9 @@ export type { ConnectArtifact, CorrelationContext, CorrelationKV, DirectClientCo
12
12
  export { assertConnectArtifact } from "./connect/artifact.js";
13
13
  export type { ClientObserverLike } from "./observability/observer.js";
14
14
  export type { Client, ClientPath } from "./client.js";
15
+ export type { LivenessOptions } from "./client-connect/connectCore.js";
16
+ export type { WebSocketLimits } from "./ws-client/binaryTransport.js";
17
+ export type { YamuxLimits } from "./yamux/session.js";
15
18
  export type { FlowersecErrorCode, FlowersecPath, FlowersecStage } from "./utils/errors.js";
16
19
  export { FlowersecError } from "./utils/errors.js";
17
20
  export { AllowPlaintext, AllowPlaintextForLoopback, RequireTLS, } from "./client-connect/transportSecurity.js";
@@ -1,42 +1,16 @@
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 { DirectConnectOptions } from "../direct-client/connect.js";
6
3
  import type { TunnelConnectOptions } from "../tunnel-client/connect.js";
7
4
  import type { AutoReconnectConfig, ConnectConfig as ReconnectConnectConfig } from "../reconnect/index.js";
8
- import { type ArtifactAwareReconnectConfig, type ArtifactFactoryArgs } from "../reconnect/artifactControlplane.js";
9
- import type { RequestConnectArtifactInput, RequestEntryConnectArtifactInput } from "../controlplane/index.js";
10
- type SharedReconnectOptions = Readonly<{
5
+ import { type ArtifactSource } from "../reconnect/artifactControlplane.js";
6
+ export type NodeReconnectConfig = Readonly<{
7
+ source: ArtifactSource;
8
+ connect?: Omit<TunnelConnectOptions, "observer" | "signal"> | Omit<DirectConnectOptions, "observer" | "signal">;
11
9
  observer?: ClientObserverLike;
12
10
  autoReconnect?: AutoReconnectConfig;
13
11
  }>;
14
- type TunnelReconnectConnectOptions = Omit<TunnelConnectOptions, "observer" | "signal">;
15
- type DirectReconnectConnectOptions = Omit<DirectConnectOptions, "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 TunnelNodeReconnectConfig = SharedReconnectOptions & ArtifactAwareTunnelReconnectConfig & Readonly<{
27
- mode?: "tunnel";
28
- connect?: TunnelReconnectConnectOptions;
29
- grant?: ChannelInitGrant;
30
- getGrant?: () => Promise<ChannelInitGrant>;
31
- }>;
32
- export type DirectNodeReconnectConfig = SharedReconnectOptions & ArtifactAwareDirectReconnectConfig & Readonly<{
33
- mode: "direct";
34
- connect?: DirectReconnectConnectOptions;
35
- directInfo?: DirectConnectInfo;
36
- getDirectInfo?: () => Promise<DirectConnectInfo>;
37
- }>;
38
- export type NodeReconnectConfig = TunnelNodeReconnectConfig | DirectNodeReconnectConfig;
39
- export declare function createTunnelNodeReconnectConfig(config: TunnelNodeReconnectConfig): ReconnectConnectConfig;
40
- export declare function createDirectNodeReconnectConfig(config: DirectNodeReconnectConfig): ReconnectConnectConfig;
12
+ export type TunnelNodeReconnectConfig = NodeReconnectConfig;
13
+ export type DirectNodeReconnectConfig = NodeReconnectConfig;
41
14
  export declare function createNodeReconnectConfig(config: NodeReconnectConfig): ReconnectConnectConfig;
42
- export {};
15
+ export declare const createTunnelNodeReconnectConfig: typeof createNodeReconnectConfig;
16
+ export declare const createDirectNodeReconnectConfig: typeof createNodeReconnectConfig;
@@ -1,78 +1,20 @@
1
- import { resolveConnectArtifact, updateTraceId, } from "../reconnect/artifactControlplane.js";
2
- import { connectDirectNode, connectNode, connectTunnelNode } from "./connect.js";
3
- async function resolveTunnelGrant(config) {
4
- if (config.getGrant)
5
- return await config.getGrant();
6
- if (config.grant)
7
- return config.grant;
8
- throw new Error("Tunnel reconnect config requires `getGrant` or `grant`");
9
- }
10
- async function resolveDirectInfo(config) {
11
- if (config.getDirectInfo)
12
- return await config.getDirectInfo();
13
- if (config.directInfo)
14
- return config.directInfo;
15
- throw new Error("Direct reconnect config requires `getDirectInfo` or `directInfo`");
16
- }
17
- export function createTunnelNodeReconnectConfig(config) {
18
- let traceId = config.artifact?.correlation?.trace_id;
19
- return {
20
- ...(config.observer === undefined ? {} : { observer: config.observer }),
21
- ...(config.autoReconnect === undefined ? {} : { autoReconnect: config.autoReconnect }),
22
- connectOnce: async ({ signal, observer }) => {
23
- if (config.getArtifact || config.artifact || config.artifactControlplane) {
24
- const artifact = await resolveConnectArtifact(config, traceId, signal);
25
- if (artifact.transport !== "tunnel") {
26
- throw new Error("Tunnel reconnect config requires a tunnel ConnectArtifact");
27
- }
28
- traceId = updateTraceId(traceId, artifact);
29
- const connectOptions = {
30
- ...(config.connect === undefined ? {} : config.connect),
31
- signal,
32
- observer,
33
- };
34
- return await connectNode(artifact, connectOptions);
35
- }
36
- const connectOptions = {
37
- ...(config.connect === undefined ? {} : config.connect),
38
- signal,
39
- observer,
40
- };
41
- return await connectTunnelNode(await resolveTunnelGrant(config), connectOptions);
42
- },
43
- };
44
- }
45
- export function createDirectNodeReconnectConfig(config) {
46
- let traceId = config.artifact?.correlation?.trace_id;
1
+ import { createArtifactResolver, updateTraceId } from "../reconnect/artifactControlplane.js";
2
+ import { connectNode } from "./connect.js";
3
+ export function createNodeReconnectConfig(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);
47
9
  return {
48
10
  ...(config.observer === undefined ? {} : { observer: config.observer }),
49
11
  ...(config.autoReconnect === undefined ? {} : { autoReconnect: config.autoReconnect }),
50
12
  connectOnce: async ({ signal, observer }) => {
51
- if (config.getArtifact || config.artifact || config.artifactControlplane) {
52
- const artifact = await resolveConnectArtifact(config, traceId, signal);
53
- if (artifact.transport !== "direct") {
54
- throw new Error("Direct reconnect config requires a direct ConnectArtifact");
55
- }
56
- traceId = updateTraceId(traceId, artifact);
57
- const connectOptions = {
58
- ...(config.connect === undefined ? {} : config.connect),
59
- signal,
60
- observer,
61
- };
62
- return await connectNode(artifact, connectOptions);
63
- }
64
- const connectOptions = {
65
- ...(config.connect === undefined ? {} : config.connect),
66
- signal,
67
- observer,
68
- };
69
- return await connectDirectNode(await resolveDirectInfo(config), connectOptions);
13
+ const artifact = await acquire({ ...(traceId === undefined ? {} : { traceId }), signal });
14
+ traceId = updateTraceId(traceId, artifact);
15
+ return await connectNode(artifact, { ...(config.connect ?? {}), signal, observer });
70
16
  },
71
17
  };
72
18
  }
73
- export function createNodeReconnectConfig(config) {
74
- if (config.mode === "direct") {
75
- return createDirectNodeReconnectConfig(config);
76
- }
77
- return createTunnelNodeReconnectConfig(config);
78
- }
19
+ export const createTunnelNodeReconnectConfig = createNodeReconnectConfig;
20
+ export const createDirectNodeReconnectConfig = createNodeReconnectConfig;
@@ -67,6 +67,9 @@ export function createNodeWsFactory(opts = {}) {
67
67
  get readyState() {
68
68
  return raw.readyState;
69
69
  },
70
+ get bufferedAmount() {
71
+ return raw.bufferedAmount;
72
+ },
70
73
  send(data) {
71
74
  raw.send(data);
72
75
  },
@@ -6,13 +6,13 @@ export type AttachReason = "send_failed" | "too_many_connections" | "expected_at
6
6
  export type HandshakeResult = "ok" | "fail";
7
7
  export type HandshakeReason = "auth_tag_mismatch" | "handshake_failed" | "invalid_suite" | "invalid_version" | "timestamp_after_init_exp" | "timestamp_out_of_skew" | "timeout" | "canceled";
8
8
  export type WsCloseKind = "local" | "peer_or_error";
9
- export type WsErrorReason = "error" | "recv_buffer_exceeded" | "unexpected_text_frame" | "unexpected_message_type";
9
+ export type WsErrorReason = "error" | "recv_buffer_exceeded" | "send_buffer_exceeded" | "send_buffer_timeout" | "unexpected_text_frame" | "unexpected_message_type";
10
10
  export type RpcCallResult = "ok" | "rpc_error" | "handler_not_found" | "transport_error" | "canceled";
11
11
  export type DiagnosticEvent = Readonly<{
12
12
  v: 1;
13
13
  namespace: "connect";
14
14
  path: ClientPath | "auto";
15
- stage: "validate" | "normalize" | "scope" | "connect" | "attach" | "handshake" | "close" | "reconnect";
15
+ stage: "validate" | "normalize" | "scope" | "connect" | "attach" | "handshake" | "transport" | "yamux" | "rpc" | "close" | "reconnect";
16
16
  code_domain: "error" | "event";
17
17
  code: string;
18
18
  result: "ok" | "fail" | "retry" | "skip";
@@ -20,6 +20,9 @@ export type DiagnosticEvent = Readonly<{
20
20
  attempt_seq: number;
21
21
  trace_id?: string;
22
22
  session_id?: string;
23
+ resource?: string;
24
+ current?: number;
25
+ limit?: number;
23
26
  }>;
24
27
  type ObserverContext = Readonly<{
25
28
  path?: ClientPath | "auto";
@@ -54,6 +54,9 @@ function buildDiagnosticEvent(context, event) {
54
54
  ...(context.sessionId === undefined
55
55
  ? {}
56
56
  : { session_id: context.sessionId }),
57
+ ...(event.resource === undefined ? {} : { resource: event.resource }),
58
+ ...(event.current === undefined ? {} : { current: event.current }),
59
+ ...(event.limit === undefined ? {} : { limit: event.limit }),
57
60
  });
58
61
  }
59
62
  function mapConnectDiagnostic(path, result, reason) {
@@ -33,6 +33,8 @@ export type RegisterProxyIntegrationOptions = Readonly<{
33
33
  maxChunkBytes?: number;
34
34
  maxBodyBytes?: number;
35
35
  maxWsFrameBytes?: number;
36
+ maxConcurrentHttpStreams?: number;
37
+ maxQueuedHttpRequests?: number;
36
38
  timeoutMs?: number;
37
39
  pathPolicy?: ProxyRuntimePathPolicy;
38
40
  externalOrigin?: string;
@@ -181,6 +181,12 @@ function buildRuntimeOptions(preset, runtime) {
181
181
  maxBodyBytes: runtime?.maxBodyBytes ?? preset.limits.max_body_bytes,
182
182
  maxWsFrameBytes: runtime?.maxWsFrameBytes ?? preset.limits.max_ws_frame_bytes,
183
183
  timeoutMs: runtime?.timeoutMs ?? preset.limits.timeout_ms ?? 0,
184
+ ...(runtime?.maxConcurrentHttpStreams === undefined
185
+ ? {}
186
+ : { maxConcurrentHttpStreams: runtime.maxConcurrentHttpStreams }),
187
+ ...(runtime?.maxQueuedHttpRequests === undefined
188
+ ? {}
189
+ : { maxQueuedHttpRequests: runtime.maxQueuedHttpRequests }),
184
190
  ...(runtime?.pathPolicy === undefined ? {} : { pathPolicy: runtime.pathPolicy }),
185
191
  ...(runtime?.externalOrigin === undefined ? {} : { externalOrigin: runtime.externalOrigin }),
186
192
  ...(runtime?.runtimeRegistrationToken === undefined ? {} : { runtimeRegistrationToken: runtime.runtimeRegistrationToken }),
@@ -15,6 +15,8 @@ export type ProxyRuntimeLimits = Readonly<{
15
15
  maxChunkBytes: number;
16
16
  maxBodyBytes: number;
17
17
  maxWsFrameBytes: number;
18
+ maxConcurrentHttpStreams: number;
19
+ maxQueuedHttpRequests: number;
18
20
  }>;
19
21
  export type ProxyRuntime = Readonly<{
20
22
  limits: ProxyRuntimeLimits;
@@ -40,6 +42,8 @@ export type ProxyRuntimeOptions = Readonly<{
40
42
  maxChunkBytes?: number;
41
43
  maxBodyBytes?: number;
42
44
  maxWsFrameBytes?: number;
45
+ maxConcurrentHttpStreams?: number;
46
+ maxQueuedHttpRequests?: number;
43
47
  timeoutMs?: number;
44
48
  extraRequestHeaders?: readonly string[];
45
49
  extraResponseHeaders?: readonly string[];
@@ -2,6 +2,7 @@ import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../
2
2
  import { createByteReader } from "../streamio/index.js";
3
3
  import { base64urlEncode } from "../utils/base64url.js";
4
4
  import { readU32be, u32be } from "../utils/bin.js";
5
+ import { AbortError, FlowersecError, isFlowersecError } from "../utils/errors.js";
5
6
  import { CookieJar } from "./cookieJar.js";
6
7
  import { DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_CHUNK_BYTES, DEFAULT_MAX_WS_FRAME_BYTES, PROXY_KIND_HTTP1, PROXY_KIND_WS, PROXY_PROTOCOL_VERSION } from "./constants.js";
7
8
  import { filterRequestHeaders, filterResponseHeaders, filterWsOpenHeaders } from "./headerPolicy.js";
@@ -129,6 +130,123 @@ function normalizeMaxBytes(name, v, defaultValue) {
129
130
  return defaultValue;
130
131
  return n;
131
132
  }
133
+ const DEFAULT_MAX_CONCURRENT_HTTP_STREAMS = 24;
134
+ const DEFAULT_MAX_QUEUED_HTTP_REQUESTS = 128;
135
+ function normalizePositiveLimit(name, value, defaultValue) {
136
+ if (value == null)
137
+ return defaultValue;
138
+ if (!Number.isFinite(value) || !Number.isSafeInteger(value) || value <= 0) {
139
+ throw new Error(`${name} must be a positive safe integer`);
140
+ }
141
+ return value;
142
+ }
143
+ function normalizeNonNegativeLimit(name, value, defaultValue) {
144
+ if (value == null)
145
+ return defaultValue;
146
+ if (!Number.isFinite(value) || !Number.isSafeInteger(value) || value < 0) {
147
+ throw new Error(`${name} must be a non-negative safe integer`);
148
+ }
149
+ return value;
150
+ }
151
+ class HttpStreamAdmission {
152
+ path;
153
+ maxConcurrent;
154
+ maxQueued;
155
+ active = 0;
156
+ pending = [];
157
+ closed = false;
158
+ constructor(path, maxConcurrent, maxQueued) {
159
+ this.path = path;
160
+ this.maxConcurrent = maxConcurrent;
161
+ this.maxQueued = maxQueued;
162
+ }
163
+ acquire(signal) {
164
+ if (this.closed)
165
+ return Promise.reject(this.closedError());
166
+ if (signal?.aborted)
167
+ return Promise.reject(this.abortedError());
168
+ if (this.active < this.maxConcurrent && this.pending.length === 0) {
169
+ this.active++;
170
+ return Promise.resolve(this.createRelease());
171
+ }
172
+ if (this.pending.length >= this.maxQueued) {
173
+ return Promise.reject(new FlowersecError({
174
+ path: this.path,
175
+ stage: "yamux",
176
+ code: "resource_exhausted",
177
+ message: "proxy runtime HTTP request queue is full",
178
+ }));
179
+ }
180
+ return new Promise((resolve, reject) => {
181
+ const waiter = {
182
+ resolve,
183
+ reject,
184
+ ...(signal === undefined ? {} : { signal }),
185
+ };
186
+ waiter.onAbort = () => {
187
+ const index = this.pending.indexOf(waiter);
188
+ if (index < 0)
189
+ return;
190
+ this.pending.splice(index, 1);
191
+ this.cleanupWaiter(waiter);
192
+ reject(this.abortedError());
193
+ };
194
+ signal?.addEventListener("abort", waiter.onAbort, { once: true });
195
+ this.pending.push(waiter);
196
+ });
197
+ }
198
+ close() {
199
+ if (this.closed)
200
+ return;
201
+ this.closed = true;
202
+ for (const waiter of this.pending.splice(0)) {
203
+ this.cleanupWaiter(waiter);
204
+ waiter.reject(this.closedError());
205
+ }
206
+ }
207
+ assertOpen() {
208
+ if (this.closed)
209
+ throw this.closedError();
210
+ }
211
+ createRelease() {
212
+ let released = false;
213
+ return () => {
214
+ if (released)
215
+ return;
216
+ released = true;
217
+ this.active = Math.max(0, this.active - 1);
218
+ this.drain();
219
+ };
220
+ }
221
+ drain() {
222
+ while (!this.closed && this.active < this.maxConcurrent && this.pending.length > 0) {
223
+ const waiter = this.pending.shift();
224
+ this.cleanupWaiter(waiter);
225
+ if (waiter.signal?.aborted) {
226
+ waiter.reject(this.abortedError());
227
+ continue;
228
+ }
229
+ this.active++;
230
+ waiter.resolve(this.createRelease());
231
+ }
232
+ }
233
+ cleanupWaiter(waiter) {
234
+ if (waiter.onAbort != null) {
235
+ waiter.signal?.removeEventListener("abort", waiter.onAbort);
236
+ }
237
+ }
238
+ abortedError() {
239
+ return new AbortError("proxy HTTP request canceled while waiting for stream admission");
240
+ }
241
+ closedError() {
242
+ return new FlowersecError({
243
+ path: this.path,
244
+ stage: "close",
245
+ code: "not_connected",
246
+ message: "proxy runtime is disposed",
247
+ });
248
+ }
249
+ }
132
250
  async function writeChunkFrames(stream, body, chunkSize, maxBodyBytes) {
133
251
  if (maxBodyBytes > 0 && body.length > maxBodyBytes)
134
252
  throw new Error("request body too large");
@@ -169,6 +287,9 @@ export function createProxyRuntime(opts) {
169
287
  const maxChunkBytes = normalizeMaxBytes("maxChunkBytes", opts.maxChunkBytes, DEFAULT_MAX_CHUNK_BYTES);
170
288
  const maxBodyBytes = normalizeMaxBytes("maxBodyBytes", opts.maxBodyBytes, DEFAULT_MAX_BODY_BYTES);
171
289
  const maxWsFrameBytes = normalizeMaxBytes("maxWsFrameBytes", opts.maxWsFrameBytes, DEFAULT_MAX_WS_FRAME_BYTES);
290
+ const maxConcurrentHttpStreams = normalizePositiveLimit("maxConcurrentHttpStreams", opts.maxConcurrentHttpStreams, DEFAULT_MAX_CONCURRENT_HTTP_STREAMS);
291
+ const maxQueuedHttpRequests = normalizeNonNegativeLimit("maxQueuedHttpRequests", opts.maxQueuedHttpRequests, DEFAULT_MAX_QUEUED_HTTP_REQUESTS);
292
+ const httpStreamAdmission = new HttpStreamAdmission(client.path, maxConcurrentHttpStreams, maxQueuedHttpRequests);
172
293
  const timeoutMs = normalizeTimeoutMs(opts.timeoutMs);
173
294
  const extraRequestHeaders = opts.extraRequestHeaders ?? [];
174
295
  const extraResponseHeaders = opts.extraResponseHeaders ?? [];
@@ -203,6 +324,7 @@ export function createProxyRuntime(opts) {
203
324
  const dispatchFetch = (req, port) => {
204
325
  const ac = new AbortController();
205
326
  let stream = null;
327
+ let releaseAdmission = null;
206
328
  port.onmessage = (ev) => {
207
329
  const m = ev.data;
208
330
  if (m && typeof m === "object" && m.type === "flowersec-proxy:abort") {
@@ -215,6 +337,8 @@ export function createProxyRuntime(opts) {
215
337
  assertPathPolicyAllows("http", path, pathPolicy);
216
338
  const requestID = req.id.trim() !== "" ? req.id : randomB64u(18);
217
339
  const externalOrigin = externalOriginOverride ?? normalizeExternalOrigin(req.external_origin);
340
+ releaseAdmission = await httpStreamAdmission.acquire(ac.signal);
341
+ httpStreamAdmission.assertOpen();
218
342
  stream = await client.openStream(PROXY_KIND_HTTP1, { signal: ac.signal });
219
343
  const reader = createByteReader(stream, { signal: ac.signal });
220
344
  const filteredReqHeaders = filterRequestHeaders(req.headers, { extraAllowed: extraRequestHeaders });
@@ -264,8 +388,17 @@ export function createProxyRuntime(opts) {
264
388
  }
265
389
  catch (e) {
266
390
  const msg = e instanceof Error ? e.message : String(e);
267
- const status = e instanceof ProxyRuntimePolicyError ? e.status : 502;
268
- port.postMessage({ type: "flowersec-proxy:response_error", status, message: msg });
391
+ const code = isFlowersecError(e) ? e.code : undefined;
392
+ const status = e instanceof ProxyRuntimePolicyError
393
+ ? e.status
394
+ : code === "resource_exhausted" || code === "not_connected"
395
+ ? 503
396
+ : 502;
397
+ port.postMessage({
398
+ type: "flowersec-proxy:response_error",
399
+ status,
400
+ message: msg,
401
+ });
269
402
  try {
270
403
  stream?.reset(new Error(msg));
271
404
  }
@@ -274,6 +407,7 @@ export function createProxyRuntime(opts) {
274
407
  }
275
408
  }
276
409
  finally {
410
+ releaseAdmission?.();
277
411
  try {
278
412
  port.close();
279
413
  }
@@ -312,10 +446,18 @@ export function createProxyRuntime(opts) {
312
446
  return { stream, protocol: resp.protocol ?? "" };
313
447
  }
314
448
  return {
315
- limits: { maxJsonFrameBytes, maxChunkBytes, maxBodyBytes, maxWsFrameBytes },
449
+ limits: {
450
+ maxJsonFrameBytes,
451
+ maxChunkBytes,
452
+ maxBodyBytes,
453
+ maxWsFrameBytes,
454
+ maxConcurrentHttpStreams,
455
+ maxQueuedHttpRequests,
456
+ },
316
457
  dispatchFetch,
317
458
  openWebSocketStream,
318
459
  dispose: () => {
460
+ httpStreamAdmission.close();
319
461
  sw?.removeEventListener("message", onMessage);
320
462
  sw?.removeEventListener("controllerchange", registerRuntime);
321
463
  }
@@ -36,12 +36,16 @@ export declare function resolveRuntimeLimitsFromScope(scope: ProxyRuntimeScopeV1
36
36
  maxChunkBytes?: number;
37
37
  maxBodyBytes?: number;
38
38
  maxWsFrameBytes?: number;
39
+ maxConcurrentHttpStreams?: number;
40
+ maxQueuedHttpRequests?: number;
39
41
  timeoutMs?: number;
40
42
  }> | undefined): Readonly<{
41
43
  maxJsonFrameBytes?: number;
42
44
  maxChunkBytes?: number;
43
45
  maxBodyBytes?: number;
44
46
  maxWsFrameBytes?: number;
47
+ maxConcurrentHttpStreams?: number;
48
+ maxQueuedHttpRequests?: number;
45
49
  timeoutMs?: number;
46
50
  }> | undefined;
47
51
  export declare function resolvePresetInputFromScope(scope: ProxyRuntimeScopeV1, presetOverride: ProxyPresetInput | undefined): ProxyPresetInput | undefined;
@@ -1,13 +1,16 @@
1
1
  import type { ConnectArtifact } from "../connect/artifact.js";
2
2
  import { type RequestConnectArtifactInput, type RequestEntryConnectArtifactInput } from "../controlplane/index.js";
3
- export type ArtifactFactoryArgs = Readonly<{
3
+ export type ArtifactAcquireContext = Readonly<{
4
4
  traceId?: string;
5
5
  signal?: AbortSignal;
6
6
  }>;
7
- export type ArtifactAwareReconnectConfig = Readonly<{
8
- artifact?: ConnectArtifact;
9
- getArtifact?: (args: ArtifactFactoryArgs) => Promise<ConnectArtifact>;
10
- artifactControlplane?: RequestConnectArtifactInput | RequestEntryConnectArtifactInput;
7
+ export type ArtifactSource = Readonly<{
8
+ kind: "once";
9
+ artifact: ConnectArtifact;
10
+ }> | Readonly<{
11
+ kind: "refreshable";
12
+ acquire: (context: ArtifactAcquireContext) => Promise<ConnectArtifact>;
11
13
  }>;
12
- export declare function resolveConnectArtifact(config: ArtifactAwareReconnectConfig, traceId?: string, signal?: AbortSignal): Promise<ConnectArtifact>;
14
+ export declare function createControlplaneArtifactSource(input: RequestConnectArtifactInput | RequestEntryConnectArtifactInput): ArtifactSource;
15
+ export declare function createArtifactResolver(source: ArtifactSource): (context: ArtifactAcquireContext) => Promise<ConnectArtifact>;
13
16
  export declare function updateTraceId(current: string | undefined, artifact: ConnectArtifact): string | undefined;
@@ -1,33 +1,34 @@
1
1
  import { requestConnectArtifact, requestEntryConnectArtifact, } from "../controlplane/index.js";
2
- export async function resolveConnectArtifact(config, traceId, signal) {
3
- if (config.getArtifact) {
4
- return await config.getArtifact({
5
- ...(traceId === undefined ? {} : { traceId }),
6
- ...(signal === undefined ? {} : { signal }),
7
- });
8
- }
9
- if (config.artifact)
10
- return config.artifact;
11
- if (config.artifactControlplane) {
12
- const correlation = traceId === undefined
13
- ? config.artifactControlplane.correlation
14
- : { traceId };
15
- if ("entryTicket" in config.artifactControlplane) {
16
- const input = {
17
- ...config.artifactControlplane,
2
+ export function createControlplaneArtifactSource(input) {
3
+ return {
4
+ kind: "refreshable",
5
+ acquire: async ({ traceId, signal }) => {
6
+ const correlation = traceId === undefined ? input.correlation : { traceId };
7
+ if ("entryTicket" in input) {
8
+ return await requestEntryConnectArtifact({
9
+ ...input,
10
+ ...(correlation === undefined ? {} : { correlation }),
11
+ ...(signal === undefined ? {} : { signal }),
12
+ });
13
+ }
14
+ return await requestConnectArtifact({
15
+ ...input,
18
16
  ...(correlation === undefined ? {} : { correlation }),
19
17
  ...(signal === undefined ? {} : { signal }),
20
- };
21
- return await requestEntryConnectArtifact(input);
22
- }
23
- const input = {
24
- ...config.artifactControlplane,
25
- ...(correlation === undefined ? {} : { correlation }),
26
- ...(signal === undefined ? {} : { signal }),
27
- };
28
- return await requestConnectArtifact(input);
29
- }
30
- throw new Error("Artifact reconnect config requires `getArtifact`, `artifact`, or `artifactControlplane`");
18
+ });
19
+ },
20
+ };
21
+ }
22
+ export function createArtifactResolver(source) {
23
+ let consumed = false;
24
+ return async (context) => {
25
+ if (source.kind === "refreshable")
26
+ return await source.acquire(context);
27
+ if (consumed)
28
+ throw new Error("one-time artifact source has already been consumed");
29
+ consumed = true;
30
+ return source.artifact;
31
+ };
31
32
  }
32
33
  export function updateTraceId(current, artifact) {
33
34
  return artifact.correlation?.trace_id ?? current;
@@ -1,5 +1,7 @@
1
1
  import type { Client } from "../client.js";
2
2
  import { type ClientObserverLike } from "../observability/observer.js";
3
+ export type { ArtifactAcquireContext, ArtifactSource } from "./artifactControlplane.js";
4
+ export { createArtifactResolver, createControlplaneArtifactSource } from "./artifactControlplane.js";
3
5
  export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
4
6
  export type AutoReconnectConfig = Readonly<{
5
7
  enabled?: boolean;
@@ -1,4 +1,5 @@
1
1
  import { emitObserverDiagnostic, withObserverContext } from "../observability/observer.js";
2
+ export { createArtifactResolver, createControlplaneArtifactSource } from "./artifactControlplane.js";
2
3
  function normalizeAutoReconnect(cfg) {
3
4
  if (!cfg?.enabled) {
4
5
  return {
@@ -159,6 +160,12 @@ export function createReconnectManager() {
159
160
  },
160
161
  onRpcCall: (...args) => user?.onRpcCall?.(...args),
161
162
  onRpcNotify: (...args) => user?.onRpcNotify?.(...args),
163
+ onDiagnosticEvent: (event) => {
164
+ user?.onDiagnosticEvent?.(event);
165
+ if (event.code === "liveness_timeout") {
166
+ startReconnect(t, cfg, new Error("liveness timeout"));
167
+ }
168
+ },
162
169
  }, {
163
170
  attemptSeq: currentAttemptSeq,
164
171
  });