@floegence/flowersec-core 0.20.0 → 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.
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floegence/flowersec-core",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Flowersec core TypeScript library (browser-friendly E2EE + multiplexing over WebSocket).",
5
5
  "license": "MIT",
6
6
  "repository": {