@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.
- package/README.md +9 -1
- package/dist/client-connect/connectCore.d.ts +1 -1
- package/dist/client-connect/connectCore.js +5 -4
- package/dist/controlplane/channelInit.d.ts +27 -0
- package/dist/controlplane/channelInit.js +116 -0
- package/dist/controlplane/http.d.ts +16 -0
- package/dist/controlplane/http.js +59 -0
- package/dist/controlplane/index.d.ts +4 -0
- package/dist/controlplane/index.js +4 -0
- package/dist/controlplane/issuer.d.ts +15 -0
- package/dist/controlplane/issuer.js +53 -0
- package/dist/controlplane/request.js +2 -1
- package/dist/controlplane/token.d.ts +33 -0
- package/dist/controlplane/token.js +119 -0
- package/dist/defaults.d.ts +46 -0
- package/dist/defaults.js +46 -0
- package/dist/endpoint/index.d.ts +83 -0
- package/dist/endpoint/index.js +384 -0
- package/dist/endpoint/node.d.ts +9 -0
- package/dist/endpoint/node.js +51 -0
- package/dist/framing/jsonframe.js +2 -1
- package/dist/node/index.d.ts +3 -0
- package/dist/node/index.js +3 -0
- package/dist/proxy/constants.js +4 -3
- package/dist/proxy/controllerWindow.js +23 -3
- package/dist/proxy/headerPolicy.js +1 -0
- package/dist/proxy/runtime.d.ts +1 -0
- package/dist/proxy/runtime.js +38 -1
- package/dist/proxy/server.d.ts +27 -0
- package/dist/proxy/server.js +480 -0
- package/dist/proxy/serviceWorker.js +23 -5
- package/dist/proxy/windowBridgeProtocol.d.ts +1 -0
- package/dist/reconnect/index.js +33 -22
- package/dist/rpc/server.d.ts +9 -2
- package/dist/rpc/server.js +33 -10
- package/dist/yamux/session.d.ts +6 -1
- package/dist/yamux/session.js +12 -6
- package/dist/yamux/stream.d.ts +1 -0
- package/dist/yamux/stream.js +19 -4
- package/package.json +5 -1
package/dist/proxy/runtime.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]);
|