@hypequery/serve 0.24.0 → 0.24.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../../src/adapters/node.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,MAAM,CAAC;AAG/E,OAAO,KAAK,EAEV,YAAY,EAGZ,kBAAkB,EACnB,MAAM,aAAa,CAAC;AAoIrB,eAAO,MAAM,iBAAiB,GAC5B,SAAS,YAAY,EACrB,UAAS,kBAAuB,MAKlB,KAAK,eAAe,EAAE,KAAK,cAAc,kBAwCxD,CAAC;AAEF,eAAO,MAAM,eAAe,GAC1B,SAAS,YAAY,EACrB,UAAS,kBAAuB;;gBA0BP,OAAO,CAAC,IAAI,CAAC;EAiEvC,CAAC"}
1
+ {"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../../src/adapters/node.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,MAAM,CAAC;AAG/E,OAAO,KAAK,EAEV,YAAY,EAGZ,kBAAkB,EACnB,MAAM,aAAa,CAAC;AAmLrB,eAAO,MAAM,iBAAiB,GAC5B,SAAS,YAAY,EACrB,UAAS,kBAAuB,MAKlB,KAAK,eAAe,EAAE,KAAK,cAAc,kBAwCxD,CAAC;AAEF,eAAO,MAAM,eAAe,GAC1B,SAAS,YAAY,EACrB,UAAS,kBAAuB;;gBA0BP,OAAO,CAAC,IAAI,CAAC;EAiEvC,CAAC"}
@@ -2,9 +2,15 @@ import { createServer } from "http";
2
2
  import { once } from "node:events";
3
3
  import { normalizeHeaders, parseQueryParams, parseRequestBody, serializeResponseBody, } from "./utils.js";
4
4
  import { adapterErrorHeaders } from "../utils/error-headers.js";
5
+ import { declaresRequestBody, isNodeBodyConsumed, readPreParsedBody, resolveNodeRequestUrl, } from "../utils/node-request.js";
5
6
  const DEFAULT_REQUEST_TIMEOUT = 30_000; // 30 seconds
6
7
  const DEFAULT_BODY_LIMIT = 1_048_576; // 1 MB
7
8
  const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT = 10_000; // 10 seconds
9
+ const payloadTooLarge = () => {
10
+ const error = new Error("Request body too large");
11
+ error.code = "PAYLOAD_TOO_LARGE";
12
+ return error;
13
+ };
8
14
  const readRequestBody = async (req, bodyLimit) => {
9
15
  const chunks = [];
10
16
  let totalLength = 0;
@@ -12,21 +18,49 @@ const readRequestBody = async (req, bodyLimit) => {
12
18
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
13
19
  totalLength += buf.length;
14
20
  if (bodyLimit > 0 && totalLength > bodyLimit) {
15
- const error = new Error("Request body too large");
16
- error.code = "PAYLOAD_TOO_LARGE";
17
- throw error;
21
+ throw payloadTooLarge();
18
22
  }
19
23
  chunks.push(buf);
20
24
  }
21
25
  return Buffer.concat(chunks);
22
26
  };
27
+ const resolveRequestBody = async (req, bodyLimit, contentType) => {
28
+ if (!isNodeBodyConsumed(req)) {
29
+ return parseRequestBody(await readRequestBody(req, bodyLimit), contentType);
30
+ }
31
+ // A body parser (express.json(), NestJS, koa-bodyparser, ...) already read
32
+ // the stream. Use what it left on req.body; it has applied its own limit.
33
+ const preParsed = readPreParsedBody(req, bodyLimit);
34
+ if (preParsed.kind === "too-large")
35
+ throw payloadTooLarge();
36
+ if (preParsed.kind === "parsed")
37
+ return preParsed.value;
38
+ if (preParsed.kind === "raw") {
39
+ return parseRequestBody(preParsed.buffer, contentType);
40
+ }
41
+ if (declaresRequestBody(req)) {
42
+ // The body is gone and nothing exposed it. Fail loudly rather than
43
+ // validating an empty body and returning a misleading 400.
44
+ throw {
45
+ status: 500,
46
+ body: {
47
+ error: {
48
+ type: "INTERNAL_SERVER_ERROR",
49
+ message: "The request body was consumed before the hypequery handler ran. " +
50
+ "Register the handler before body-parsing middleware, or set " +
51
+ "req.body to the parsed body (for Fastify: request.raw.body = request.body).",
52
+ },
53
+ },
54
+ };
55
+ }
56
+ return undefined;
57
+ };
23
58
  const buildServeRequest = async (req, bodyLimit) => {
24
59
  const method = (req.method ?? "GET").toUpperCase();
25
- const url = new URL(req.url ?? "/", "http://localhost");
26
- const bodyBuffer = await readRequestBody(req, bodyLimit);
60
+ const url = new URL(resolveNodeRequestUrl(req), "http://localhost");
27
61
  const headers = normalizeHeaders(req.headers);
28
62
  const contentType = headers["content-type"] ?? headers["Content-Type"];
29
- const body = await parseRequestBody(bodyBuffer, contentType);
63
+ const body = await resolveRequestBody(req, bodyLimit, contentType);
30
64
  return {
31
65
  method,
32
66
  path: url.pathname,
@@ -0,0 +1,34 @@
1
+ import type { IncomingMessage } from "http";
2
+ /**
3
+ * The URL the client actually requested. Express and Connect strip the mount
4
+ * prefix from `req.url` inside `app.use('/prefix', handler)` but keep the full
5
+ * URL in `req.originalUrl`; routing needs the full path to match `basePath`.
6
+ */
7
+ export declare const resolveNodeRequestUrl: (req: IncomingMessage) => string;
8
+ /**
9
+ * True when something upstream (a body parser) has already read the request
10
+ * stream, so reading it again would yield nothing.
11
+ */
12
+ export declare const isNodeBodyConsumed: (req: IncomingMessage) => boolean;
13
+ /** True when the request headers announce a body. */
14
+ export declare const declaresRequestBody: (req: IncomingMessage) => boolean;
15
+ export type PreParsedBody = {
16
+ kind: "none";
17
+ } | {
18
+ kind: "too-large";
19
+ } | {
20
+ kind: "raw";
21
+ buffer: Buffer;
22
+ } | {
23
+ kind: "parsed";
24
+ value: unknown;
25
+ };
26
+ /**
27
+ * The body an upstream parser left on `req.body`, held to *bodyLimit* bytes
28
+ * (0 for none) before any of it is parsed. Buffers and strings
29
+ * (express.raw(), express.text()) are raw bytes for the adapter's own
30
+ * content-type parsing, except a string on a JSON request that a JSON parser
31
+ * already decoded (`readJsonString`); anything else is used as-is.
32
+ */
33
+ export declare const readPreParsedBody: (req: IncomingMessage, bodyLimit?: number) => PreParsedBody;
34
+ //# sourceMappingURL=node-request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node-request.d.ts","sourceRoot":"","sources":["../../src/utils/node-request.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,MAAM,CAAC;AAc5C;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GAAI,KAAK,eAAe,KAAG,MAM5D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,KAAK,eAAe,KAAG,OACK,CAAC;AAEhE,qDAAqD;AACrD,eAAO,MAAM,mBAAmB,GAAI,KAAK,eAAe,KAAG,OAI1D,CAAC;AAEF,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAChB;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,GACrB;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAC;AAqDvC;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,eAAe,EAAE,kBAAa,KAAG,aAgBvE,CAAC"}
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The URL the client actually requested. Express and Connect strip the mount
3
+ * prefix from `req.url` inside `app.use('/prefix', handler)` but keep the full
4
+ * URL in `req.originalUrl`; routing needs the full path to match `basePath`.
5
+ */
6
+ export const resolveNodeRequestUrl = (req) => {
7
+ const { originalUrl } = req;
8
+ if (typeof originalUrl === "string" && originalUrl.length > 0) {
9
+ return originalUrl;
10
+ }
11
+ return req.url ?? "/";
12
+ };
13
+ /**
14
+ * True when something upstream (a body parser) has already read the request
15
+ * stream, so reading it again would yield nothing.
16
+ */
17
+ export const isNodeBodyConsumed = (req) => req.readableEnded || req._body === true;
18
+ /** True when the request headers announce a body. */
19
+ export const declaresRequestBody = (req) => {
20
+ if (req.headers["transfer-encoding"] !== undefined)
21
+ return true;
22
+ const length = Number(req.headers["content-length"]);
23
+ return Number.isFinite(length) && length > 0;
24
+ };
25
+ /**
26
+ * How to read a string `req.body` on a JSON request: as raw text, or as a JSON
27
+ * value a parser already decoded (`express.json({ strict: false })` turns
28
+ * `"123"` into the string `123`) and must not be parsed again.
29
+ *
30
+ * Raw text has exactly the byte length the client declared, and a decoded
31
+ * JSON string is at least two bytes shorter because its quotes are gone, so
32
+ * the lengths decide whenever they can be compared. A chunked body declares no
33
+ * length, and a compressed one declares the compressed length; then the text
34
+ * decides: raw text that is a JSON object or array is what an endpoint takes
35
+ * (and is returned already parsed), while a decoded string is kept as a
36
+ * string, so `"123"` never becomes `123`.
37
+ *
38
+ * Supported limitation: without a comparable length, raw text that is a bare
39
+ * JSON primitive (`express.text()` given `123`, `true` or `null`) cannot be
40
+ * told from a decoded string and stays a string. An endpoint taking a
41
+ * primitive behind `express.text()` should receive a declared-length body, or
42
+ * sit behind `express.json()` instead.
43
+ */
44
+ const readJsonString = (req, body, byteLength) => {
45
+ const encoding = req.headers["content-encoding"];
46
+ const declared = req.headers["content-length"];
47
+ const comparable = (encoding === undefined || encoding.trim().toLowerCase() === "identity") &&
48
+ declared !== undefined &&
49
+ /^\d+$/.test(declared);
50
+ if (comparable) {
51
+ return Number(declared) === byteLength
52
+ ? { kind: "raw", buffer: Buffer.from(body, "utf8") }
53
+ : { kind: "parsed", value: body };
54
+ }
55
+ const value = parseJsonObjectText(body);
56
+ return { kind: "parsed", value: value === undefined ? body : value };
57
+ };
58
+ /** The object or array *text* encodes as JSON, or undefined for anything else. */
59
+ const parseJsonObjectText = (text) => {
60
+ const first = text.trimStart()[0];
61
+ if (first !== "{" && first !== "[")
62
+ return undefined;
63
+ try {
64
+ const value = JSON.parse(text);
65
+ return typeof value === "object" && value !== null ? value : undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ };
71
+ /**
72
+ * The body an upstream parser left on `req.body`, held to *bodyLimit* bytes
73
+ * (0 for none) before any of it is parsed. Buffers and strings
74
+ * (express.raw(), express.text()) are raw bytes for the adapter's own
75
+ * content-type parsing, except a string on a JSON request that a JSON parser
76
+ * already decoded (`readJsonString`); anything else is used as-is.
77
+ */
78
+ export const readPreParsedBody = (req, bodyLimit = 0) => {
79
+ const { body } = req;
80
+ if (body === undefined)
81
+ return { kind: "none" };
82
+ if (Buffer.isBuffer(body)) {
83
+ return bodyLimit > 0 && body.length > bodyLimit
84
+ ? { kind: "too-large" }
85
+ : { kind: "raw", buffer: body };
86
+ }
87
+ if (typeof body !== "string")
88
+ return { kind: "parsed", value: body };
89
+ const byteLength = Buffer.byteLength(body, "utf8");
90
+ if (bodyLimit > 0 && byteLength > bodyLimit)
91
+ return { kind: "too-large" };
92
+ const contentType = req.headers["content-type"];
93
+ if (typeof contentType === "string" && contentType.includes("application/json")) {
94
+ return readJsonString(req, body, byteLength);
95
+ }
96
+ return { kind: "raw", buffer: Buffer.from(body, "utf8") };
97
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypequery/serve",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
4
4
  "description": "Code-first TypeScript runtime for validated ClickHouse analytics APIs, OpenAPI, auth, and tenancy",
5
5
  "keywords": [
6
6
  "clickhouse",
@@ -34,11 +34,11 @@
34
34
  "dist"
35
35
  ],
36
36
  "dependencies": {
37
- "jose": "^5.9.6",
37
+ "jose": "^6.2.13",
38
38
  "openapi-typescript": "^7.13.0",
39
39
  "zod": "^4.6.6",
40
40
  "zod-to-json-schema": "^3.25.2",
41
- "@hypequery/datasets": "^0.27.0"
41
+ "@hypequery/datasets": "^0.28.0"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "tsx": "^4.0.0",
@@ -53,8 +53,11 @@
53
53
  }
54
54
  },
55
55
  "devDependencies": {
56
+ "@types/express": "^5.0.6",
56
57
  "@types/node": "^20.11.30",
57
58
  "@vitest/coverage-v8": "^5.0.5",
59
+ "express": "^5.2.1",
60
+ "fastify": "^5.12.7",
58
61
  "tsx": "^4.23.15",
59
62
  "typescript": "^6.0.3",
60
63
  "vitest": "^5.0.5"