@floegence/flowersec-core 0.21.0 → 0.22.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 (40) hide show
  1. package/README.md +9 -1
  2. package/dist/client-connect/connectCore.d.ts +1 -1
  3. package/dist/client-connect/connectCore.js +5 -4
  4. package/dist/controlplane/channelInit.d.ts +27 -0
  5. package/dist/controlplane/channelInit.js +116 -0
  6. package/dist/controlplane/http.d.ts +16 -0
  7. package/dist/controlplane/http.js +59 -0
  8. package/dist/controlplane/index.d.ts +4 -0
  9. package/dist/controlplane/index.js +4 -0
  10. package/dist/controlplane/issuer.d.ts +15 -0
  11. package/dist/controlplane/issuer.js +53 -0
  12. package/dist/controlplane/request.js +2 -1
  13. package/dist/controlplane/token.d.ts +33 -0
  14. package/dist/controlplane/token.js +119 -0
  15. package/dist/defaults.d.ts +46 -0
  16. package/dist/defaults.js +46 -0
  17. package/dist/endpoint/index.d.ts +83 -0
  18. package/dist/endpoint/index.js +384 -0
  19. package/dist/endpoint/node.d.ts +9 -0
  20. package/dist/endpoint/node.js +51 -0
  21. package/dist/framing/jsonframe.js +2 -1
  22. package/dist/node/index.d.ts +3 -0
  23. package/dist/node/index.js +3 -0
  24. package/dist/proxy/constants.js +4 -3
  25. package/dist/proxy/controllerWindow.js +23 -3
  26. package/dist/proxy/headerPolicy.js +1 -0
  27. package/dist/proxy/runtime.d.ts +1 -0
  28. package/dist/proxy/runtime.js +38 -1
  29. package/dist/proxy/server.d.ts +27 -0
  30. package/dist/proxy/server.js +480 -0
  31. package/dist/proxy/serviceWorker.js +23 -5
  32. package/dist/proxy/windowBridgeProtocol.d.ts +1 -0
  33. package/dist/reconnect/index.js +33 -22
  34. package/dist/rpc/server.d.ts +9 -2
  35. package/dist/rpc/server.js +33 -10
  36. package/dist/yamux/session.d.ts +6 -1
  37. package/dist/yamux/session.js +12 -6
  38. package/dist/yamux/stream.d.ts +1 -0
  39. package/dist/yamux/stream.js +19 -4
  40. package/package.json +5 -1
@@ -345,11 +345,44 @@ export function createProxyRuntime(opts) {
345
345
  const ac = new AbortController();
346
346
  let stream = null;
347
347
  let releaseAdmission = null;
348
+ const usesResponseCredits = req.response_flow_control === "chunk_credit_v1";
349
+ let responseCreditAvailable = false;
350
+ let responseCreditResolve = null;
351
+ const wakeResponseCreditWaiter = () => {
352
+ const resolve = responseCreditResolve;
353
+ responseCreditResolve = null;
354
+ resolve?.();
355
+ };
348
356
  port.onmessage = (ev) => {
349
357
  const m = ev.data;
350
- if (m && typeof m === "object" && m.type === "flowersec-proxy:abort") {
358
+ if (!m || typeof m !== "object")
359
+ return;
360
+ if (m.type === "flowersec-proxy:abort") {
361
+ responseCreditAvailable = false;
351
362
  ac.abort("aborted");
363
+ wakeResponseCreditWaiter();
364
+ return;
365
+ }
366
+ if (usesResponseCredits && m.type === "flowersec-proxy:response_credit") {
367
+ responseCreditAvailable = true;
368
+ wakeResponseCreditWaiter();
369
+ }
370
+ };
371
+ const waitForResponseCredit = async () => {
372
+ if (!usesResponseCredits)
373
+ return;
374
+ if (ac.signal.aborted)
375
+ throw new AbortError("aborted");
376
+ while (!responseCreditAvailable) {
377
+ if (ac.signal.aborted)
378
+ throw new AbortError("aborted");
379
+ await new Promise((resolve) => {
380
+ responseCreditResolve = resolve;
381
+ });
352
382
  }
383
+ if (ac.signal.aborted)
384
+ throw new AbortError("aborted");
385
+ responseCreditAvailable = false;
353
386
  };
354
387
  void (async () => {
355
388
  try {
@@ -400,6 +433,9 @@ export function createProxyRuntime(opts) {
400
433
  port.postMessage({ type: "flowersec-proxy:response_meta", status, headers: passthrough });
401
434
  const chunks = await readChunkFrames(reader, maxChunkBytes, maxBodyBytes);
402
435
  for await (const chunk of chunks) {
436
+ await waitForResponseCredit();
437
+ if (ac.signal.aborted)
438
+ throw new AbortError("aborted");
403
439
  // Always transfer an ArrayBuffer (SharedArrayBuffer is not transferable).
404
440
  const ab = chunk.slice().buffer;
405
441
  port.postMessage({ type: "flowersec-proxy:response_chunk", data: ab }, [ab]);
@@ -429,6 +465,7 @@ export function createProxyRuntime(opts) {
429
465
  }
430
466
  }
431
467
  finally {
468
+ wakeResponseCreditWaiter();
432
469
  releaseAdmission?.();
433
470
  try {
434
471
  port.close();
@@ -0,0 +1,27 @@
1
+ import type { Session } from "../endpoint/index.js";
2
+ import { RpcRouter, type RpcServerOptions } from "../rpc/server.js";
3
+ import type { YamuxStream } from "../yamux/stream.js";
4
+ import { PROXY_KIND_HTTP1, PROXY_KIND_WS } from "./constants.js";
5
+ export type ProxyServerOptions = Readonly<{
6
+ upstream: string;
7
+ upstreamOrigin?: string;
8
+ allowedUpstreamHosts?: readonly string[];
9
+ maxJsonFrameBytes?: number;
10
+ maxChunkBytes?: number;
11
+ maxBodyBytes?: number;
12
+ maxWsFrameBytes?: number;
13
+ defaultTimeoutMs?: number;
14
+ maxTimeoutMs?: number;
15
+ maxConcurrentStreams?: number;
16
+ extraRequestHeaders?: readonly string[];
17
+ extraResponseHeaders?: readonly string[];
18
+ blockedResponseHeaders?: readonly string[];
19
+ extraWsHeaders?: readonly string[];
20
+ forbiddenCookieNames?: readonly string[];
21
+ forbiddenCookieNamePrefixes?: readonly string[];
22
+ fetch?: typeof fetch;
23
+ rpcRouter?: RpcRouter;
24
+ rpcServerOptions?: RpcServerOptions;
25
+ }>;
26
+ export declare function serveProxySession(session: Session, options: ProxyServerOptions, signal?: AbortSignal): Promise<void>;
27
+ export declare function serveProxyStream(kind: typeof PROXY_KIND_HTTP1 | typeof PROXY_KIND_WS, stream: YamuxStream, options: ProxyServerOptions, signal?: AbortSignal): Promise<void>;
@@ -0,0 +1,480 @@
1
+ import { createRequire } from "node:module";
2
+ import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../framing/jsonframe.js";
3
+ import { RpcRouter, RpcServer } from "../rpc/server.js";
4
+ import { createByteReader } from "../streamio/index.js";
5
+ import { readU32be, u32be } from "../utils/bin.js";
6
+ 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
+ import { filterRequestHeaders, filterResponseHeaders, filterWsOpenHeaders, isSafeHeaderValue, isValidHeaderName, normalizeHeaderName } from "./headerPolicy.js";
8
+ export async function serveProxySession(session, options, signal) {
9
+ const compiled = compileOptions(options);
10
+ const active = new Set();
11
+ try {
12
+ while (!signal?.aborted) {
13
+ const accepted = await session.acceptStream(signal === undefined ? {} : { signal });
14
+ if (accepted.kind === "rpc") {
15
+ if (active.size >= compiled.maxConcurrentStreams) {
16
+ await accepted.stream.reset(new Error("proxy stream concurrency exhausted"));
17
+ continue;
18
+ }
19
+ const task = serveRPCStream(accepted.stream, options.rpcRouter ?? new RpcRouter(), options.rpcServerOptions, signal)
20
+ .catch(() => { })
21
+ .finally(() => active.delete(task));
22
+ active.add(task);
23
+ continue;
24
+ }
25
+ if (accepted.kind !== PROXY_KIND_HTTP1 && accepted.kind !== PROXY_KIND_WS) {
26
+ await accepted.stream.reset(new Error(`unsupported proxy stream kind ${accepted.kind}`));
27
+ continue;
28
+ }
29
+ if (active.size >= compiled.maxConcurrentStreams) {
30
+ await accepted.stream.reset(new Error("proxy stream concurrency exhausted"));
31
+ continue;
32
+ }
33
+ const task = serveProxyStreamCompiled(accepted.kind, accepted.stream, compiled, signal)
34
+ .catch(() => { })
35
+ .finally(() => active.delete(task));
36
+ active.add(task);
37
+ }
38
+ }
39
+ finally {
40
+ await Promise.allSettled(active);
41
+ }
42
+ }
43
+ async function serveRPCStream(stream, router, options, signal) {
44
+ const reader = createByteReader(stream, signal === undefined ? {} : { signal });
45
+ const server = new RpcServer({
46
+ readExactly: (length) => reader.readExactly(length),
47
+ write: (bytes) => stream.write(bytes),
48
+ close: (error) => { void stream.reset(asError(error)); },
49
+ }, options, router);
50
+ await server.serve(signal);
51
+ }
52
+ export function serveProxyStream(kind, stream, options, signal) {
53
+ return serveProxyStreamCompiled(kind, stream, compileOptions(options), signal);
54
+ }
55
+ async function serveProxyStreamCompiled(kind, stream, options, signal) {
56
+ try {
57
+ if (kind === PROXY_KIND_HTTP1)
58
+ await serveHTTP(stream, options, signal);
59
+ else
60
+ await serveWebSocket(stream, options, signal);
61
+ }
62
+ finally {
63
+ try {
64
+ await stream.close();
65
+ }
66
+ catch { /* The peer may already have reset the stream. */ }
67
+ }
68
+ }
69
+ async function serveHTTP(stream, options, signal) {
70
+ const reader = createByteReader(stream, signal === undefined ? {} : { signal });
71
+ let requestId = "unknown";
72
+ try {
73
+ const meta = assertHTTPRequestMeta(await readJsonFrame(reader, options.maxJsonFrameBytes));
74
+ requestId = meta.request_id;
75
+ const path = parsePath(meta.path);
76
+ const body = await readBody(reader, options.maxChunkBytes, options.maxBodyBytes);
77
+ const headers = requestHeaders(meta.headers, options);
78
+ applyExternalOrigin(headers, meta.external_origin);
79
+ const target = new URL(options.upstream);
80
+ target.pathname = path.pathname;
81
+ target.search = path.search;
82
+ target.hash = "";
83
+ const timeoutMs = resolveTimeout(meta.timeout_ms, options);
84
+ const controller = new AbortController();
85
+ const onAbort = () => controller.abort(signal?.reason);
86
+ signal?.addEventListener("abort", onAbort, { once: true });
87
+ const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(new Error("upstream request timeout")), timeoutMs) : undefined;
88
+ try {
89
+ const method = meta.method.trim().toUpperCase();
90
+ const response = await options.fetch(target, {
91
+ method,
92
+ headers,
93
+ redirect: "manual",
94
+ signal: controller.signal,
95
+ ...((method === "GET" || method === "HEAD") ? {} : { body: body.slice().buffer }),
96
+ });
97
+ const responseHeaders = collectResponseHeaders(response.headers, options);
98
+ const responseMeta = {
99
+ v: PROXY_PROTOCOL_VERSION,
100
+ request_id: requestId,
101
+ ok: true,
102
+ status: response.status,
103
+ headers: responseHeaders,
104
+ };
105
+ await writeJsonFrame(stream, responseMeta);
106
+ if (response.body == null) {
107
+ await stream.write(u32be(0));
108
+ return;
109
+ }
110
+ const bodyReader = response.body.getReader();
111
+ let total = 0;
112
+ while (true) {
113
+ const next = await bodyReader.read();
114
+ if (next.done)
115
+ break;
116
+ const bytes = next.value;
117
+ total += bytes.length;
118
+ if (total > options.maxBodyBytes)
119
+ throw new ProxyServerError("response_body_too_large", "response body too large");
120
+ for (let offset = 0; offset < bytes.length; offset += options.maxChunkBytes) {
121
+ const chunk = bytes.subarray(offset, Math.min(bytes.length, offset + options.maxChunkBytes));
122
+ await stream.write(u32be(chunk.length));
123
+ await stream.write(chunk);
124
+ }
125
+ }
126
+ await stream.write(u32be(0));
127
+ }
128
+ finally {
129
+ if (timer != null)
130
+ clearTimeout(timer);
131
+ signal?.removeEventListener("abort", onAbort);
132
+ }
133
+ }
134
+ catch (error) {
135
+ await writeHTTPError(stream, requestId, classifyHTTPError(error), error);
136
+ }
137
+ }
138
+ async function serveWebSocket(stream, options, signal) {
139
+ const reader = createByteReader(stream, signal === undefined ? {} : { signal });
140
+ let connId = "unknown";
141
+ let raw;
142
+ try {
143
+ const meta = assertWSOpenMeta(await readJsonFrame(reader, options.maxJsonFrameBytes));
144
+ connId = meta.conn_id;
145
+ const path = parsePath(meta.path);
146
+ const target = new URL(options.upstream);
147
+ target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
148
+ target.pathname = path.pathname;
149
+ target.search = path.search;
150
+ target.hash = "";
151
+ const filtered = serverWSHeaders(meta.headers, options);
152
+ const protocolsHeader = filtered.find((header) => header.name === "sec-websocket-protocol")?.value ?? "";
153
+ const protocols = protocolsHeader.split(",").map((value) => value.trim()).filter((value) => value !== "");
154
+ const headers = { Origin: options.upstreamOrigin };
155
+ for (const header of filtered) {
156
+ if (header.name === "sec-websocket-protocol")
157
+ continue;
158
+ headers[header.name] = headers[header.name] == null ? header.value : `${headers[header.name]}, ${header.value}`;
159
+ }
160
+ const require = createRequire(import.meta.url);
161
+ const module = require("ws");
162
+ const WebSocketCtor = module.WebSocket ?? module;
163
+ raw = new WebSocketCtor(target.toString(), protocols, {
164
+ headers,
165
+ maxPayload: options.maxWsFrameBytes,
166
+ perMessageDeflate: false,
167
+ handshakeTimeout: Math.min(options.defaultTimeoutMs, options.maxTimeoutMs),
168
+ });
169
+ await waitForUpstreamOpen(raw, signal);
170
+ const response = {
171
+ v: PROXY_PROTOCOL_VERSION,
172
+ conn_id: connId,
173
+ ok: true,
174
+ protocol: String(raw.protocol ?? ""),
175
+ };
176
+ await writeJsonFrame(stream, response);
177
+ let writeChain = Promise.resolve();
178
+ let terminalResolve;
179
+ const terminal = new Promise((resolve) => { terminalResolve = resolve; });
180
+ const writeFrame = (op, payload) => {
181
+ const write = writeChain.then(() => writeWSFrame(stream, op, payload, options.maxWsFrameBytes));
182
+ writeChain = write.catch(() => { });
183
+ void write.catch((error) => terminalResolve(asError(error)));
184
+ };
185
+ raw.on("message", (data, isBinary) => writeFrame(isBinary ? 2 : 1, toBytes(data)));
186
+ raw.on("ping", (data) => writeFrame(9, toBytes(data)));
187
+ raw.on("pong", (data) => writeFrame(10, toBytes(data)));
188
+ raw.on("close", (code, reason) => {
189
+ const reasonBytes = toBytes(reason);
190
+ const payload = new Uint8Array(2 + reasonBytes.length);
191
+ payload[0] = (code >>> 8) & 0xff;
192
+ payload[1] = code & 0xff;
193
+ payload.set(reasonBytes, 2);
194
+ writeFrame(8, payload);
195
+ terminalResolve();
196
+ });
197
+ raw.on("error", (error) => terminalResolve(error));
198
+ const inbound = (async () => {
199
+ while (true) {
200
+ const frame = await readWSFrame(reader, options.maxWsFrameBytes);
201
+ switch (frame.op) {
202
+ case 1:
203
+ raw.send(frame.payload, { binary: false });
204
+ break;
205
+ case 2:
206
+ raw.send(frame.payload, { binary: true });
207
+ break;
208
+ case 8:
209
+ raw.close(frame.payload.length >= 2 ? (frame.payload[0] << 8) | frame.payload[1] : 1000, new TextDecoder().decode(frame.payload.subarray(2)));
210
+ return;
211
+ case 9:
212
+ raw.ping(frame.payload);
213
+ break;
214
+ case 10:
215
+ raw.pong(frame.payload);
216
+ break;
217
+ default: throw new Error("invalid websocket frame operation");
218
+ }
219
+ }
220
+ })();
221
+ const result = await Promise.race([inbound.then(() => undefined), terminal]);
222
+ if (result != null)
223
+ throw result;
224
+ await writeChain;
225
+ }
226
+ catch (error) {
227
+ if (raw == null || raw.readyState !== 1)
228
+ await writeWSOpenError(stream, connId, classifyWSError(error), error);
229
+ }
230
+ finally {
231
+ try {
232
+ raw?.close();
233
+ }
234
+ catch { /* Best effort. */ }
235
+ }
236
+ }
237
+ function compileOptions(input) {
238
+ const upstream = new URL(input.upstream);
239
+ if ((upstream.protocol !== "http:" && upstream.protocol !== "https:") || upstream.username !== "" || upstream.password !== "") {
240
+ throw new Error("upstream must be an http(s) URL without credentials");
241
+ }
242
+ const allowedHosts = new Set((input.allowedUpstreamHosts?.length ? input.allowedUpstreamHosts : ["127.0.0.1"]).map((host) => host.trim().toLowerCase()));
243
+ if (!allowedHosts.has(upstream.hostname.toLowerCase()))
244
+ throw new Error("upstream host is not allowed");
245
+ const upstreamOrigin = normalizeOrigin(input.upstreamOrigin ?? upstream.origin);
246
+ const maxJsonFrameBytes = positive(input.maxJsonFrameBytes, DEFAULT_MAX_JSON_FRAME_BYTES, "maxJsonFrameBytes");
247
+ const maxChunkBytes = positive(input.maxChunkBytes, DEFAULT_MAX_CHUNK_BYTES, "maxChunkBytes");
248
+ const maxBodyBytes = positive(input.maxBodyBytes, DEFAULT_MAX_BODY_BYTES, "maxBodyBytes");
249
+ const maxWsFrameBytes = positive(input.maxWsFrameBytes, DEFAULT_MAX_WS_FRAME_BYTES, "maxWsFrameBytes");
250
+ const defaultTimeoutMs = nonNegative(input.defaultTimeoutMs, 30_000, "defaultTimeoutMs");
251
+ const maxTimeoutMs = nonNegative(input.maxTimeoutMs, 300_000, "maxTimeoutMs");
252
+ if (maxTimeoutMs > 0 && defaultTimeoutMs > maxTimeoutMs)
253
+ throw new Error("defaultTimeoutMs exceeds maxTimeoutMs");
254
+ return {
255
+ upstream,
256
+ upstreamOrigin,
257
+ maxJsonFrameBytes,
258
+ maxChunkBytes,
259
+ maxBodyBytes,
260
+ maxWsFrameBytes,
261
+ defaultTimeoutMs,
262
+ maxTimeoutMs,
263
+ maxConcurrentStreams: positive(input.maxConcurrentStreams, 64, "maxConcurrentStreams"),
264
+ extraRequestHeaders: input.extraRequestHeaders ?? [],
265
+ extraResponseHeaders: input.extraResponseHeaders ?? [],
266
+ blockedResponseHeaders: normalizeNames(input.blockedResponseHeaders),
267
+ extraWsHeaders: input.extraWsHeaders ?? [],
268
+ forbiddenCookieNames: normalizeNames(input.forbiddenCookieNames),
269
+ forbiddenCookieNamePrefixes: [...normalizeNames(input.forbiddenCookieNamePrefixes)],
270
+ fetch: input.fetch ?? globalThis.fetch.bind(globalThis),
271
+ };
272
+ }
273
+ function assertHTTPRequestMeta(input) {
274
+ if (typeof input !== "object" || input == null || Array.isArray(input))
275
+ throw new ProxyServerError("invalid_request_meta", "invalid HTTP request meta");
276
+ const meta = input;
277
+ if (meta.v !== PROXY_PROTOCOL_VERSION || typeof meta.request_id !== "string" || meta.request_id.trim() === "")
278
+ throw new ProxyServerError("invalid_request_meta", "invalid request ID");
279
+ if (typeof meta.method !== "string" || meta.method.trim() === "" || typeof meta.path !== "string" || !Array.isArray(meta.headers))
280
+ throw new ProxyServerError("invalid_request_meta", "invalid HTTP request meta");
281
+ if (meta.timeout_ms !== undefined && (!Number.isSafeInteger(meta.timeout_ms) || meta.timeout_ms < 0))
282
+ throw new ProxyServerError("invalid_request_meta", "invalid timeout_ms");
283
+ return { ...meta, request_id: meta.request_id.trim(), method: meta.method.trim() };
284
+ }
285
+ function assertWSOpenMeta(input) {
286
+ if (typeof input !== "object" || input == null || Array.isArray(input))
287
+ throw new ProxyServerError("invalid_ws_open_meta", "invalid websocket open meta");
288
+ const meta = input;
289
+ if (meta.v !== PROXY_PROTOCOL_VERSION || typeof meta.conn_id !== "string" || meta.conn_id.trim() === "" || typeof meta.path !== "string" || !Array.isArray(meta.headers)) {
290
+ throw new ProxyServerError("invalid_ws_open_meta", "invalid websocket open meta");
291
+ }
292
+ return { ...meta, conn_id: meta.conn_id.trim() };
293
+ }
294
+ function parsePath(input) {
295
+ const path = input.trim();
296
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("://") || /[\r\n\t ]/.test(path))
297
+ throw new ProxyServerError("invalid_request_meta", "invalid path");
298
+ return new URL(path, "http://flowersec.invalid");
299
+ }
300
+ function requestHeaders(input, options) {
301
+ const filtered = filterRequestHeaders(input, { extraAllowed: options.extraRequestHeaders });
302
+ const headers = new Headers();
303
+ for (const header of filtered)
304
+ headers.append(header.name, header.value);
305
+ for (const header of input) {
306
+ if (normalizeHeaderName(header.name) !== "cookie" || !isSafeHeaderValue(header.value))
307
+ continue;
308
+ const cookie = filterCookie(header.value, options);
309
+ if (cookie !== "")
310
+ headers.append("cookie", cookie);
311
+ }
312
+ return headers;
313
+ }
314
+ function serverWSHeaders(input, options) {
315
+ const filtered = filterWsOpenHeaders(input, { extraAllowed: options.extraWsHeaders });
316
+ return filtered.flatMap((header) => header.name === "cookie" ? [{ ...header, value: filterCookie(header.value, options) }] : [header]).filter((header) => header.value !== "");
317
+ }
318
+ function filterCookie(input, options) {
319
+ return input.split(";").map((part) => part.trim()).filter((part) => {
320
+ const index = part.indexOf("=");
321
+ if (index <= 0)
322
+ return false;
323
+ const name = part.slice(0, index).trim().toLowerCase();
324
+ if (options.forbiddenCookieNames.has(name))
325
+ return false;
326
+ return !options.forbiddenCookieNamePrefixes.some((prefix) => name.startsWith(prefix));
327
+ }).join("; ");
328
+ }
329
+ function collectResponseHeaders(input, options) {
330
+ const raw = [];
331
+ const setCookies = input.getSetCookie?.() ?? [];
332
+ input.forEach((value, name) => {
333
+ if (name.toLowerCase() !== "set-cookie")
334
+ raw.push({ name, value });
335
+ });
336
+ for (const value of setCookies)
337
+ raw.push({ name: "set-cookie", value });
338
+ return filterResponseHeaders(raw, { extraAllowed: options.extraResponseHeaders }).passthrough
339
+ .concat(raw.filter((header) => normalizeHeaderName(header.name) === "set-cookie"))
340
+ .filter((header) => !options.blockedResponseHeaders.has(normalizeHeaderName(header.name)));
341
+ }
342
+ function applyExternalOrigin(headers, input) {
343
+ if (input == null || input.trim() === "")
344
+ return;
345
+ const external = normalizeOrigin(input);
346
+ const current = headers.get("origin");
347
+ if (current != null && normalizeOrigin(current) !== external)
348
+ throw new ProxyServerError("invalid_request_meta", "external_origin conflicts with origin header");
349
+ headers.set("x-forwarded-proto", new URL(external).protocol.slice(0, -1));
350
+ headers.set("host", new URL(external).host);
351
+ }
352
+ async function readBody(reader, maxChunkBytes, maxBodyBytes) {
353
+ const chunks = [];
354
+ let total = 0;
355
+ while (true) {
356
+ const length = readU32be(await reader.readExactly(4), 0);
357
+ if (length === 0)
358
+ break;
359
+ if (length > maxChunkBytes)
360
+ throw new ProxyServerError("request_body_too_large", "request chunk too large");
361
+ total += length;
362
+ if (total > maxBodyBytes)
363
+ throw new ProxyServerError("request_body_too_large", "request body too large");
364
+ chunks.push(await reader.readExactly(length));
365
+ }
366
+ const body = new Uint8Array(total);
367
+ let offset = 0;
368
+ for (const chunk of chunks) {
369
+ body.set(chunk, offset);
370
+ offset += chunk.length;
371
+ }
372
+ return body;
373
+ }
374
+ async function writeHTTPError(stream, requestId, code, error) {
375
+ const meta = { v: PROXY_PROTOCOL_VERSION, request_id: requestId.trim() || "unknown", ok: false, error: { code, message: asError(error).message } };
376
+ try {
377
+ await writeJsonFrame(stream, meta);
378
+ await stream.write(u32be(0));
379
+ }
380
+ catch { /* The peer may be gone. */ }
381
+ }
382
+ async function readWSFrame(reader, maxBytes) {
383
+ const header = await reader.readExactly(5);
384
+ const length = readU32be(header, 1);
385
+ if (length > maxBytes)
386
+ throw new Error("websocket frame too large");
387
+ return { op: header[0], payload: await reader.readExactly(length) };
388
+ }
389
+ async function writeWSFrame(stream, op, payload, maxBytes) {
390
+ if (payload.length > maxBytes)
391
+ throw new Error("websocket frame too large");
392
+ const header = new Uint8Array(5);
393
+ header[0] = op;
394
+ header.set(u32be(payload.length), 1);
395
+ await stream.write(header);
396
+ if (payload.length > 0)
397
+ await stream.write(payload);
398
+ }
399
+ async function writeWSOpenError(stream, connId, code, error) {
400
+ const response = { v: PROXY_PROTOCOL_VERSION, conn_id: connId.trim() || "unknown", ok: false, error: { code, message: asError(error).message } };
401
+ try {
402
+ await writeJsonFrame(stream, response);
403
+ }
404
+ catch { /* The peer may be gone. */ }
405
+ }
406
+ function waitForUpstreamOpen(websocket, signal) {
407
+ return new Promise((resolve, reject) => {
408
+ const cleanup = () => { websocket.off("open", onOpen); websocket.off("error", onError); signal?.removeEventListener("abort", onAbort); };
409
+ const onOpen = () => { cleanup(); resolve(); };
410
+ const onError = (error) => { cleanup(); reject(error); };
411
+ const onAbort = () => { cleanup(); reject(signal?.reason ?? new Error("aborted")); };
412
+ websocket.once("open", onOpen);
413
+ websocket.once("error", onError);
414
+ signal?.addEventListener("abort", onAbort, { once: true });
415
+ });
416
+ }
417
+ function resolveTimeout(input, options) {
418
+ const timeout = input == null || input === 0 ? options.defaultTimeoutMs : input;
419
+ return options.maxTimeoutMs > 0 ? Math.min(timeout, options.maxTimeoutMs) : timeout;
420
+ }
421
+ function normalizeOrigin(input) {
422
+ const url = new URL(input);
423
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "" || (url.pathname !== "" && url.pathname !== "/") || url.search !== "" || url.hash !== "")
424
+ throw new Error("invalid origin");
425
+ return url.origin;
426
+ }
427
+ function normalizeNames(input) {
428
+ const result = new Set();
429
+ for (const item of input ?? []) {
430
+ const name = normalizeHeaderName(item);
431
+ if (name === "" || !isValidHeaderName(name))
432
+ throw new Error("invalid policy name");
433
+ result.add(name);
434
+ }
435
+ return result;
436
+ }
437
+ function positive(input, fallback, name) {
438
+ const value = input ?? fallback;
439
+ if (!Number.isSafeInteger(value) || value <= 0)
440
+ throw new Error(`${name} must be a positive integer`);
441
+ return value;
442
+ }
443
+ function nonNegative(input, fallback, name) {
444
+ const value = input ?? fallback;
445
+ if (!Number.isSafeInteger(value) || value < 0)
446
+ throw new Error(`${name} must be a non-negative integer`);
447
+ return value;
448
+ }
449
+ function classifyHTTPError(error) {
450
+ if (error instanceof ProxyServerError)
451
+ return error.code;
452
+ if (asError(error).name === "AbortError")
453
+ return "timeout";
454
+ return "upstream_request_failed";
455
+ }
456
+ function classifyWSError(error) {
457
+ if (error instanceof ProxyServerError)
458
+ return error.code;
459
+ return "upstream_ws_dial_failed";
460
+ }
461
+ function toBytes(input) {
462
+ if (input instanceof Uint8Array)
463
+ return input;
464
+ if (input instanceof ArrayBuffer)
465
+ return new Uint8Array(input);
466
+ if (ArrayBuffer.isView(input))
467
+ return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
468
+ return new TextEncoder().encode(String(input ?? ""));
469
+ }
470
+ function asError(error) {
471
+ return error instanceof Error ? error : new Error(String(error));
472
+ }
473
+ class ProxyServerError extends Error {
474
+ code;
475
+ constructor(code, message) {
476
+ super(message);
477
+ this.code = code;
478
+ this.name = "ProxyServerError";
479
+ }
480
+ }
@@ -472,12 +472,25 @@ async function handleFetch(event) {
472
472
  const donePromise = new Promise((resolve, reject) => { doneResolve = resolve; doneReject = reject; });
473
473
 
474
474
  let controller = null;
475
+ let responseCreditOutstanding = false;
476
+
477
+ function requestResponseCredit() {
478
+ if (responseCreditOutstanding) return;
479
+ responseCreditOutstanding = true;
480
+ try { port.postMessage({ type: "flowersec-proxy:response_credit" }); } catch {}
481
+ }
482
+
483
+ function abortRuntimeResponse() {
484
+ responseCreditOutstanding = false;
485
+ try { port.postMessage({ type: "flowersec-proxy:abort" }); } catch {}
486
+ try { port.close(); } catch {}
487
+ }
475
488
 
476
489
  const stream = new ReadableStream({
477
490
  start(c) { controller = c; },
491
+ pull() { requestResponseCredit(); },
478
492
  cancel() {
479
- try { port.postMessage({ type: "flowersec-proxy:abort" }); } catch {}
480
- try { port.close(); } catch {}
493
+ abortRuntimeResponse();
481
494
  }
482
495
  });
483
496
 
@@ -490,7 +503,7 @@ async function handleFetch(event) {
490
503
  metaReject(err);
491
504
  doneReject(err);
492
505
  controller?.error(err);
493
- try { port.close(); } catch {}
506
+ abortRuntimeResponse();
494
507
  return false;
495
508
  }
496
509
  htmlChunks.push(b);
@@ -516,27 +529,30 @@ async function handleFetch(event) {
516
529
  metaReject(err);
517
530
  doneReject(err);
518
531
  controller?.error(err);
519
- try { port.close(); } catch {}
532
+ abortRuntimeResponse();
520
533
  return;
521
534
  }
522
535
  }
523
536
  }
524
537
  metaResolve(m);
538
+ if (shouldInjectHTML) requestResponseCredit();
525
539
  if (controller && !shouldInjectHTML) for (const q of queued) controller.enqueue(q);
526
540
  if (shouldInjectHTML) for (const q of queued) if (!pushInjectChunk(q)) return;
527
541
  queued.length = 0;
528
542
  return;
529
543
  }
530
544
  if (m.type === "flowersec-proxy:response_chunk") {
545
+ responseCreditOutstanding = false;
531
546
  const b = new Uint8Array(m.data);
532
547
  if (shouldInjectHTML) {
533
- pushInjectChunk(b);
548
+ if (pushInjectChunk(b)) requestResponseCredit();
534
549
  return;
535
550
  }
536
551
  if (controller) controller.enqueue(b); else queued.push(b);
537
552
  return;
538
553
  }
539
554
  if (m.type === "flowersec-proxy:response_end") {
555
+ responseCreditOutstanding = false;
540
556
  if (shouldInjectHTML) {
541
557
  doneResolve(htmlChunks);
542
558
  return;
@@ -545,6 +561,7 @@ async function handleFetch(event) {
545
561
  return;
546
562
  }
547
563
  if (m.type === "flowersec-proxy:response_error") {
564
+ responseCreditOutstanding = false;
548
565
  const status = typeof m.status === "number" && Number.isFinite(m.status) ? Math.floor(m.status) : 502;
549
566
  lastErrorStatus = status > 0 ? status : 502;
550
567
  lastErrorMessage = String(m.message || "proxy error");
@@ -572,6 +589,7 @@ async function handleFetch(event) {
572
589
  path,
573
590
  headers: headersToPairs(req.headers),
574
591
  ...(externalOrigin ? { external_origin: externalOrigin } : {}),
592
+ response_flow_control: "chunk_credit_v1",
575
593
  body
576
594
  }
577
595
  }, [port2]);
@@ -16,6 +16,7 @@ export type ProxyWindowFetchRequest = Readonly<{
16
16
  path: string;
17
17
  headers: readonly Header[];
18
18
  external_origin?: string;
19
+ response_flow_control?: "chunk_credit_v1";
19
20
  body?: ArrayBuffer;
20
21
  }>;
21
22
  export type ProxyWindowFetchForwardMsg = Readonly<{