@juspay/neurolink 13.8.11 → 13.8.12

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.
@@ -13,6 +13,8 @@ import { ensureTelemetry, getLangfuseHealthStatus, } from "../../services/server
13
13
  import { logger } from "../../utils/logger.js";
14
14
  import { applyAllClients, applyClientsOnProxyStart, restoreAllClients, } from "../proxy-clients/registry.js";
15
15
  import { resolveProxyConfigPath } from "../../proxy/proxyConfig.js";
16
+ import { readProxyRequestText } from "../../proxy/requestBodyLimit.js";
17
+ import { RawBodyTooLargeError } from "../../server/utils/rawBody.js";
16
18
  import { redactUrlsInText, sanitizeForLog, } from "../../utils/logSanitize.js";
17
19
  import { raceWithAbort, withTimeout, } from "../../utils/async/withTimeout.js";
18
20
  import { startProxyHttpTrace } from "../../proxy/proxyTracer.js";
@@ -1511,6 +1513,13 @@ export async function createProxyStartApp(params) {
1511
1513
  }),
1512
1514
  ]);
1513
1515
  };
1516
+ /** 413 in the wire format this proxy answers its other request errors in. */
1517
+ const respondRequestTooLarge = async (c, metadata, message) => {
1518
+ if (metadata) {
1519
+ await recordRuntimeError(metadata, 413, "request_too_large", message);
1520
+ }
1521
+ return c.json({ type: "error", error: { type: "request_too_large", message } }, 413);
1522
+ };
1514
1523
  app.onError(async (err, c) => {
1515
1524
  const errMsg = err instanceof Error ? err.message : String(err);
1516
1525
  logger.always(`[proxy] unhandled error: ${errMsg}`);
@@ -1703,7 +1712,13 @@ export async function createProxyStartApp(params) {
1703
1712
  let body;
1704
1713
  let rawBody;
1705
1714
  if (method === "post") {
1706
- rawBody = await c.req.text().catch(() => undefined);
1715
+ // Bounded before it is parsed, and so before the share gate below
1716
+ // (which needs the model), the way the server adapters bound theirs.
1717
+ const read = await readProxyRequestText(c.req.raw, route.path);
1718
+ if (read instanceof RawBodyTooLargeError) {
1719
+ return respondRequestTooLarge(c, metadata, read.message);
1720
+ }
1721
+ rawBody = read;
1707
1722
  try {
1708
1723
  body = rawBody ? JSON.parse(rawBody) : emptyBody;
1709
1724
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Request-body ceiling for the proxy's POST routes.
3
+ *
4
+ * The body is read before anything else about the request is known (the model,
5
+ * the share token), so it is bounded first, chunk by chunk, the way the server
6
+ * adapters bound theirs. Each route gets the request size limit its API
7
+ * documents; one environment variable overrides all of them.
8
+ *
9
+ * @module proxy/requestBodyLimit
10
+ */
11
+ import { RawBodyTooLargeError } from "../server/utils/rawBody.js";
12
+ /**
13
+ * Environment variable that sets the ceiling for every POST route, replacing
14
+ * the per-route defaults below: a byte count, or `64mb` / `512kb` / `1gb`.
15
+ */
16
+ export declare const PROXY_MAX_REQUEST_BODY_ENV = "NEUROLINK_PROXY_MAX_REQUEST_BODY_BYTES";
17
+ /**
18
+ * 32 MiB. The Claude Messages API and Token Counting API document a 32 MB
19
+ * maximum request size and answer a larger request with 413 `request_too_large`:
20
+ * https://platform.claude.com/docs/en/api/errors#request-size-limits
21
+ *
22
+ * Also the limit for a POST route with no upstream API of its own (the peer
23
+ * endpoints), which has nothing documented to follow.
24
+ */
25
+ export declare const DEFAULT_PROXY_MAX_REQUEST_BODY_BYTES: number;
26
+ /**
27
+ * 100 MiB. The Gemini API documents inline data as "100 MB per request or
28
+ * payload (50 MB for PDFs)":
29
+ * https://ai.google.dev/gemini-api/docs/file-input-methods
30
+ */
31
+ export declare const GEMINI_PROXY_MAX_REQUEST_BODY_BYTES: number;
32
+ /**
33
+ * 64 MiB. A local cap, not an OpenAI figure: OpenAI documents no general
34
+ * request-body limit for the Responses or Chat Completions APIs. The only
35
+ * per-request payload figure it states is the image input requirement, "Up to
36
+ * 512 MB total payload per request":
37
+ * https://developers.openai.com/api/docs/guides/images-vision
38
+ * (file inputs are separately limited to 50 MB combined:
39
+ * https://developers.openai.com/api/docs/guides/file-inputs). The proxy holds
40
+ * a body in memory while it reads it, about four times its size, so it does
41
+ * not take the 512 MB figure as its ceiling; a client that sends more raises
42
+ * `NEUROLINK_PROXY_MAX_REQUEST_BODY_BYTES`. The Codex route speaks the
43
+ * Responses format and documents nothing of its own, so it uses the same cap.
44
+ */
45
+ export declare const OPENAI_PROXY_MAX_REQUEST_BODY_BYTES: number;
46
+ /**
47
+ * The ceiling in bytes for one route: the environment override when it is a
48
+ * positive size, else that route's documented limit.
49
+ *
50
+ * @param routePath - The route as registered, for example `/v1/messages`.
51
+ */
52
+ export declare function resolveProxyMaxRequestBodyBytes(routePath: string, env?: Record<string, string | undefined>): number;
53
+ /**
54
+ * The request body as text, `undefined` when it cannot be read (a client that
55
+ * hung up mid-body), or the error when it is over the route's ceiling. Over the
56
+ * ceiling the stream is cancelled, so nothing past it is buffered.
57
+ *
58
+ * @param routePath - The route as registered, for example `/v1/messages`.
59
+ */
60
+ export declare function readProxyRequestText(request: Request, routePath: string, env?: Record<string, string | undefined>): Promise<string | undefined | RawBodyTooLargeError>;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Request-body ceiling for the proxy's POST routes.
3
+ *
4
+ * The body is read before anything else about the request is known (the model,
5
+ * the share token), so it is bounded first, chunk by chunk, the way the server
6
+ * adapters bound theirs. Each route gets the request size limit its API
7
+ * documents; one environment variable overrides all of them.
8
+ *
9
+ * @module proxy/requestBodyLimit
10
+ */
11
+ import { RawBodyTooLargeError, readBoundedRequestBody, tryParseByteLimit, } from "../server/utils/rawBody.js";
12
+ import { logger } from "../utils/logger.js";
13
+ /**
14
+ * Environment variable that sets the ceiling for every POST route, replacing
15
+ * the per-route defaults below: a byte count, or `64mb` / `512kb` / `1gb`.
16
+ */
17
+ export const PROXY_MAX_REQUEST_BODY_ENV = "NEUROLINK_PROXY_MAX_REQUEST_BODY_BYTES";
18
+ const MIB = 1024 * 1024;
19
+ /**
20
+ * 32 MiB. The Claude Messages API and Token Counting API document a 32 MB
21
+ * maximum request size and answer a larger request with 413 `request_too_large`:
22
+ * https://platform.claude.com/docs/en/api/errors#request-size-limits
23
+ *
24
+ * Also the limit for a POST route with no upstream API of its own (the peer
25
+ * endpoints), which has nothing documented to follow.
26
+ */
27
+ export const DEFAULT_PROXY_MAX_REQUEST_BODY_BYTES = 32 * MIB;
28
+ /**
29
+ * 100 MiB. The Gemini API documents inline data as "100 MB per request or
30
+ * payload (50 MB for PDFs)":
31
+ * https://ai.google.dev/gemini-api/docs/file-input-methods
32
+ */
33
+ export const GEMINI_PROXY_MAX_REQUEST_BODY_BYTES = 100 * MIB;
34
+ /**
35
+ * 64 MiB. A local cap, not an OpenAI figure: OpenAI documents no general
36
+ * request-body limit for the Responses or Chat Completions APIs. The only
37
+ * per-request payload figure it states is the image input requirement, "Up to
38
+ * 512 MB total payload per request":
39
+ * https://developers.openai.com/api/docs/guides/images-vision
40
+ * (file inputs are separately limited to 50 MB combined:
41
+ * https://developers.openai.com/api/docs/guides/file-inputs). The proxy holds
42
+ * a body in memory while it reads it, about four times its size, so it does
43
+ * not take the 512 MB figure as its ceiling; a client that sends more raises
44
+ * `NEUROLINK_PROXY_MAX_REQUEST_BODY_BYTES`. The Codex route speaks the
45
+ * Responses format and documents nothing of its own, so it uses the same cap.
46
+ */
47
+ export const OPENAI_PROXY_MAX_REQUEST_BODY_BYTES = 64 * MIB;
48
+ /**
49
+ * Registered route path (a suffix, so a base path does not matter) to limit.
50
+ * The first match wins; no match means the default above.
51
+ */
52
+ const ROUTE_LIMITS = [
53
+ [/\/v1\/messages(?:\/count_tokens)?$/, DEFAULT_PROXY_MAX_REQUEST_BODY_BYTES],
54
+ [/\/v1\/chat\/completions$/, OPENAI_PROXY_MAX_REQUEST_BODY_BYTES],
55
+ [/\/backend-api\/codex\/responses$/, OPENAI_PROXY_MAX_REQUEST_BODY_BYTES],
56
+ [/\/v1beta\/models\/[^/]+$/, GEMINI_PROXY_MAX_REQUEST_BODY_BYTES],
57
+ ];
58
+ let warnedAbout;
59
+ /**
60
+ * The ceiling in bytes for one route: the environment override when it is a
61
+ * positive size, else that route's documented limit.
62
+ *
63
+ * @param routePath - The route as registered, for example `/v1/messages`.
64
+ */
65
+ export function resolveProxyMaxRequestBodyBytes(routePath, env = process.env) {
66
+ const raw = env[PROXY_MAX_REQUEST_BODY_ENV]?.trim();
67
+ if (raw) {
68
+ const parsed = tryParseByteLimit(raw);
69
+ if (parsed !== undefined && parsed > 0) {
70
+ return parsed;
71
+ }
72
+ if (warnedAbout !== raw) {
73
+ warnedAbout = raw;
74
+ logger.always(`[proxy] ignoring ${PROXY_MAX_REQUEST_BODY_ENV}=${JSON.stringify(raw)}: expected a positive byte count or a size such as 64mb; using each route's documented limit`);
75
+ }
76
+ }
77
+ return (ROUTE_LIMITS.find(([pattern]) => pattern.test(routePath))?.[1] ??
78
+ DEFAULT_PROXY_MAX_REQUEST_BODY_BYTES);
79
+ }
80
+ /**
81
+ * The request body as text, `undefined` when it cannot be read (a client that
82
+ * hung up mid-body), or the error when it is over the route's ceiling. Over the
83
+ * ceiling the stream is cancelled, so nothing past it is buffered.
84
+ *
85
+ * @param routePath - The route as registered, for example `/v1/messages`.
86
+ */
87
+ export async function readProxyRequestText(request, routePath, env = process.env) {
88
+ try {
89
+ const bytes = await readBoundedRequestBody(request, resolveProxyMaxRequestBodyBytes(routePath, env));
90
+ return bytes instanceof RawBodyTooLargeError
91
+ ? bytes
92
+ : new TextDecoder().decode(bytes);
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
@@ -6,6 +6,10 @@
6
6
  import type { NeuroLink } from "../../neurolink.js";
7
7
  import { BaseServerAdapter } from "../abstract/baseServerAdapter.js";
8
8
  import type { MiddlewareDefinition, RouteDefinition, ServerAdapterConfig } from "../../types/index.js";
9
+ /**
10
+ * Hono-specific server adapter
11
+ * Supports multiple runtimes: Bun, Deno, Node.js
12
+ */
9
13
  export declare class HonoServerAdapter extends BaseServerAdapter {
10
14
  private app;
11
15
  private server?;
@@ -14,6 +18,7 @@ export declare class HonoServerAdapter extends BaseServerAdapter {
14
18
  private requestContextStore;
15
19
  private HTTPExceptionCtor;
16
20
  private streamSSEFn;
21
+ private parseFormBody;
17
22
  constructor(neurolink: NeuroLink, config?: ServerAdapterConfig);
18
23
  /**
19
24
  * Create rate limiter middleware for Hono
@@ -74,5 +79,13 @@ export declare class HonoServerAdapter extends BaseServerAdapter {
74
79
  getNativeServer(): unknown;
75
80
  private extractHeaders;
76
81
  private extractQuery;
82
+ /**
83
+ * A request's context, or the 413 that answers a JSON or form body over its
84
+ * limit. That answer is built here, before the context exists, so no
85
+ * middleware (an auth check included) and no route runs for the request.
86
+ * An upload over the limit still reaches its route as the error itself,
87
+ * which answers in its own wire format.
88
+ */
89
+ private createRequestContext;
77
90
  private extractBody;
78
91
  }
@@ -7,31 +7,11 @@ import { logger } from "../../utils/logger.js";
7
7
  import { AlreadyRunningError, ServerAdapterError, ServerStopError, wrapError, } from "../errors.js";
8
8
  import { BaseServerAdapter } from "../abstract/baseServerAdapter.js";
9
9
  import { isErrorResponse } from "../utils/validation.js";
10
- import { isRawBodyContentType, isWebResponse, parseByteLimit, RawBodyTooLargeError, readRawBody, } from "../utils/rawBody.js";
10
+ import { isRawBodyContentType, isWebResponse, parseByteLimit, RawBodyTooLargeError, readBoundedRequestBody, } from "../utils/rawBody.js";
11
11
  /**
12
12
  * Hono-specific server adapter
13
13
  * Supports multiple runtimes: Bun, Deno, Node.js
14
14
  */
15
- /** Raw bodies already read for a request, so a second extraction never hits a consumed stream. */
16
- const rawBodies = new WeakMap();
17
- /** A web `ReadableStream` of bytes as an async iterable of Buffers, releasing the reader on exit. */
18
- async function* webStreamChunks(stream) {
19
- const reader = stream.getReader();
20
- try {
21
- for (;;) {
22
- const { value, done } = await reader.read();
23
- if (done) {
24
- return;
25
- }
26
- if (value) {
27
- yield Buffer.from(value.buffer, value.byteOffset, value.byteLength);
28
- }
29
- }
30
- }
31
- finally {
32
- reader.releaseLock();
33
- }
34
- }
35
15
  export class HonoServerAdapter extends BaseServerAdapter {
36
16
  app;
37
17
  server;
@@ -44,6 +24,7 @@ export class HonoServerAdapter extends BaseServerAdapter {
44
24
  // rely on initializeFramework's own local closure variables.
45
25
  HTTPExceptionCtor;
46
26
  streamSSEFn;
27
+ parseFormBody;
47
28
  constructor(neurolink, config = {}) {
48
29
  super(neurolink, config);
49
30
  }
@@ -136,7 +117,7 @@ export class HonoServerAdapter extends BaseServerAdapter {
136
117
  * adapter (Express/Fastify/Koa) never pulls Hono's import graph in.
137
118
  */
138
119
  async initializeFramework() {
139
- const [{ Hono }, { cors }, { HTTPException }, { logger: honoLogger }, { secureHeaders }, { streamSSE }, { timeout },] = await Promise.all([
120
+ const [{ Hono }, { cors }, { HTTPException }, { logger: honoLogger }, { secureHeaders }, { streamSSE }, { timeout }, { parseBody },] = await Promise.all([
140
121
  this.importFrameworkDependency("hono", "hono"),
141
122
  this.importFrameworkDependency("hono/cors", "hono"),
142
123
  this.importFrameworkDependency("hono/http-exception", "hono"),
@@ -144,9 +125,11 @@ export class HonoServerAdapter extends BaseServerAdapter {
144
125
  this.importFrameworkDependency("hono/secure-headers", "hono"),
145
126
  this.importFrameworkDependency("hono/streaming", "hono"),
146
127
  this.importFrameworkDependency("hono/timeout", "hono"),
128
+ this.importFrameworkDependency("hono/utils/body", "hono"),
147
129
  ]);
148
130
  this.HTTPExceptionCtor = HTTPException;
149
131
  this.streamSSEFn = streamSSE;
132
+ this.parseFormBody = parseBody;
150
133
  this.app = new Hono();
151
134
  // Add secure headers
152
135
  this.app.use("*", secureHeaders());
@@ -243,17 +226,12 @@ export class HonoServerAdapter extends BaseServerAdapter {
243
226
  // Extract path parameters
244
227
  const params = c.req.param();
245
228
  // Reuse existing context from middleware if available, otherwise create new one
246
- const existingCtx = this.requestContextStore.get(requestId);
247
- const ctx = existingCtx ||
248
- this.createContext({
249
- requestId,
250
- method: c.req.method,
251
- path: c.req.path,
252
- headers: this.extractHeaders(c),
253
- query: this.extractQuery(c),
254
- params,
255
- body: await this.extractBody(c),
256
- });
229
+ const created = this.requestContextStore.get(requestId) ||
230
+ (await this.createRequestContext(c, requestId, params));
231
+ if (isWebResponse(created)) {
232
+ return created;
233
+ }
234
+ const ctx = created;
257
235
  // Emit request event
258
236
  this.emit("request", {
259
237
  requestId,
@@ -484,15 +462,11 @@ export class HonoServerAdapter extends BaseServerAdapter {
484
462
  let ctx = this.requestContextStore.get(requestId);
485
463
  if (!ctx) {
486
464
  // Create new context for the first middleware
487
- ctx = this.createContext({
488
- requestId,
489
- method: c.req.method,
490
- path: c.req.path,
491
- headers: this.extractHeaders(c),
492
- query: this.extractQuery(c),
493
- params,
494
- body: await this.extractBody(c),
495
- });
465
+ const created = await this.createRequestContext(c, requestId, params);
466
+ if (isWebResponse(created)) {
467
+ return created;
468
+ }
469
+ ctx = created;
496
470
  // Store context for sharing between middleware and route handlers
497
471
  this.requestContextStore.set(requestId, ctx);
498
472
  }
@@ -667,22 +641,68 @@ export class HonoServerAdapter extends BaseServerAdapter {
667
641
  });
668
642
  return query;
669
643
  }
644
+ /**
645
+ * A request's context, or the 413 that answers a JSON or form body over its
646
+ * limit. That answer is built here, before the context exists, so no
647
+ * middleware (an auth check included) and no route runs for the request.
648
+ * An upload over the limit still reaches its route as the error itself,
649
+ * which answers in its own wire format.
650
+ */
651
+ async createRequestContext(c, requestId, params) {
652
+ const body = await this.extractBody(c);
653
+ if (body instanceof RawBodyTooLargeError &&
654
+ !isRawBodyContentType(c.req.header("Content-Type"))) {
655
+ logger.warn("[HonoAdapter] Request rejected", {
656
+ requestId,
657
+ code: "PAYLOAD_TOO_LARGE",
658
+ error: body.message,
659
+ });
660
+ this.emitRequestError({
661
+ requestId,
662
+ error: body,
663
+ timestamp: new Date(),
664
+ });
665
+ return c.json({
666
+ error: { code: "PAYLOAD_TOO_LARGE", message: body.message },
667
+ metadata: { requestId, timestamp: new Date().toISOString() },
668
+ }, 413);
669
+ }
670
+ return this.createContext({
671
+ requestId,
672
+ method: c.req.method,
673
+ path: c.req.path,
674
+ headers: this.extractHeaders(c),
675
+ query: this.extractQuery(c),
676
+ params,
677
+ body,
678
+ });
679
+ }
670
680
  async extractBody(c) {
671
681
  if (!this.config.bodyParser.enabled) {
672
682
  return undefined;
673
683
  }
674
684
  const contentType = c.req.header("Content-Type") || "";
675
- if (contentType.includes("application/json")) {
676
- try {
677
- return await c.req.json();
678
- }
679
- catch {
680
- return undefined;
681
- }
682
- }
683
- if (contentType.includes("application/x-www-form-urlencoded")) {
685
+ // JSON and url-encoded forms are read through the same bounded read as
686
+ // uploads, with the limits the other adapters apply: `jsonLimit` for JSON,
687
+ // `maxSize` for forms. The bytes are then parsed the way Hono parses a
688
+ // body it has already buffered. A body that cannot be read or parsed is
689
+ // `undefined`, as it was when Hono did the reading.
690
+ const isJson = contentType.includes("application/json");
691
+ if (isJson || contentType.includes("application/x-www-form-urlencoded")) {
684
692
  try {
685
- return await c.req.parseBody();
693
+ const bytes = await readBoundedRequestBody(c.req.raw, parseByteLimit(isJson
694
+ ? this.config.bodyParser.jsonLimit
695
+ : this.config.bodyParser.maxSize));
696
+ if (bytes instanceof RawBodyTooLargeError) {
697
+ return bytes;
698
+ }
699
+ return isJson
700
+ ? JSON.parse(new TextDecoder().decode(bytes))
701
+ : await this.parseFormBody(new Request(c.req.url, {
702
+ method: "POST",
703
+ headers: { "Content-Type": contentType },
704
+ body: new Uint8Array(bytes),
705
+ }));
686
706
  }
687
707
  catch {
688
708
  return undefined;
@@ -693,37 +713,7 @@ export class HonoServerAdapter extends BaseServerAdapter {
693
713
  // a throw from the middleware path would land in onError, which answers
694
714
  // 500. The route answers 413 (or a validation error) from it.
695
715
  if (isRawBodyContentType(contentType)) {
696
- const limit = parseByteLimit(this.config.bodyParser.rawMaxSize);
697
- if (Number(c.req.header("Content-Length") ?? 0) > limit) {
698
- return new RawBodyTooLargeError(limit);
699
- }
700
- // Read the web stream chunk by chunk and stop at the limit, so a
701
- // chunked upload without a Content-Length cannot be buffered whole
702
- // (and that before any route logic, including the API-key check, runs).
703
- // The body is extracted more than once per request (middleware and
704
- // route); a web stream can only be read once, so the outcome is kept
705
- // per request the way Hono caches `arrayBuffer()`.
706
- const cached = rawBodies.get(c.req.raw);
707
- if (cached !== undefined) {
708
- return cached;
709
- }
710
- const stream = c.req.raw.body;
711
- if (!stream) {
712
- return Buffer.alloc(0);
713
- }
714
- let outcome;
715
- try {
716
- outcome = await readRawBody(webStreamChunks(stream), limit);
717
- }
718
- catch (error) {
719
- if (!(error instanceof RawBodyTooLargeError)) {
720
- throw error;
721
- }
722
- await stream.cancel().catch(() => undefined);
723
- outcome = error;
724
- }
725
- rawBodies.set(c.req.raw, outcome);
726
- return outcome;
716
+ return readBoundedRequestBody(c.req.raw, parseByteLimit(this.config.bodyParser.rawMaxSize));
727
717
  }
728
718
  return undefined;
729
719
  }
@@ -13,6 +13,8 @@
13
13
  */
14
14
  /** `true` for a content type whose body the adapters pass through unparsed. */
15
15
  export declare function isRawBodyContentType(contentType: unknown): boolean;
16
+ /** "10mb" / "512kb" / "1gb" / a byte count → bytes, or `undefined` when it is none of those. */
17
+ export declare function tryParseByteLimit(limit: string | number | undefined): number | undefined;
16
18
  /** "10mb" / "512kb" / "1gb" / a byte count → bytes. Falls back to 10 MB. */
17
19
  export declare function parseByteLimit(limit: string | number | undefined): number;
18
20
  /** Thrown when a raw body grows past the adapter's `maxSize`. */
@@ -22,6 +24,21 @@ export declare class RawBodyTooLargeError extends Error {
22
24
  }
23
25
  /** Read a Node request stream into one `Buffer`, refusing more than `maxBytes`. */
24
26
  export declare function readRawBody(stream: AsyncIterable<Buffer | string>, maxBytes: number): Promise<Buffer>;
27
+ /** A web `ReadableStream` of bytes as an async iterable of Buffers, releasing the reader on exit. */
28
+ export declare function webStreamChunks(stream: ReadableStream<Uint8Array>): AsyncIterable<Buffer>;
29
+ /**
30
+ * Read a web `Request` body, refusing more than `maxBytes`.
31
+ *
32
+ * A declared `Content-Length` over the limit is refused without reading
33
+ * anything. Otherwise the stream is read chunk by chunk and stopped at the
34
+ * limit, so a chunked body without a `Content-Length` cannot be buffered whole.
35
+ * Over the limit the result is the error itself rather than a throw: a throw
36
+ * from a middleware lands in the framework's error handler, which answers 500.
37
+ *
38
+ * The outcome is kept per request, because a web stream can only be read once
39
+ * and the body may be asked for more than once (middleware, then route).
40
+ */
41
+ export declare function readBoundedRequestBody(request: Request, maxBytes: number): Promise<Buffer | RawBodyTooLargeError>;
25
42
  /** A web `Response` returned by a route handler. */
26
43
  export declare function isWebResponse(value: unknown): value is Response;
27
44
  /** Status, headers and body bytes of a web `Response`, for adapters that write to a Node response. */
@@ -17,8 +17,8 @@ const RAW_BODY_CONTENT_TYPE = /^\s*(multipart\/form-data|application\/octet-stre
17
17
  export function isRawBodyContentType(contentType) {
18
18
  return (typeof contentType === "string" && RAW_BODY_CONTENT_TYPE.test(contentType));
19
19
  }
20
- /** "10mb" / "512kb" / "1gb" / a byte count → bytes. Falls back to 10 MB. */
21
- export function parseByteLimit(limit) {
20
+ /** "10mb" / "512kb" / "1gb" / a byte count → bytes, or `undefined` when it is none of those. */
21
+ export function tryParseByteLimit(limit) {
22
22
  if (typeof limit === "number" && Number.isFinite(limit) && limit > 0) {
23
23
  return limit;
24
24
  }
@@ -26,7 +26,7 @@ export function parseByteLimit(limit) {
26
26
  ? limit.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i)
27
27
  : null;
28
28
  if (!match) {
29
- return 10 * 1024 * 1024;
29
+ return undefined;
30
30
  }
31
31
  const units = {
32
32
  b: 1,
@@ -34,7 +34,14 @@ export function parseByteLimit(limit) {
34
34
  mb: 1024 * 1024,
35
35
  gb: 1024 * 1024 * 1024,
36
36
  };
37
- return Math.floor(Number(match[1]) * units[(match[2] ?? "b").toLowerCase()]);
37
+ const bytes = Math.floor(Number(match[1]) * units[(match[2] ?? "b").toLowerCase()]);
38
+ // A digit string long enough to overflow reads as Infinity, which no body
39
+ // can exceed: treat it as unparseable so the caller's default applies.
40
+ return Number.isFinite(bytes) ? bytes : undefined;
41
+ }
42
+ /** "10mb" / "512kb" / "1gb" / a byte count → bytes. Falls back to 10 MB. */
43
+ export function parseByteLimit(limit) {
44
+ return tryParseByteLimit(limit) ?? 10 * 1024 * 1024;
38
45
  }
39
46
  /** Thrown when a raw body grows past the adapter's `maxSize`. */
40
47
  export class RawBodyTooLargeError extends Error {
@@ -58,6 +65,64 @@ export async function readRawBody(stream, maxBytes) {
58
65
  }
59
66
  return Buffer.concat(chunks);
60
67
  }
68
+ /** A web `ReadableStream` of bytes as an async iterable of Buffers, releasing the reader on exit. */
69
+ export async function* webStreamChunks(stream) {
70
+ const reader = stream.getReader();
71
+ try {
72
+ for (;;) {
73
+ const { value, done } = await reader.read();
74
+ if (done) {
75
+ return;
76
+ }
77
+ if (value) {
78
+ yield Buffer.from(value.buffer, value.byteOffset, value.byteLength);
79
+ }
80
+ }
81
+ }
82
+ finally {
83
+ reader.releaseLock();
84
+ }
85
+ }
86
+ /** Bodies already read for a request, so a second read never hits a consumed stream. */
87
+ const boundedBodies = new WeakMap();
88
+ /**
89
+ * Read a web `Request` body, refusing more than `maxBytes`.
90
+ *
91
+ * A declared `Content-Length` over the limit is refused without reading
92
+ * anything. Otherwise the stream is read chunk by chunk and stopped at the
93
+ * limit, so a chunked body without a `Content-Length` cannot be buffered whole.
94
+ * Over the limit the result is the error itself rather than a throw: a throw
95
+ * from a middleware lands in the framework's error handler, which answers 500.
96
+ *
97
+ * The outcome is kept per request, because a web stream can only be read once
98
+ * and the body may be asked for more than once (middleware, then route).
99
+ */
100
+ export async function readBoundedRequestBody(request, maxBytes) {
101
+ if (Number(request.headers.get("content-length") ?? 0) > maxBytes) {
102
+ return new RawBodyTooLargeError(maxBytes);
103
+ }
104
+ const cached = boundedBodies.get(request);
105
+ if (cached !== undefined) {
106
+ return cached;
107
+ }
108
+ const stream = request.body;
109
+ if (!stream) {
110
+ return Buffer.alloc(0);
111
+ }
112
+ let outcome;
113
+ try {
114
+ outcome = await readRawBody(webStreamChunks(stream), maxBytes);
115
+ }
116
+ catch (error) {
117
+ if (!(error instanceof RawBodyTooLargeError)) {
118
+ throw error;
119
+ }
120
+ await stream.cancel().catch(() => undefined);
121
+ outcome = error;
122
+ }
123
+ boundedBodies.set(request, outcome);
124
+ return outcome;
125
+ }
61
126
  /** A web `Response` returned by a route handler. */
62
127
  export function isWebResponse(value) {
63
128
  return typeof Response !== "undefined" && value instanceof Response;