@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 }),
|
package/dist/proxy/runtime.d.ts
CHANGED
|
@@ -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[];
|
package/dist/proxy/runtime.js
CHANGED
|
@@ -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
|
|
268
|
-
|
|
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: {
|
|
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;
|