@floegence/flowersec-core 0.24.0 → 0.26.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.
@@ -3,41 +3,38 @@ import { DEFAULT_MAX_JSON_FRAME_BYTES, readJsonFrame, writeJsonFrame } from "../
3
3
  import { RpcRouter, RpcServer } from "../rpc/server.js";
4
4
  import { createByteReader } from "../streamio/index.js";
5
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";
6
+ import { DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_CHUNK_BYTES, DEFAULT_MAX_CONCURRENT_STREAMS, DEFAULT_MAX_WS_FRAME_BYTES, PROXY_KIND_HTTP1, PROXY_KIND_WS, PROXY_PROTOCOL_VERSION, } from "./constants.js";
7
7
  import { filterRequestHeaders, filterResponseHeaders, filterWsOpenHeaders, isSafeHeaderValue, isValidHeaderName, normalizeHeaderName } from "./headerPolicy.js";
8
8
  export async function serveProxySession(session, options, signal) {
9
9
  const compiled = compileOptions(options);
10
- const active = new Set();
10
+ const activeProxy = new Set();
11
+ const activeRPC = new Set();
11
12
  try {
12
13
  while (!signal?.aborted) {
13
14
  const accepted = await session.acceptStream(signal === undefined ? {} : { signal });
14
15
  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
16
  const task = serveRPCStream(accepted.stream, options.rpcRouter ?? new RpcRouter(), options.rpcServerOptions, signal)
20
17
  .catch(() => { })
21
- .finally(() => active.delete(task));
22
- active.add(task);
18
+ .finally(() => activeRPC.delete(task));
19
+ activeRPC.add(task);
23
20
  continue;
24
21
  }
25
22
  if (accepted.kind !== PROXY_KIND_HTTP1 && accepted.kind !== PROXY_KIND_WS) {
26
23
  await accepted.stream.reset(new Error(`unsupported proxy stream kind ${accepted.kind}`));
27
24
  continue;
28
25
  }
29
- if (active.size >= compiled.maxConcurrentStreams) {
26
+ if (activeProxy.size >= compiled.maxConcurrentStreams) {
30
27
  await accepted.stream.reset(new Error("proxy stream concurrency exhausted"));
31
28
  continue;
32
29
  }
33
30
  const task = serveProxyStreamCompiled(accepted.kind, accepted.stream, compiled, signal)
34
31
  .catch(() => { })
35
- .finally(() => active.delete(task));
36
- active.add(task);
32
+ .finally(() => activeProxy.delete(task));
33
+ activeProxy.add(task);
37
34
  }
38
35
  }
39
36
  finally {
40
- await Promise.allSettled(active);
37
+ await Promise.allSettled([...activeProxy, ...activeRPC]);
41
38
  }
42
39
  }
43
40
  async function serveRPCStream(stream, router, options, signal) {
@@ -50,7 +47,8 @@ async function serveRPCStream(stream, router, options, signal) {
50
47
  await server.serve(signal);
51
48
  }
52
49
  export function serveProxyStream(kind, stream, options, signal) {
53
- return serveProxyStreamCompiled(kind, stream, compileOptions(options), signal);
50
+ const compiled = compileOptions(options);
51
+ return serveProxyStreamCompiled(kind, stream, compiled, signal);
54
52
  }
55
53
  async function serveProxyStreamCompiled(kind, stream, options, signal) {
56
54
  try {
@@ -67,78 +65,152 @@ async function serveProxyStreamCompiled(kind, stream, options, signal) {
67
65
  }
68
66
  }
69
67
  async function serveHTTP(stream, options, signal) {
70
- const reader = createByteReader(stream, signal === undefined ? {} : { signal });
68
+ const reader = createByteReader(stream);
71
69
  let requestId = "unknown";
70
+ let responseStarted = false;
71
+ let streamReset = false;
72
+ let requestBody;
73
+ let responseBodyReader;
74
+ let controller;
75
+ let timer;
76
+ let removeAbortListener;
77
+ const resetStream = async (error) => {
78
+ if (streamReset)
79
+ return;
80
+ streamReset = true;
81
+ try {
82
+ await stream.reset(error);
83
+ }
84
+ catch { /* The peer may already have reset the stream. */ }
85
+ };
72
86
  try {
73
87
  const meta = assertHTTPRequestMeta(await readJsonFrame(reader, options.maxJsonFrameBytes));
74
88
  requestId = meta.request_id;
75
89
  const path = parsePath(meta.path);
76
- const body = await readBody(reader, options.maxChunkBytes, options.maxBodyBytes);
90
+ const knownRequestLength = contentLength(meta.headers, "request");
91
+ if (knownRequestLength != null && knownRequestLength > options.maxBodyBytes) {
92
+ throw new ProxyServerError("request_body_too_large", "request body too large");
93
+ }
94
+ if (signal?.aborted)
95
+ throw new ProxyServerError("canceled", "proxy request canceled");
77
96
  const headers = requestHeaders(meta.headers, options);
78
97
  applyExternalOrigin(headers, meta.external_origin);
79
98
  const target = new URL(options.upstream);
80
99
  target.pathname = path.pathname;
81
100
  target.search = path.search;
82
101
  target.hash = "";
83
- const timeoutMs = resolveTimeout(meta.timeout_ms, options);
84
- const controller = new AbortController();
85
- const onAbort = () => controller.abort(signal?.reason);
102
+ controller = new AbortController();
103
+ const abortUpstream = (error) => {
104
+ if (!controller.signal.aborted)
105
+ controller.abort(error);
106
+ requestBody?.abort(error);
107
+ if (responseStarted)
108
+ void resetStream(error);
109
+ };
110
+ const onAbort = () => abortUpstream(new ProxyServerError("canceled", "proxy request canceled"));
86
111
  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
- }
112
+ removeAbortListener = () => signal?.removeEventListener("abort", onAbort);
113
+ if (signal?.aborted)
114
+ onAbort();
115
+ const timeoutMs = resolveTimeout(meta.timeout_ms, options);
116
+ timer = timeoutMs > 0
117
+ ? setTimeout(() => abortUpstream(new ProxyServerError("timeout", "upstream request timeout")), timeoutMs)
118
+ : undefined;
119
+ requestBody = createStreamingRequestBody(reader, options.maxChunkBytes, options.maxBodyBytes, abortUpstream);
120
+ const method = meta.method.trim().toUpperCase();
121
+ const requestInit = {
122
+ method,
123
+ headers,
124
+ redirect: "manual",
125
+ signal: controller.signal,
126
+ };
127
+ if (method === "GET" || method === "HEAD") {
128
+ requestBody.drain();
129
+ }
130
+ else {
131
+ requestInit.body = requestBody.stream;
132
+ requestInit.duplex = "half";
133
+ }
134
+ if (controller.signal.aborted)
135
+ throw controller.signal.reason;
136
+ const response = await options.fetch(target, requestInit);
137
+ if (requestBody.error != null)
138
+ throw requestBody.error;
139
+ requestBody.drain();
140
+ const knownResponseLength = contentLength(response.headers, "response");
141
+ if (knownResponseLength != null && knownResponseLength > options.maxBodyBytes) {
142
+ await response.body?.cancel();
143
+ throw new ProxyServerError("response_body_too_large", "response body too large");
144
+ }
145
+ const responseHeaders = collectResponseHeaders(response.headers, options);
146
+ const responseMeta = {
147
+ v: PROXY_PROTOCOL_VERSION,
148
+ request_id: requestId,
149
+ ok: true,
150
+ status: response.status,
151
+ headers: responseHeaders,
152
+ };
153
+ await writeJsonFrame(stream, responseMeta);
154
+ responseStarted = true;
155
+ if (response.body == null) {
126
156
  await stream.write(u32be(0));
157
+ const requestError = await requestBody.completion;
158
+ if (requestError != null)
159
+ throw requestError;
160
+ return;
127
161
  }
128
- finally {
129
- if (timer != null)
130
- clearTimeout(timer);
131
- signal?.removeEventListener("abort", onAbort);
162
+ responseBodyReader = response.body.getReader();
163
+ let total = 0;
164
+ while (true) {
165
+ const next = await Promise.race([
166
+ responseBodyReader.read(),
167
+ requestBody.failure.then((error) => { throw error; }),
168
+ ]);
169
+ if (next.done)
170
+ break;
171
+ const bytes = next.value;
172
+ const nextTotal = total + bytes.length;
173
+ if (!Number.isSafeInteger(nextTotal) || nextTotal > options.maxBodyBytes) {
174
+ throw new ProxyServerError("response_body_too_large", "response body too large");
175
+ }
176
+ total = nextTotal;
177
+ for (let offset = 0; offset < bytes.length; offset += options.maxChunkBytes) {
178
+ const chunk = bytes.subarray(offset, Math.min(bytes.length, offset + options.maxChunkBytes));
179
+ await stream.write(u32be(chunk.length));
180
+ await stream.write(chunk);
181
+ }
132
182
  }
183
+ await stream.write(u32be(0));
184
+ const requestError = await requestBody.completion;
185
+ if (requestError != null)
186
+ throw requestError;
133
187
  }
134
188
  catch (error) {
135
- await writeHTTPError(stream, requestId, classifyHTTPError(error), error);
189
+ const effective = requestBody?.error ?? controller?.signal.reason ?? asError(error);
190
+ if (controller != null && !controller.signal.aborted)
191
+ controller.abort(effective);
192
+ if (responseStarted)
193
+ await resetStream(effective);
194
+ else
195
+ await writeHTTPError(stream, requestId, classifyHTTPError(effective));
196
+ }
197
+ finally {
198
+ if (timer != null)
199
+ clearTimeout(timer);
200
+ removeAbortListener?.();
201
+ requestBody?.stop();
202
+ try {
203
+ await responseBodyReader?.cancel();
204
+ }
205
+ catch { /* The upstream body may already be closed. */ }
136
206
  }
137
207
  }
138
208
  async function serveWebSocket(stream, options, signal) {
139
209
  const reader = createByteReader(stream, signal === undefined ? {} : { signal });
140
210
  let connId = "unknown";
141
211
  let raw;
212
+ let upstreamOpened = false;
213
+ let removePostOpenAbortListener;
142
214
  try {
143
215
  const meta = assertWSOpenMeta(await readJsonFrame(reader, options.maxJsonFrameBytes));
144
216
  connId = meta.conn_id;
@@ -164,55 +236,137 @@ async function serveWebSocket(stream, options, signal) {
164
236
  headers,
165
237
  maxPayload: options.maxWsFrameBytes,
166
238
  perMessageDeflate: false,
167
- handshakeTimeout: Math.min(options.defaultTimeoutMs, options.maxTimeoutMs),
239
+ handshakeTimeout: resolveTimeout(undefined, options),
168
240
  });
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
241
  let writeChain = Promise.resolve();
242
+ let queuedWriteBytes = 0;
243
+ let terminalSettled = false;
244
+ let terminalError;
178
245
  let terminalResolve;
179
246
  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)));
247
+ const settleTerminal = (error) => {
248
+ if (terminalSettled)
249
+ return;
250
+ terminalSettled = true;
251
+ terminalError = error;
252
+ terminalResolve(error);
253
+ };
254
+ const queueFrame = (op, payload, allowCloseFrame = false) => {
255
+ if (terminalSettled)
256
+ return Promise.reject(new Error("proxy WebSocket is closed"));
257
+ const frameBytes = payload.byteLength + 5;
258
+ const nextQueuedWriteBytes = queuedWriteBytes + frameBytes;
259
+ if (!Number.isSafeInteger(nextQueuedWriteBytes)
260
+ || (!allowCloseFrame && nextQueuedWriteBytes > options.maxWsQueuedBytes)) {
261
+ const error = new ProxyServerError("resource_exhausted", "proxy WebSocket write queue exhausted");
262
+ settleTerminal(error);
263
+ try {
264
+ raw.close(1011, "proxy buffer limit exceeded");
265
+ }
266
+ catch { /* Best effort. */ }
267
+ return Promise.reject(error);
268
+ }
269
+ queuedWriteBytes = nextQueuedWriteBytes;
270
+ const write = writeChain
271
+ .then(() => {
272
+ if (terminalSettled)
273
+ throw terminalError ?? new Error("proxy WebSocket is closed");
274
+ return writeWSFrame(stream, op, payload, options.maxWsFrameBytes);
275
+ })
276
+ .finally(() => {
277
+ queuedWriteBytes = Math.max(0, queuedWriteBytes - frameBytes);
278
+ });
279
+ writeChain = write;
280
+ void write.catch((error) => settleTerminal(asError(error)));
281
+ return write;
282
+ };
283
+ const queueEventFrame = (op, payload) => {
284
+ void queueFrame(op, payload).catch(() => { });
184
285
  };
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
286
  raw.on("close", (code, reason) => {
287
+ if (terminalSettled)
288
+ return;
289
+ if (!upstreamOpened) {
290
+ settleTerminal(new Error("upstream WebSocket closed before open"));
291
+ return;
292
+ }
189
293
  const reasonBytes = toBytes(reason);
190
294
  const payload = new Uint8Array(2 + reasonBytes.length);
191
295
  payload[0] = (code >>> 8) & 0xff;
192
296
  payload[1] = code & 0xff;
193
297
  payload.set(reasonBytes, 2);
194
- writeFrame(8, payload);
195
- terminalResolve();
298
+ void queueFrame(8, payload, true).then(() => settleTerminal(), (error) => settleTerminal(asError(error)));
196
299
  });
197
- raw.on("error", (error) => terminalResolve(error));
300
+ raw.on("error", (error) => settleTerminal(error));
301
+ await waitForUpstreamOpen(raw, signal);
302
+ upstreamOpened = true;
303
+ const response = {
304
+ v: PROXY_PROTOCOL_VERSION,
305
+ conn_id: connId,
306
+ ok: true,
307
+ protocol: String(raw.protocol ?? ""),
308
+ };
309
+ const openResponseWrite = writeJsonFrame(stream, response);
310
+ writeChain = openResponseWrite;
311
+ void openResponseWrite.catch((error) => settleTerminal(asError(error)));
312
+ raw.on("message", (data, isBinary) => queueEventFrame(isBinary ? 2 : 1, toBytes(data)));
313
+ raw.on("ping", (data) => queueEventFrame(9, toBytes(data)));
314
+ raw.on("pong", (data) => queueEventFrame(10, toBytes(data)));
315
+ const onPostOpenAbort = () => {
316
+ const error = asError(signal?.reason ?? new Error("proxy WebSocket aborted"));
317
+ settleTerminal(error);
318
+ try {
319
+ if (typeof raw.terminate === "function")
320
+ raw.terminate();
321
+ else
322
+ raw.close();
323
+ }
324
+ catch {
325
+ // The terminal error is already authoritative.
326
+ }
327
+ };
328
+ if (signal != null) {
329
+ signal.addEventListener("abort", onPostOpenAbort, { once: true });
330
+ removePostOpenAbortListener = () => signal.removeEventListener("abort", onPostOpenAbort);
331
+ if (signal.aborted)
332
+ onPostOpenAbort();
333
+ }
334
+ const openResponseResult = await Promise.race([
335
+ openResponseWrite.then(() => undefined),
336
+ terminal,
337
+ ]);
338
+ if (openResponseResult != null)
339
+ throw openResponseResult;
340
+ try {
341
+ if (!terminalSettled && typeof raw.resume === "function")
342
+ raw.resume();
343
+ }
344
+ catch (error) {
345
+ settleTerminal(asError(error));
346
+ throw error;
347
+ }
198
348
  const inbound = (async () => {
199
349
  while (true) {
200
350
  const frame = await readWSFrame(reader, options.maxWsFrameBytes);
201
351
  switch (frame.op) {
202
352
  case 1:
203
- raw.send(frame.payload, { binary: false });
353
+ await sendUpstreamFrame(raw, 1, frame.payload);
204
354
  break;
205
355
  case 2:
206
- raw.send(frame.payload, { binary: true });
356
+ await sendUpstreamFrame(raw, 2, frame.payload);
207
357
  break;
208
- case 8:
358
+ case 8: {
209
359
  raw.close(frame.payload.length >= 2 ? (frame.payload[0] << 8) | frame.payload[1] : 1000, new TextDecoder().decode(frame.payload.subarray(2)));
360
+ const closeError = await terminal;
361
+ if (closeError != null)
362
+ throw closeError;
210
363
  return;
364
+ }
211
365
  case 9:
212
- raw.ping(frame.payload);
366
+ await sendUpstreamFrame(raw, 9, frame.payload);
213
367
  break;
214
368
  case 10:
215
- raw.pong(frame.payload);
369
+ await sendUpstreamFrame(raw, 10, frame.payload);
216
370
  break;
217
371
  default: throw new Error("invalid websocket frame operation");
218
372
  }
@@ -224,10 +378,11 @@ async function serveWebSocket(stream, options, signal) {
224
378
  await writeChain;
225
379
  }
226
380
  catch (error) {
227
- if (raw == null || raw.readyState !== 1)
228
- await writeWSOpenError(stream, connId, classifyWSError(error), error);
381
+ if (!upstreamOpened)
382
+ await writeWSOpenError(stream, connId, classifyWSError(error));
229
383
  }
230
384
  finally {
385
+ removePostOpenAbortListener?.();
231
386
  try {
232
387
  raw?.close();
233
388
  }
@@ -247,6 +402,12 @@ function compileOptions(input) {
247
402
  const maxChunkBytes = positive(input.maxChunkBytes, DEFAULT_MAX_CHUNK_BYTES, "maxChunkBytes");
248
403
  const maxBodyBytes = positive(input.maxBodyBytes, DEFAULT_MAX_BODY_BYTES, "maxBodyBytes");
249
404
  const maxWsFrameBytes = positive(input.maxWsFrameBytes, DEFAULT_MAX_WS_FRAME_BYTES, "maxWsFrameBytes");
405
+ const defaultMaxWsQueuedBytes = maxWsFrameBytes <= Number.MAX_SAFE_INTEGER - 5
406
+ ? maxWsFrameBytes + 5
407
+ : maxWsFrameBytes;
408
+ const maxWsQueuedBytes = positive(input.maxWsQueuedBytes, defaultMaxWsQueuedBytes, "maxWsQueuedBytes");
409
+ if (maxWsQueuedBytes < 5)
410
+ throw new Error("maxWsQueuedBytes must be at least 5");
250
411
  const defaultTimeoutMs = nonNegative(input.defaultTimeoutMs, 30_000, "defaultTimeoutMs");
251
412
  const maxTimeoutMs = nonNegative(input.maxTimeoutMs, 300_000, "maxTimeoutMs");
252
413
  if (maxTimeoutMs > 0 && defaultTimeoutMs > maxTimeoutMs)
@@ -258,9 +419,10 @@ function compileOptions(input) {
258
419
  maxChunkBytes,
259
420
  maxBodyBytes,
260
421
  maxWsFrameBytes,
422
+ maxWsQueuedBytes,
261
423
  defaultTimeoutMs,
262
424
  maxTimeoutMs,
263
- maxConcurrentStreams: positive(input.maxConcurrentStreams, 64, "maxConcurrentStreams"),
425
+ maxConcurrentStreams: positive(input.maxConcurrentStreams, DEFAULT_MAX_CONCURRENT_STREAMS, "maxConcurrentStreams"),
264
426
  extraRequestHeaders: input.extraRequestHeaders ?? [],
265
427
  extraResponseHeaders: input.extraResponseHeaders ?? [],
266
428
  blockedResponseHeaders: normalizeNames(input.blockedResponseHeaders),
@@ -349,30 +511,137 @@ function applyExternalOrigin(headers, input) {
349
511
  headers.set("x-forwarded-proto", new URL(external).protocol.slice(0, -1));
350
512
  headers.set("host", new URL(external).host);
351
513
  }
352
- async function readBody(reader, maxChunkBytes, maxBodyBytes) {
353
- const chunks = [];
514
+ function createStreamingRequestBody(reader, maxChunkBytes, maxBodyBytes, onFailure) {
354
515
  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));
516
+ let finished = false;
517
+ let draining = false;
518
+ let readChain = Promise.resolve();
519
+ let error;
520
+ let streamController;
521
+ let resolveFailure;
522
+ const failure = new Promise((resolve) => { resolveFailure = resolve; });
523
+ let resolveCompletion;
524
+ const completion = new Promise((resolve) => { resolveCompletion = resolve; });
525
+ const fail = (cause) => {
526
+ if (error != null)
527
+ return error;
528
+ const raw = asError(cause);
529
+ error = raw instanceof ProxyServerError
530
+ ? raw
531
+ : new ProxyServerError("request_body_invalid", raw.message);
532
+ finished = true;
533
+ try {
534
+ streamController?.error(error);
535
+ }
536
+ catch { /* The body consumer may already be gone. */ }
537
+ resolveFailure(error);
538
+ resolveCompletion(error);
539
+ onFailure(error);
540
+ return error;
541
+ };
542
+ const readChunkUnserialized = async () => {
543
+ try {
544
+ const length = readU32be(await reader.readExactly(4), 0);
545
+ if (length === 0) {
546
+ finished = true;
547
+ resolveCompletion(undefined);
548
+ return null;
549
+ }
550
+ if (length > maxChunkBytes) {
551
+ throw new ProxyServerError("request_body_too_large", "request chunk too large");
552
+ }
553
+ const nextTotal = total + length;
554
+ if (!Number.isSafeInteger(nextTotal) || nextTotal > maxBodyBytes) {
555
+ throw new ProxyServerError("request_body_too_large", "request body too large");
556
+ }
557
+ const chunk = await reader.readExactly(length);
558
+ total = nextTotal;
559
+ return chunk;
560
+ }
561
+ catch (cause) {
562
+ throw fail(cause);
563
+ }
564
+ };
565
+ const readChunk = () => {
566
+ const next = readChain.then(readChunkUnserialized);
567
+ readChain = next.then(() => { }, () => { });
568
+ return next;
569
+ };
570
+ const drain = () => {
571
+ if (draining || finished)
572
+ return;
573
+ draining = true;
574
+ try {
575
+ streamController?.close();
576
+ }
577
+ catch { /* A pending pull will observe draining below. */ }
578
+ void (async () => {
579
+ while (!finished)
580
+ await readChunk();
581
+ })().catch(() => { });
582
+ };
583
+ const stream = new ReadableStream({
584
+ start(controller) { streamController = controller; },
585
+ async pull(controller) {
586
+ if (finished) {
587
+ if (error == null)
588
+ controller.close();
589
+ return;
590
+ }
591
+ const chunk = await readChunk();
592
+ if (draining)
593
+ return;
594
+ if (chunk == null)
595
+ controller.close();
596
+ else
597
+ controller.enqueue(chunk);
598
+ },
599
+ cancel() { drain(); },
600
+ }, { highWaterMark: 0 });
601
+ return {
602
+ stream,
603
+ failure,
604
+ completion,
605
+ get error() { return error; },
606
+ drain,
607
+ abort: (cause) => { if (!finished)
608
+ fail(cause); },
609
+ stop: () => {
610
+ if (finished)
611
+ return;
612
+ finished = true;
613
+ try {
614
+ streamController?.close();
615
+ }
616
+ catch { /* The fetch implementation may own the stream. */ }
617
+ resolveCompletion(undefined);
618
+ },
619
+ };
620
+ }
621
+ function contentLength(headers, side) {
622
+ const rawValues = headers instanceof Headers
623
+ ? (headers.get("content-length") == null ? [] : [headers.get("content-length")])
624
+ : headers
625
+ .filter((header) => normalizeHeaderName(header.name) === "content-length")
626
+ .map((header) => header.value.trim());
627
+ if (rawValues.length === 0)
628
+ return undefined;
629
+ if (rawValues.length !== 1 || !/^(0|[1-9][0-9]*)$/.test(rawValues[0])) {
630
+ throw new ProxyServerError(side === "request" ? "invalid_request_meta" : "upstream_request_failed", `invalid ${side} content-length`);
365
631
  }
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;
632
+ const value = Number(rawValues[0]);
633
+ if (!Number.isSafeInteger(value)) {
634
+ throw new ProxyServerError(side === "request" ? "invalid_request_meta" : "upstream_request_failed", `invalid ${side} content-length`);
371
635
  }
372
- return body;
636
+ return value;
373
637
  }
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 } };
638
+ async function writeHTTPError(stream, requestId, code) {
639
+ const meta = {
640
+ v: PROXY_PROTOCOL_VERSION,
641
+ request_id: requestId.trim() || "unknown",
642
+ ok: false,
643
+ error: { code, message: publicHTTPErrorMessage(code) },
644
+ };
376
645
  try {
377
646
  await writeJsonFrame(stream, meta);
378
647
  await stream.write(u32be(0));
@@ -396,8 +665,13 @@ async function writeWSFrame(stream, op, payload, maxBytes) {
396
665
  if (payload.length > 0)
397
666
  await stream.write(payload);
398
667
  }
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 } };
668
+ async function writeWSOpenError(stream, connId, code) {
669
+ const response = {
670
+ v: PROXY_PROTOCOL_VERSION,
671
+ conn_id: connId.trim() || "unknown",
672
+ ok: false,
673
+ error: { code, message: publicWSErrorMessage(code) },
674
+ };
401
675
  try {
402
676
  await writeJsonFrame(stream, response);
403
677
  }
@@ -405,13 +679,65 @@ async function writeWSOpenError(stream, connId, code, error) {
405
679
  }
406
680
  function waitForUpstreamOpen(websocket, signal) {
407
681
  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(); };
682
+ const cleanup = () => {
683
+ websocket.off("open", onOpen);
684
+ websocket.off("error", onError);
685
+ websocket.off("close", onClose);
686
+ signal?.removeEventListener("abort", onAbort);
687
+ };
688
+ const onOpen = () => {
689
+ try {
690
+ if (typeof websocket.pause === "function")
691
+ websocket.pause();
692
+ }
693
+ catch (error) {
694
+ cleanup();
695
+ reject(asError(error));
696
+ return;
697
+ }
698
+ cleanup();
699
+ resolve();
700
+ };
410
701
  const onError = (error) => { cleanup(); reject(error); };
702
+ const onClose = () => { cleanup(); reject(new Error("upstream WebSocket closed before open")); };
411
703
  const onAbort = () => { cleanup(); reject(signal?.reason ?? new Error("aborted")); };
412
704
  websocket.once("open", onOpen);
413
705
  websocket.once("error", onError);
706
+ websocket.once("close", onClose);
414
707
  signal?.addEventListener("abort", onAbort, { once: true });
708
+ if (signal?.aborted)
709
+ onAbort();
710
+ });
711
+ }
712
+ function sendUpstreamFrame(websocket, op, payload) {
713
+ return new Promise((resolve, reject) => {
714
+ const finish = (error) => {
715
+ if (error == null)
716
+ resolve();
717
+ else
718
+ reject(asError(error));
719
+ };
720
+ try {
721
+ switch (op) {
722
+ case 1:
723
+ websocket.send(payload, { binary: false }, finish);
724
+ return;
725
+ case 2:
726
+ websocket.send(payload, { binary: true }, finish);
727
+ return;
728
+ case 9:
729
+ websocket.ping(payload, undefined, finish);
730
+ return;
731
+ case 10:
732
+ websocket.pong(payload, undefined, finish);
733
+ return;
734
+ default:
735
+ reject(new Error("invalid upstream WebSocket operation"));
736
+ }
737
+ }
738
+ catch (error) {
739
+ reject(asError(error));
740
+ }
415
741
  });
416
742
  }
417
743
  function resolveTimeout(input, options) {
@@ -458,6 +784,33 @@ function classifyWSError(error) {
458
784
  return error.code;
459
785
  return "upstream_ws_dial_failed";
460
786
  }
787
+ function publicHTTPErrorMessage(code) {
788
+ switch (code) {
789
+ case "invalid_request_meta":
790
+ return "invalid proxy request";
791
+ case "request_body_too_large":
792
+ case "response_body_too_large":
793
+ return "proxy body limit exceeded";
794
+ case "resource_exhausted":
795
+ return "proxy resource limit exceeded";
796
+ case "timeout":
797
+ return "upstream request timed out";
798
+ case "canceled":
799
+ return "proxy request canceled";
800
+ default:
801
+ return "upstream request failed";
802
+ }
803
+ }
804
+ function publicWSErrorMessage(code) {
805
+ switch (code) {
806
+ case "invalid_ws_open_meta":
807
+ return "invalid proxy WebSocket request";
808
+ case "resource_exhausted":
809
+ return "proxy WebSocket resource limit exceeded";
810
+ default:
811
+ return "upstream WebSocket connection failed";
812
+ }
813
+ }
461
814
  function toBytes(input) {
462
815
  if (input instanceof Uint8Array)
463
816
  return input;