From 1ccff1eb5c7966086f00e1cc1863805eade621f8 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Mon, 19 Oct 2026 01:49:58 +0200 Subject: [PATCH 1/8] Vendor @nestjs/platform-node 2b10fb2 (12.1.3) typed sources under runtime/node/nest --- runtime/node/nest/platform-node/SOURCE | 11 + .../node/nest/platform-node/UPSTREAM-DIFF.md | 9 + .../platform-node/adapters/node-request.ts | 726 ++++++++++++++++ .../platform-node/body-parser/body-parser.ts | 805 ++++++++++++++++++ .../nest/platform-node/router/node-router.ts | 499 +++++++++++ .../nest/platform-node/router/route-table.ts | 527 ++++++++++++ .../node/nest/platform-node/router/utils.ts | 117 +++ 7 files changed, 2694 insertions(+) create mode 100644 runtime/node/nest/platform-node/SOURCE create mode 100644 runtime/node/nest/platform-node/UPSTREAM-DIFF.md create mode 100644 runtime/node/nest/platform-node/adapters/node-request.ts create mode 100644 runtime/node/nest/platform-node/body-parser/body-parser.ts create mode 100644 runtime/node/nest/platform-node/router/node-router.ts create mode 100644 runtime/node/nest/platform-node/router/route-table.ts create mode 100644 runtime/node/nest/platform-node/router/utils.ts diff --git a/runtime/node/nest/platform-node/SOURCE b/runtime/node/nest/platform-node/SOURCE new file mode 100644 index 0000000..fa07a1d --- /dev/null +++ b/runtime/node/nest/platform-node/SOURCE @@ -0,0 +1,11 @@ +Vendored from nestjs/nest branch feat/platform-node +commit 2b10fb228353fcaedd2817cc22f89ad1cca9a900 (2026-10-14, packages at 12.1.3), packages/platform-node/: + router/route-table.ts + router/node-router.ts + router/utils.ts + adapters/node-request.ts + body-parser/body-parser.ts +Excluded on purpose: adapters/node-adapter.ts (extends @nestjs/core AbstractHttpAdapter), +multer/, views/, interfaces/ (not imported by the files above), index.ts. +Edited only as listed in UPSTREAM-DIFF.md (strictness fixes). +Re-vendor: copy the same five files from the pinned commit, then re-apply UPSTREAM-DIFF.md. diff --git a/runtime/node/nest/platform-node/UPSTREAM-DIFF.md b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md new file mode 100644 index 0000000..617e990 --- /dev/null +++ b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md @@ -0,0 +1,9 @@ +# Edits against upstream (nestjs/nest `feat/platform-node` @ 2b10fb2) + +Policy: the vendored files are edited only where the whole-program `strict` +checking of this target rejects code platform-node compiles under its own +`noImplicitAny: false` / `useUnknownInCatchVariables: false` tsconfig, and +each edit is listed here with the upstream line. Behaviour is unchanged. + +| File | Upstream line | Edit | Why | +|---|---|---|---| diff --git a/runtime/node/nest/platform-node/adapters/node-request.ts b/runtime/node/nest/platform-node/adapters/node-request.ts new file mode 100644 index 0000000..b0328d1 --- /dev/null +++ b/runtime/node/nest/platform-node/adapters/node-request.ts @@ -0,0 +1,726 @@ +import { serialize as serializeCookie } from 'cookie'; +import { sign as signCookie } from 'cookie-signature'; +import encodeUrl from 'encodeurl'; +import { IncomingMessage, ServerResponse, STATUS_CODES } from 'http'; +import { parse as parseQueryString } from 'fast-querystring'; +import { contentType as lookupContentType } from 'mime-types'; +import onFinished from 'on-finished'; +import { isAbsolute } from 'path'; +import send from 'send'; +import typeIs from 'type-is'; +import { getRoutingPath } from '../router/utils.js'; + +const QUERY = Symbol('query'); +const LOCALS = Symbol('locals'); + +/** + * Set by the `NodeAdapter` on the HTTP server it creates: a function that + * runs the error-handling middleware (and Nest's exception filters) for an + * error raised after the route handler returned, such as by + * `res.sendFile()`. Express reaches them through the `next()` it stores on + * every request; the server is reachable from the request's socket, so this + * costs nothing per request. + * + * @internal + */ +export const ROUTE_ERROR_HANDLER = Symbol('nestjs.node.routeErrorHandler'); + +/** + * What `req.app` returns: middleware written for Express reads settings from + * it (`express-rate-limit` checks `req.app.get('trust proxy')`). There are no + * Express settings here; `get()` reports the adapter's fixed behavior. + */ +const NODE_APP = Object.freeze({ + /** + * `'trust proxy'` is `false` (`req.ip` is the socket address); every other + * setting is `undefined`. + */ + get(setting: string): unknown { + return setting === 'trust proxy' ? false : undefined; + }, +}); + +/** + * Options of {@link NodeResponse.sendFile}, those of the `send` package, as + * with Express' `res.sendFile()`. + */ +export interface NodeSendFileOptions { + /** Directory a relative `path` is resolved against. */ + root?: string; + /** `Cache-Control` max-age, in milliseconds or as a string ("1d"). */ + maxAge?: number | string; + /** Extra headers, set once the file is known to exist. */ + headers?: Record; + /** How to treat dotfiles: `'ignore'` (404, the default), `'allow'` or `'deny'` (403). */ + dotfiles?: 'allow' | 'deny' | 'ignore'; + /** Whether to send an `ETag`. Defaults to `true`, as with Express. */ + etag?: boolean; + lastModified?: boolean; + cacheControl?: boolean; + immutable?: boolean; + acceptRanges?: boolean; +} + +/** + * Options of {@link NodeResponse.cookie}, as with Express' `res.cookie()`. + */ +export interface NodeCookieOptions { + /** Lifetime in milliseconds (sent as `Expires` and `Max-Age`). */ + maxAge?: number; + expires?: Date; + /** Defaults to `/`. */ + path?: string; + domain?: string; + httpOnly?: boolean; + secure?: boolean; + sameSite?: boolean | 'lax' | 'strict' | 'none'; + priority?: 'low' | 'medium' | 'high'; + partitioned?: boolean; + /** Signs the value with the secret `cookie-parser` sets on `req.secret`. */ + signed?: boolean; + /** Encodes the value; `encodeURIComponent()` by default. */ + encode?: (value: string) => string; +} + +/** + * Request object handed to middleware and route handlers by the + * `NodeAdapter`. It is a plain Node.js `IncomingMessage` (the HTTP server is + * created with this class), extended with the handful of properties the Nest + * core and common middleware read. Everything is defined on the prototype and + * computed lazily, so a request that never reads `query` never parses the + * query string. + * + * @publicApi + */ +export class NodeRequest extends IncomingMessage { + /** + * The request URL as received, before any mount point stripped a prefix + * from `url`. + */ + declare originalUrl: string; + /** + * Route parameters of the matched route (or middleware path). + */ + declare params: Record; + /** + * Parsed request body, set by the body parsers. + */ + declare body: any; + /** + * Unparsed request body, set by the body parsers when the `rawBody` + * application option is enabled. + */ + declare rawBody?: Buffer; + /** + * Path the current middleware is mounted on (see `use(path, handler)`). + */ + declare baseUrl: string; + + declare [QUERY]: Record | undefined; + + /** + * Query string parameters, parsed on first access. + */ + get query(): Record { + let query = this[QUERY]; + if (query === undefined) { + const url = this.url!; + const index = url.indexOf('?'); + query = this[QUERY] = + index === -1 ? {} : parseQueryString(url.slice(index + 1)); + } + return query!; + } + + set query(value: Record) { + this[QUERY] = value; + } + + /** + * Path of `url`, as the router matches it: the path of an absolute-form + * target, percent-decoded except for reserved characters such as "%2F", + * without the query string. Unlike Express' `req.path`, which keeps the + * percent-encoding, a middleware checking it sees the path the routes see. + */ + get path(): string { + const url = this.url!; + try { + return getRoutingPath(url); + } catch { + // A malformed URL, which reaches no route (see NodeRouter) + const index = url.indexOf('?'); + return index === -1 ? url : url.slice(0, index); + } + } + + /** + * Host name from the `Host` header, without the port. + */ + get hostname(): string | undefined { + const host = this.headers.host; + if (!host) { + return undefined; + } + // IPv6 literal, e.g. "[::1]:3000" + const offset = + host.charCodeAt(0) === 91 /* [ */ ? host.indexOf(']') + 1 : 0; + const index = host.indexOf(':', offset); + return index === -1 ? host : host.slice(0, index); + } + + /** + * Remote address of the underlying socket. + */ + get ip(): string | undefined { + return this.socket?.remoteAddress; + } + + get protocol(): 'http' | 'https' { + return (this.socket as { encrypted?: boolean })?.encrypted + ? 'https' + : 'http'; + } + + get secure(): boolean { + return this.protocol === 'https'; + } + + /** + * Returns a request header (case-insensitive), like Express' `req.get()`. + */ + get(name: string): string | string[] | undefined { + const lowerCaseName = name.toLowerCase(); + if (lowerCaseName === 'referer' || lowerCaseName === 'referrer') { + return this.headers.referrer || this.headers.referer; + } + return this.headers[lowerCaseName]; + } + + /** + * Alias of {@link NodeRequest.get}. + */ + header(name: string): string | string[] | undefined { + return this.get(name); + } + + /** + * Stand-in for the Express application, for middleware that reads its + * settings: `req.app.get('trust proxy')` is `false`, every other setting + * `undefined`. + */ + get app(): { get(setting: string): unknown } { + return NODE_APP; + } + + /** + * Checks the request's `Content-Type` against `types`, like Express' + * `req.is()`: returns the first matching type, `false` when none matches and + * `null` when the request has no body. Types may be MIME types, wildcards + * (`'text/*'`, `'+json'`) or extensions (`'json'`, `'html'`). Without + * arguments, returns the request's media type. + */ + is(...types: (string | string[])[]): string | false | null { + return typeIs(this, types.flat()); + } +} + +// Every request has a "body" (Express libraries check "'body' in req"), with +// no per-request property write until a parser sets one +Object.defineProperty(NodeRequest.prototype, 'body', { + value: undefined, + writable: true, + configurable: true, +}); + +/** + * Response object handed to middleware and route handlers by the + * `NodeAdapter`: a plain Node.js `ServerResponse`, plus the small subset of + * the Express response API that middleware and exception filters most often + * rely on (`status()`, `send()`, `json()`, `set()`, `append()`, `cookie()`, + * `redirect()`, `sendFile()`, `locals`...). These helpers live on + * the prototype and add no per-request cost; unlike Express, `send()` does not + * compute an `ETag`. + * + * @publicApi + */ +export class NodeResponse< + Request extends IncomingMessage = NodeRequest, +> extends ServerResponse { + declare [LOCALS]: Record | undefined; + + /** + * Values scoped to this response, like Express' `res.locals`, for + * middleware to pass data along. Created on first access. + */ + get locals(): Record { + return (this[LOCALS] ??= Object.create(null)); + } + + set locals(value: Record) { + this[LOCALS] = value; + } + + /** + * Sets the status code. Chainable. + */ + status(statusCode: number): this { + this.statusCode = statusCode; + return this; + } + + /** + * Sets a response header (or several, given an object). Chainable. + */ + set(field: string | Record, value?: any): this { + if (typeof field === 'string') { + this.setHeader( + field, + Array.isArray(value) ? value.map(String) : String(value), + ); + } else { + for (const name of Object.keys(field)) { + this.set(name, field[name]); + } + } + return this; + } + + /** + * Alias of {@link NodeResponse.set}. + */ + header(field: string | Record, value?: any): this { + return this.set(field, value); + } + + /** + * Appends a value to a response header, creating the header when missing, + * like Express' `res.append()`. Chainable. + */ + append(field: string, value: string | string[]): this { + const previous = this.getHeader(field); + let combined: unknown = value; + if (previous) { + combined = Array.isArray(previous) + ? previous.concat(value) + : Array.isArray(value) + ? [previous, ...value] + : [previous, value]; + } + return this.set(field, combined); + } + + /** + * Sets a cookie, like Express' `res.cookie()`, for middleware written for + * Express: objects are sent as `j:` + JSON, `maxAge` is in milliseconds and + * `signed` uses the secret `cookie-parser` sets on `req.secret`. In your own + * handlers, prefer Nest's adapter-agnostic cookies (`setCookie()`). + * Chainable. + */ + cookie(name: string, value: unknown, options?: NodeCookieOptions): this { + const opts: Record = { ...options }; + const secret = (this.req as { secret?: string }).secret; + if (opts.signed && !secret) { + throw new Error('cookieParser("secret") required for signed cookies'); + } + let text = + typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value); + if (opts.signed) { + text = 's:' + signCookie(text, secret!); + } + if (opts.maxAge != null) { + const maxAge = opts.maxAge - 0; + if (!Number.isNaN(maxAge)) { + opts.expires = new Date(Date.now() + maxAge); + opts.maxAge = Math.floor(maxAge / 1000); + } + } + if (opts.path == null) { + opts.path = '/'; + } + return this.append('Set-Cookie', serializeCookie(name, String(text), opts)); + } + + /** + * Expires a cookie, like Express' `res.clearCookie()`; `path` and `domain` + * must match the ones the cookie was set with. Chainable. + */ + clearCookie(name: string, options?: NodeCookieOptions): this { + const opts: NodeCookieOptions = { + path: '/', + ...options, + expires: new Date(1), + }; + delete opts.maxAge; + return this.cookie(name, '', opts); + } + + /** + * Returns a response header that was set earlier. + */ + get(field: string) { + return this.getHeader(field); + } + + /** + * Sets the `Content-Type` header, given a MIME type or, as with Express, a + * file extension (`'json'`, `'.html'`, `'png'`). Chainable. + */ + type(type: string): this { + this.setHeader( + 'Content-Type', + type.includes('/') + ? type + : lookupContentType(type) || 'application/octet-stream', + ); + return this; + } + + /** + * Sends a JSON response. + */ + json(body: unknown): this { + if (endWithoutBody(this)) { + return this; + } + // Before any header, in case it throws (a BigInt, a circular reference) + const text = JSON.stringify(body); + if (!this.hasHeader('Content-Type')) { + this.setHeader('Content-Type', 'application/json; charset=utf-8'); + } + if (text === undefined) { + this.end(); + } else { + endWithBody(this, text); + } + return this; + } + + /** + * Sends a response: objects, numbers and booleans as JSON, buffers, typed + * arrays and `DataView`s as binary, anything else as text. Unlike Express, + * strings default to `text/plain` (as with the values route handlers + * return); set the `Content-Type` first to send HTML. + */ + send(body?: unknown): this { + if (endWithoutBody(this)) { + return this; + } + if (body === undefined || body === null) { + this.end(); + } else if (typeof body === 'string') { + if (!this.hasHeader('Content-Type')) { + this.setHeader('Content-Type', 'text/plain; charset=utf-8'); + } + endWithBody(this, body); + } else if (ArrayBuffer.isView(body)) { + if (!this.hasHeader('Content-Type')) { + this.setHeader('Content-Type', 'application/octet-stream'); + } + endWithBody(this, toBytes(body)); + } else if ( + typeof body === 'object' || + typeof body === 'number' || + typeof body === 'boolean' + ) { + // As with Express 5 + return this.json(body); + } else { + return this.send(String(body)); + } + return this; + } + + /** + * Sends the status code with its reason phrase as the body. + */ + sendStatus(statusCode: number): this { + this.statusCode = statusCode; + this.setHeader('Content-Type', 'text/plain; charset=utf-8'); + return this.send(STATUS_CODES[statusCode] ?? String(statusCode)); + } + + /** + * Streams a file, like Express' `res.sendFile()` (both build on the `send` + * package): `Content-Type` from the extension, `Content-Length`, + * `Last-Modified`, `ETag`, ranges and conditional requests (304). `path` is + * absolute, or relative to `options.root`. + * + * `callback(err)` is called once the transfer is done or failed (check + * `res.headersSent` before responding). Without one, a failure goes to the + * error-handling middleware and Nest's exception filters, as with Express: + * a missing file is a 404, a directory answers like an unmatched route. + */ + sendFile( + path: string, + options?: NodeSendFileOptions | ((err?: Error) => void) | null, + callback?: (err?: Error) => void, + ): void { + if (!path) { + throw new TypeError('path argument is required to res.sendFile'); + } + if (typeof path !== 'string') { + throw new TypeError('path must be a string to res.sendFile'); + } + let done = callback; + let opts: NodeSendFileOptions = {}; + if (typeof options === 'function') { + done = options; + } else if (options) { + opts = { ...options }; + } + if (!opts.root && !isAbsolute(path)) { + throw new TypeError( + 'path must be absolute or specify root to res.sendFile', + ); + } + opts.etag ??= true; + const req = this.req; + const file = send(req, encodeURI(path), opts as send.SendOptions); + transferFile(this, file, opts, err => { + if (done) { + return done(err); + } + // Client aborts and write errors are nothing to report, as with Express + if ( + !err || + (err as any).code === 'ECONNABORTED' || + (err as any).syscall === 'write' + ) { + return; + } + const handleError = (req.socket as any)?.server?.[ROUTE_ERROR_HANDLER]; + if (handleError) { + handleError(err, req, this); + } else { + respondWithError(this, err); + } + }); + } + + /** + * Redirects to `url` (encoded into the `Location` header), with a 302 unless + * a status code is given first, like Express' `res.redirect()`. The body is + * a short note in plain text or HTML, whichever the client's `Accept` + * header prefers, and empty when it accepts neither. + */ + redirect(url: string): void; + redirect(statusCode: number, url: string): void; + redirect(statusCodeOrUrl: number | string, url?: string): void { + const statusCode = + typeof statusCodeOrUrl === 'number' ? statusCodeOrUrl : 302; + const location = encodeUrl( + typeof statusCodeOrUrl === 'number' ? url! : statusCodeOrUrl, + ); + const message = STATUS_CODES[statusCode] ?? String(statusCode); + let body = ''; + switch (preferredRedirectType(this.req.headers.accept)) { + case 'text': + this.setHeader('Content-Type', 'text/plain; charset=utf-8'); + body = `${message}. Redirecting to ${location}`; + break; + case 'html': + this.setHeader('Content-Type', 'text/html; charset=utf-8'); + body = `

${message}. Redirecting to ${escapeHtml(location)}

`; + break; + } + this.statusCode = statusCode; + this.setHeader('Location', location); + appendVary(this, 'Accept'); + this.setHeader('Content-Length', Buffer.byteLength(body)); + this.end(this.req.method === 'HEAD' ? undefined : body); + } +} + +/** + * Pipes a `send` stream into the response and calls `callback` once, when the + * transfer ended, failed or was aborted: Express' `sendfile()`. + */ +function transferFile( + res: ServerResponse, + file: send.SendStream, + options: NodeSendFileOptions, + callback: (err?: Error) => void, +) { + let done = false; + let streaming: boolean | undefined; + const finish = (err?: Error) => { + if (!done) { + done = true; + callback(err); + } + }; + const onAborted = () => + finish( + Object.assign(new Error('Request aborted'), { code: 'ECONNABORTED' }), + ); + + file.on('directory', () => + finish(Object.assign(new Error('EISDIR, read'), { code: 'EISDIR' })), + ); + file.on('end', () => finish()); + file.on('error', (err: Error) => finish(err)); + file.on('file', () => { + streaming = false; + }); + file.on('stream', () => { + streaming = true; + }); + onFinished(res, (err: any) => { + if (err && err.code === 'ECONNRESET') { + return onAborted(); + } + if (err) { + return finish(err); + } + if (done) { + return; + } + setImmediate(() => { + if (streaming !== false && !done) { + onAborted(); + return; + } + finish(); + }); + }); + if (options.headers) { + const headers = options.headers; + file.on('headers', (response: ServerResponse) => { + for (const name of Object.keys(headers)) { + response.setHeader(name, headers[name]); + } + }); + } + file.pipe(res); +} + +/** + * Answers an error with its status and reason phrase, when there is no + * adapter to hand it to (a `NodeRouter` mounted in another server). + */ +function respondWithError(res: ServerResponse, err: any) { + if (res.headersSent) { + res.destroy(); + return; + } + // A directory answers like a missing file + const status = + err?.code === 'EISDIR' ? 404 : (err?.statusCode ?? err?.status); + res.statusCode = status >= 400 && status < 600 ? status : 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.end(STATUS_CODES[res.statusCode]); +} + +/** + * Picks the redirect body Express would send for an `Accept` header: the + * candidate with the highest quality, then the most specific matching range, + * then the earlier range, then plain text over HTML (Express' order); `null` + * when neither is acceptable. + */ +function preferredRedirectType( + accept: string | undefined, +): 'text' | 'html' | null { + if (!accept) { + return 'text'; + } + const ranges = accept.split(',').map((part, order) => { + const [range, ...params] = part.trim().toLowerCase().split(';'); + const qParam = params.find(param => param.trim().startsWith('q=')); + const q = qParam ? Number(qParam.trim().slice(2)) : 1; + const [type, subtype] = range.trim().split('/'); + return { type, subtype, q: Number.isNaN(q) ? 0 : q, order }; + }); + const rank = (subtype: 'plain' | 'html') => { + let best: { q: number; specificity: number; order: number } | undefined; + for (const range of ranges) { + const specificity = + range.type === 'text' && range.subtype === subtype + ? 2 + : range.type === 'text' && range.subtype === '*' + ? 1 + : range.type === '*' && range.subtype === '*' + ? 0 + : -1; + if (specificity > (best?.specificity ?? -1)) { + best = { q: range.q, specificity, order: range.order }; + } + } + return best; + }; + const text = rank('plain'); + const html = rank('html'); + const acceptable = [ + text && text.q > 0 ? { type: 'text' as const, ...text } : undefined, + html && html.q > 0 ? { type: 'html' as const, ...html } : undefined, + ].filter(candidate => candidate !== undefined); + acceptable.sort( + (a, b) => b.q - a.q || b.specificity - a.specificity || a.order - b.order, + ); + return acceptable[0]?.type ?? null; +} + +function appendVary(response: ServerResponse, field: string) { + const vary = response.getHeader('Vary'); + if (vary === undefined) { + response.setHeader('Vary', field); + return; + } + const value = Array.isArray(vary) ? vary.join(', ') : String(vary); + const fields = value.split(',').map(name => name.trim().toLowerCase()); + if (!fields.includes('*') && !fields.includes(field.toLowerCase())) { + response.setHeader('Vary', value ? `${value}, ${field}` : field); + } +} + +function escapeHtml(text: string) { + return text.replace( + /[&<>"']/g, + char => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ + char + ]!, + ); +} + +/** + * The bytes of a `Buffer`, a typed array or a `DataView`, which are sent as + * binary (as Fastify does) rather than serialized to JSON. + */ +export function toBytes(view: ArrayBufferView): Uint8Array { + return view instanceof Uint8Array + ? view + : new Uint8Array(view.buffer, view.byteOffset, view.byteLength); +} + +// With an explicit "Content-Length", which Node.js leaves out in answer to +// HEAD requests otherwise +function endWithBody(response: ServerResponse, body: string | Uint8Array) { + response.setHeader( + 'Content-Length', + typeof body === 'string' ? Buffer.byteLength(body) : body.byteLength, + ); + response.end(body); +} + +/** + * Ends 204, 205 and 304 responses without a body or the headers describing + * one, as Express does: RFC 9110 forbids a body for them, and a stray + * "Content-Length" can desynchronize keep-alive connections. Returns whether + * the response was ended. + */ +export function endWithoutBody(response: ServerResponse): boolean { + const { statusCode } = response; + if (statusCode !== 204 && statusCode !== 205 && statusCode !== 304) { + return false; + } + response.removeHeader('Content-Type'); + response.removeHeader('Transfer-Encoding'); + if (statusCode === 205) { + response.setHeader('Content-Length', '0'); + } else { + response.removeHeader('Content-Length'); + } + response.end(); + return true; +} diff --git a/runtime/node/nest/platform-node/body-parser/body-parser.ts b/runtime/node/nest/platform-node/body-parser/body-parser.ts new file mode 100644 index 0000000..3d9b81b --- /dev/null +++ b/runtime/node/nest/platform-node/body-parser/body-parser.ts @@ -0,0 +1,805 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import contentType from 'content-type'; +import qs from 'qs'; +import secureJson from 'secure-json-parse'; +import type { Readable, Transform } from 'stream'; +import typeIs from 'type-is'; +import { createBrotliDecompress, createGunzip, createInflate } from 'zlib'; + +/** + * Options shared by every body parser. They mirror the ones of the + * `body-parser` package, so that applications moving from the + * `ExpressAdapter` keep their configuration. + * + * @publicApi + */ +export interface NodeBodyParserOptions { + /** + * Maximum body size, in bytes or as a string such as `'100kb'`. + * Defaults to `'100kb'`. + */ + limit?: number | string; + /** + * Whether to decompress `gzip`, `deflate` and `br` bodies. Defaults to + * `true`; when `false`, compressed bodies are rejected with a 415. + */ + inflate?: boolean; + /** + * Media type(s) the parser handles, or a function deciding it. + */ + type?: string | string[] | ((req: IncomingMessage) => boolean); + /** + * Called with the raw body before parsing; throw to reject the request + * with a 403. + */ + verify?: ( + req: IncomingMessage, + res: ServerResponse, + buffer: Buffer, + encoding: string, + ) => void; +} + +/** + * What to do with a body that tries to inject prototype properties, as with + * Fastify: reject it with a 400 (`'error'`, the default), drop the offending + * keys (`'remove'`), or keep them (`'ignore'`, the `body-parser` behavior). + * + * @publicApi + */ +export type NodePoisoningAction = 'error' | 'remove' | 'ignore'; + +/** + * @publicApi + */ +export interface NodePrototypePoisoningOptions { + /** + * Action for `"__proto__"` keys. Defaults to `'error'`. In urlencoded + * bodies, `qs` drops these keys itself, so `'remove'` and `'ignore'` both + * leave them out there. + */ + onProtoPoisoning?: NodePoisoningAction; + /** + * Action for `constructor.prototype` keys. Defaults to `'error'`. + */ + onConstructorPoisoning?: NodePoisoningAction; +} + +/** + * @publicApi + */ +export interface NodeJsonParserOptions + extends NodeBodyParserOptions, NodePrototypePoisoningOptions { + /** + * Only accept arrays and objects. Defaults to `true`. + */ + strict?: boolean; + reviver?: (key: string, value: any) => any; +} + +/** + * @publicApi + */ +export interface NodeUrlencodedParserOptions + extends NodeBodyParserOptions, NodePrototypePoisoningOptions { + /** + * Parse nested objects and arrays (`a[b]=c`) with `qs`. Defaults to + * `false`. + */ + extended?: boolean; + /** + * Maximum number of parameters. Defaults to `1000`. + */ + parameterLimit?: number; + /** + * Maximum nesting depth with `extended`. Defaults to `32`. + */ + depth?: number; + /** + * Charset used when the request does not specify one: `'utf-8'` (the + * default) or `'iso-8859-1'`. + */ + defaultCharset?: 'utf-8' | 'iso-8859-1'; + /** + * Picks the charset from a `utf8=✓` parameter (which is dropped), as + * Rails and some HTML forms send. Defaults to `false`. + */ + charsetSentinel?: boolean; + /** + * Decodes HTML numeric entities (`☺`), which browsers send for + * characters an `iso-8859-1` form cannot encode. Defaults to `false`. + */ + interpretNumericEntities?: boolean; +} + +/** + * @publicApi + */ +export interface NodeTextParserOptions extends NodeBodyParserOptions { + /** + * Charset used when the request does not specify one. Defaults to + * `'utf-8'`. + */ + defaultCharset?: string; +} + +type Middleware = (req: any, res: any, next: (err?: any) => void) => void; + +interface ReaderOptions { + limit: number; + inflate: boolean; + matchesType: (req: IncomingMessage) => boolean; + verify: NodeBodyParserOptions['verify']; + /** Charsets the parser accepts, `undefined` for any. */ + isValidCharset?: (charset: string) => boolean; + defaultCharset: string; +} + +const UNITS: Record = { + b: 1, + kb: 2 ** 10, + mb: 2 ** 20, + gb: 2 ** 30, + tb: 2 ** 40, + pb: 2 ** 50, +}; + +function parseLimit(limit: number | string | null | undefined): number { + if (limit === undefined || limit === null) { + return 100 * 1024; + } + if (typeof limit === 'number') { + // An invalid limit must not silently disable the size check + // (body-parser CVE-2026-12590); `Infinity` disables it on purpose + if (Number.isNaN(limit) || limit < 0) { + throw new TypeError(`Invalid body size limit: ${limit}`); + } + return limit; + } + const match = + typeof limit === 'string' + ? /^\s*(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb|pb)?\s*$/i.exec(limit) + : null; + if (!match) { + throw new TypeError(`Invalid body size limit: "${limit}"`); + } + return Math.floor( + parseFloat(match[1]) * UNITS[(match[2] ?? 'b').toLowerCase()], + ); +} + +function createHttpError( + statusCode: number, + message: string, + type: string, + properties: object = {}, + ErrorClass: typeof Error = Error, +) { + const error = new ErrorClass(message); + return Object.assign(error, { + status: statusCode, + statusCode, + expose: statusCode < 500, + type, + ...properties, + }); +} + +/** + * Media type of the request (`content-type` without its parameters), lower + * case. + */ +function getMediaType(req: IncomingMessage): string { + const contentType = req.headers['content-type']; + if (!contentType) { + return ''; + } + const index = contentType.indexOf(';'); + return (index === -1 ? contentType : contentType.slice(0, index)) + .trim() + .toLowerCase(); +} + +// A client sends the same header with every request, so it is parsed once +let cachedHeader = ''; +let cachedCharset: string | undefined; + +function getCharset(req: IncomingMessage): string | undefined { + const header = req.headers['content-type']; + if (!header || header.indexOf(';') === -1) { + return undefined; + } + if (header !== cachedHeader) { + cachedHeader = header; + try { + // RFC 9110 parameter parsing (quoted strings included), as body-parser + // does, so that a "charset=" inside another parameter's quoted value is + // not mistaken for the charset + cachedCharset = contentType + .parse(header) + .parameters.charset?.toLowerCase(); + } catch { + cachedCharset = undefined; + } + } + return cachedCharset; +} + +function createTypeMatcher( + type: NodeBodyParserOptions['type'], + defaultType: string, +): (req: IncomingMessage) => boolean { + if (typeof type === 'function') { + return type; + } + const types = + type === undefined ? [defaultType] : ([] as string[]).concat(type); + // Plain media types (the defaults) are compared directly, anything else + // ("json", "application/*", "*/*+json"...) goes through "type-is" + if (types.every(t => t.includes('/') && !/[*+]/.test(t))) { + const mediaTypes = new Set(types.map(t => t.toLowerCase())); + return req => mediaTypes.has(getMediaType(req)); + } + return req => typeIs(req, types) !== false; +} + +function hasBody(req: IncomingMessage) { + return ( + req.headers['transfer-encoding'] !== undefined || + req.headers['content-length'] !== undefined + ); +} + +function getContentStream(req: IncomingMessage, inflate: boolean): Readable { + const encoding = ( + req.headers['content-encoding'] ?? 'identity' + ).toLowerCase(); + if (encoding === 'identity') { + return req; + } + if (!inflate) { + throw createHttpError( + 415, + 'content encoding unsupported', + 'encoding.unsupported', + { encoding }, + ); + } + let stream: Transform; + switch (encoding) { + case 'gzip': + case 'x-gzip': + stream = createGunzip(); + break; + case 'deflate': + stream = createInflate(); + break; + case 'br': + stream = createBrotliDecompress(); + break; + default: + throw createHttpError( + 415, + `unsupported content encoding "${encoding}"`, + 'encoding.unsupported', + { encoding }, + ); + } + req.pipe(stream); + return stream; +} + +/** + * Reads the whole (decompressed) body, enforcing the size limit, then calls + * `parse` with it. Requests without a body, or whose content type the parser + * does not handle, are passed on untouched without any work. + */ +function createReader( + options: ReaderOptions, + parse: (buffer: Buffer, charset: string, req: IncomingMessage) => unknown, + name: string, +): Middleware { + const reader: Middleware = (req, res, next) => { + // Nothing to read, already read by another parser, a client that went + // away, or not our type + if ( + !hasBody(req) || + req.readableEnded || + req.destroyed || + req.body !== undefined || + !options.matchesType(req) + ) { + return next(); + } + const charset = getCharset(req) ?? options.defaultCharset; + if (options.isValidCharset && !options.isValidCharset(charset)) { + return next( + createHttpError( + 415, + `unsupported charset "${charset.toUpperCase()}"`, + 'charset.unsupported', + { charset }, + ), + ); + } + + const contentLength = req.headers['content-length']; + const length = + contentLength !== undefined ? parseInt(contentLength, 10) : undefined; + const limit = options.limit; + let stream: Readable; + try { + stream = getContentStream(req, options.inflate); + } catch (error) { + return dump(req, () => next(error)); + } + if (stream === req && length !== undefined && length > limit) { + return dump(req, () => + next( + createHttpError(413, 'request entity too large', 'entity.too.large', { + expected: length, + length, + limit, + }), + ), + ); + } + + const chunks: Buffer[] = []; + let received = 0; + let finished = false; + + const cleanup = () => { + finished = true; + stream.off('data', onData); + stream.off('end', onEnd); + stream.off('error', onError); + req.off('close', onClose); + if (stream !== req) { + // An "error" event with no listener would crash the process + stream.on('error', noop); + } + }; + const fail = (error: Error) => { + if (finished) { + return; + } + cleanup(); + if (stream !== req) { + req.unpipe(); + stream.destroy(); + } + dump(req, () => next(error)); + }; + const onData = (chunk: Buffer) => { + received += chunk.length; + if (received > limit) { + return fail( + createHttpError(413, 'request entity too large', 'entity.too.large', { + limit, + received, + }), + ); + } + chunks.push(chunk); + }; + const onEnd = () => { + if (finished) { + return; + } + cleanup(); + if (stream === req && length !== undefined && received !== length) { + return next( + createHttpError( + 400, + 'request size did not match content length', + 'request.size.invalid', + { expected: length, length, received }, + ), + ); + } + const buffer = + chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, received); + if (options.verify) { + try { + options.verify(req, res, buffer, charset); + } catch (error) { + return next(toVerifyError(error, buffer)); + } + } + try { + req.body = parse(buffer, charset, req); + } catch (error) { + return next(error); + } + next(); + }; + const aborted = () => + createHttpError(400, 'request aborted', 'request.aborted', { + code: 'ECONNABORTED', + expected: length, + length, + received, + }); + const onError = (error: Error) => + fail( + // Node.js destroys the request with an "aborted" error when the + // client goes away mid-body + req.destroyed && !req.complete + ? aborted() + : createHttpError(400, error.message, 'stream.error', {}, Error), + ); + const onClose = () => { + if (!req.complete) { + fail(aborted()); + } + }; + + stream.on('data', onData); + stream.on('end', onEnd); + stream.on('error', onError); + req.on('close', onClose); + }; + // The adapter recognizes the default parsers by name (see + // "registerParserMiddleware()") + Object.defineProperty(reader, 'name', { value: name }); + return reader; +} + +function noop() {} + +// As with body-parser: a status the error carries wins over the 403 +function toVerifyError(error: unknown, buffer: Buffer) { + const failure = + error instanceof Error + ? error + : new Error(String(error ?? 'verify failed')); + const errorStatus = + (failure as any).status || (failure as any).statusCode || 403; + const status = errorStatus >= 400 && errorStatus < 600 ? errorStatus : 403; + return Object.assign(failure, { + status, + statusCode: status, + expose: status < 500, + type: (failure as any).type || 'entity.verify.failed', + body: buffer, + }); +} + +/** + * Reads off the rest of the request, so that the connection can be reused, + * then calls `callback` once, also when the client goes away. + */ +function dump(req: IncomingMessage, callback: () => void) { + // Ended, or destroyed (a client abort, whose "close" has already fired) + if (req.readableEnded || req.destroyed) { + return callback(); + } + let called = false; + const done = () => { + if (!called) { + called = true; + callback(); + } + }; + req.on('end', done); + req.on('error', done); + req.on('close', done); + req.resume(); +} + +function getPoisoningActions(options?: NodePrototypePoisoningOptions) { + return { + protoAction: options?.onProtoPoisoning ?? 'error', + constructorAction: options?.onConstructorPoisoning ?? 'error', + }; +} + +function toParseError(error: Error, text: string) { + return Object.assign(error, { + status: 400, + statusCode: 400, + expose: true, + type: 'entity.parse.failed', + body: text, + }); +} + +function decode(buffer: Buffer, charset: string): string { + switch (charset) { + case 'utf-8': + case 'utf8': { + const text = buffer.toString('utf8'); + // Without the byte order mark, as body-parser (and TextDecoder) do + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + } + case 'us-ascii': + case 'ascii': + case 'latin1': + case 'iso-8859-1': + return buffer.toString('latin1'); + case 'utf-16': + case 'utf16': + return new TextDecoder(detectUtf16Encoding(buffer)).decode(buffer); + default: { + let decoder: TextDecoder; + try { + decoder = new TextDecoder(charset); + } catch { + throw createHttpError( + 415, + `unsupported charset "${charset.toUpperCase()}"`, + 'charset.unsupported', + { charset }, + ); + } + return decoder.encoding === 'windows-1252' + ? decodeWindows1252(buffer) + : decoder.decode(buffer); + } + } +} + +/** + * Byte order of a "utf-16" body, as iconv-lite (body-parser) picks it: from + * the byte order mark, or else from the first 100 characters, counting the + * ASCII ones as either order would read them; little-endian when undecided. + */ +function detectUtf16Encoding(buffer: Buffer): 'utf-16le' | 'utf-16be' { + if (buffer.length >= 2) { + if (buffer[0] === 0xff && buffer[1] === 0xfe) { + return 'utf-16le'; + } + if (buffer[0] === 0xfe && buffer[1] === 0xff) { + return 'utf-16be'; + } + } + let asciiLE = 0; + let asciiBE = 0; + const end = Math.min(buffer.length - 1, 200); + for (let i = 0; i < end; i += 2) { + if (buffer[i] === 0 && buffer[i + 1] !== 0) { + asciiBE++; + } + if (buffer[i] !== 0 && buffer[i + 1] === 0) { + asciiLE++; + } + } + return asciiBE > asciiLE ? 'utf-16be' : 'utf-16le'; +} + +// Characters of bytes 0x80-0x9F in windows-1252 (the rest match latin1) +const WINDOWS_1252_HIGH = + '€\u0081‚ƒ„…†‡ˆ‰Š‹Œ\u008dŽ\u008f\u0090‘’“”•–—˜™š›œ\u009džŸ'; +const C1_CONTROLS = /[\x80-\x9f]/g; + +// Some Node.js 22 and 24 releases (e.g. 22.15, 24.4) decode windows-1252, and +// the labels mapped to it such as "cp1252", as ISO-8859-1 in TextDecoder +function decodeWindows1252(buffer: Buffer): string { + return buffer + .toString('latin1') + .replace(C1_CONTROLS, char => + WINDOWS_1252_HIGH.charAt(char.charCodeAt(0) - 0x80), + ); +} + +function firstNonWhitespaceChar(text: string): string | undefined { + for (let i = 0; i < text.length; i++) { + const char = text.charCodeAt(i); + // space, \t, \n, \r and the UTF-8 byte order mark + if ( + char !== 32 && + char !== 9 && + char !== 10 && + char !== 13 && + char !== 0xfeff + ) { + return text[i]; + } + } + return undefined; +} + +function toReaderOptions( + options: NodeBodyParserOptions | undefined, + defaultType: string, + defaultCharset = 'utf-8', + isValidCharset?: (charset: string) => boolean, +): ReaderOptions { + return { + limit: parseLimit(options?.limit), + inflate: options?.inflate !== false, + matchesType: createTypeMatcher(options?.type, defaultType), + verify: options?.verify, + isValidCharset, + defaultCharset, + }; +} + +/** + * Parses `application/json` bodies into `req.body`. Behaves like + * `bodyParser.json()`: strict by default, an empty body gives `{}`, and + * invalid JSON is rejected with a `SyntaxError` carrying `status: 400`. + * + * @publicApi + */ +export function json(options?: NodeJsonParserOptions): Middleware { + const strict = options?.strict !== false; + const reviver = options?.reviver; + const poisoningActions = getPoisoningActions(options); + return createReader( + toReaderOptions(options, 'application/json', 'utf-8', charset => + charset.startsWith('utf-'), + ), + (buffer, charset) => { + const text = decode(buffer, charset); + if (text.length === 0) { + // An empty body is a common client-side mistake + return {}; + } + const first = firstNonWhitespaceChar(text); + if (strict && first !== '{' && first !== '[') { + throw createHttpError( + 400, + first === undefined + ? 'Unexpected end of JSON input' + : `Unexpected token '${first}', "${text.slice(0, 20)}" is not valid JSON`, + 'entity.parse.failed', + { body: text }, + SyntaxError, + ); + } + try { + // JSON.parse(), then a scan for "__proto__" and "constructor" keys + // only when the text contains one (the parser Fastify uses) + return secureJson.parse(text, reviver, poisoningActions); + } catch (error) { + throw toParseError(error, text); + } + }, + 'jsonParser', + ); +} + +// A "__proto__" segment in a key "qs" parses with brackets ("a[__proto__]") +const PROTO_KEY = /(?:^|\[)__proto__(?:$|[[\]])/; + +/** + * Parses `application/x-www-form-urlencoded` bodies into `req.body`, with + * `qs` (nested objects and arrays with `extended: true`), like + * `bodyParser.urlencoded()`. + * + * @publicApi + */ +export function urlencoded(options?: NodeUrlencodedParserOptions): Middleware { + const extended = options?.extended === true; + const poisoningActions = getPoisoningActions(options); + const scanForPoisoning = + extended && + (poisoningActions.protoAction !== 'ignore' || + poisoningActions.constructorAction !== 'ignore'); + let parameterLimit = options?.parameterLimit ?? 1000; + if (isNaN(parameterLimit) || parameterLimit < 1) { + throw new TypeError('option parameterLimit must be a positive number'); + } + if (Number.isFinite(parameterLimit)) { + parameterLimit = parameterLimit | 0; + } + let depth = extended ? (options?.depth ?? 32) : 0; + if (isNaN(depth) || depth < 0) { + throw new TypeError('option depth must be a zero or a positive number'); + } + const defaultCharset = options?.defaultCharset || 'utf-8'; + if (defaultCharset !== 'utf-8' && defaultCharset !== 'iso-8859-1') { + throw new TypeError( + 'option defaultCharset must be either utf-8 or iso-8859-1', + ); + } + if (Number.isFinite(depth)) { + depth = depth | 0; + } + // "qs" silently drops "__proto__" keys; with 'error', reject them as the + // JSON parser does + const decoder = + poisoningActions.protoAction === 'error' + ? ( + value: string, + defaultDecoder: (value: string, ...args: any[]) => string, + charset: string, + type: 'key' | 'value', + ) => { + const decoded = defaultDecoder(value, defaultDecoder, charset); + if ( + type === 'key' && + (extended ? PROTO_KEY.test(decoded) : decoded === '__proto__') + ) { + throw new SyntaxError( + 'Object contains forbidden prototype property', + ); + } + return decoded; + } + : undefined; + return createReader( + toReaderOptions( + options, + 'application/x-www-form-urlencoded', + defaultCharset, + charset => charset === 'utf-8' || charset === 'iso-8859-1', + ), + (buffer, charset) => { + const text = decode(buffer, charset); + let parameterCount = 1; + // As body-parser counts them: indexOf() jumps from one "&" to the next, + // several times faster than a loop over every character + for (let i = text.indexOf('&'); i !== -1; i = text.indexOf('&', i + 1)) { + if (++parameterCount > parameterLimit) { + throw createHttpError( + 413, + 'too many parameters', + 'parameters.too.many', + ); + } + } + try { + const body = qs.parse(text, { + allowPrototypes: true, + arrayLimit: extended ? Math.max(100, parameterCount) : parameterCount, + depth, + charset: charset === 'iso-8859-1' ? 'iso-8859-1' : 'utf-8', + charsetSentinel: options?.charsetSentinel, + interpretNumericEntities: options?.interpretNumericEntities, + decoder, + parameterLimit, + strictDepth: true, + }); + // "qs" drops "__proto__" keys, but nests "constructor[prototype][x]" + // like the JSON payload the JSON parser rejects + return scanForPoisoning + ? secureJson.scan(body, poisoningActions) + : body; + } catch (error) { + if (error instanceof SyntaxError) { + throw toParseError(error, text); + } + if (error instanceof RangeError) { + throw createHttpError( + 400, + 'The input exceeded the depth', + 'querystring.parse.rangeError', + ); + } + throw error; + } + }, + 'urlencodedParser', + ); +} + +/** + * Reads `text/plain` bodies into `req.body` as a string, like + * `bodyParser.text()`. + * + * @publicApi + */ +export function text(options?: NodeTextParserOptions): Middleware { + return createReader( + toReaderOptions(options, 'text/plain', options?.defaultCharset ?? 'utf-8'), + (buffer, charset) => decode(buffer, charset), + 'textParser', + ); +} + +/** + * Reads `application/octet-stream` bodies into `req.body` as a `Buffer`, like + * `bodyParser.raw()`. + * + * @publicApi + */ +export function raw(options?: NodeBodyParserOptions): Middleware { + return createReader( + toReaderOptions(options, 'application/octet-stream'), + buffer => buffer, + 'rawParser', + ); +} diff --git a/runtime/node/nest/platform-node/router/node-router.ts b/runtime/node/nest/platform-node/router/node-router.ts new file mode 100644 index 0000000..a661471 --- /dev/null +++ b/runtime/node/nest/platform-node/router/node-router.ts @@ -0,0 +1,499 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { match } from 'path-to-regexp'; +import { NodeRequest, NodeResponse } from '../adapters/node-request.js'; +import { + PARAM_TOO_LONG, + type RouteEntry, + type RouteHandler, + type RouteMatch, + type RouteTableOptions, + RouteTable, +} from './route-table.js'; +import { getRawPrefixEnd, getRoutingPath, loosen } from './utils.js'; + +type NextFunction = (err?: any) => void; +type PathMatcher = (path: string) => false | { path: string; params: any }; + +interface MiddlewareLayer { + table?: undefined; + /** `null` matches every path. */ + matcher: PathMatcher | null; + /** Strip the matched path from `req.url` (a `use(path, ...)` mount). */ + isMount: boolean; + /** Upper-case method, or `undefined` for every method. */ + method: string | undefined; + handler: Function; + isErrorHandler: boolean; +} + +interface RouterLayer { + table: RouteTable; +} + +type Layer = MiddlewareLayer | RouterLayer; + +/** + * Callback invoked when a request falls off the end of the stack. + */ +export type FinalHandler = ( + err: any, + req: NodeRequest, + res: NodeResponse, +) => void; + +function decodeParam(value: string) { + return decodeURIComponent(value); +} + +function invoke( + handler: Function, + req: any, + res: any, + next: NextFunction, + err?: any, +) { + try { + const result = + err === undefined + ? handler(req, res, next) + : handler(err, req, res, next); + if (result !== undefined && typeof result?.then === 'function') { + // As in Express 5: a rejected promise is forwarded to the error handlers + result.then(undefined, (reason: unknown) => + next(reason || new Error('Rejected promise')), + ); + } + } catch (error) { + next(error || new Error('Thrown falsy value')); + } +} + +export interface NodeRouterOptions { + /** + * Longest (decoded) route parameter a route matches; `100` by default, as + * with Fastify. A request whose parameter is longer falls through to + * another matching route (a sibling wildcard, for instance), if any, or + * gets a 414. Wildcards are not limited. + */ + maxParamLength?: number; +} + +/** + * Set on a request that no route matched only because a route parameter was + * longer than `maxParamLength`; turned into a 414 where the request would + * otherwise get a 404 (see {@link createUriTooLongError}). + * + * @internal + */ +export const PARAM_TOO_LONG_KEY = Symbol('nestjs.node.paramTooLong'); + +/** + * The 414 for a request flagged with {@link PARAM_TOO_LONG_KEY}, shaped like + * an http-errors error, so that Nest's exception filter answers it with its + * status and message. + * + * @internal + */ +export function createUriTooLongError() { + return Object.assign(new Error('Route parameter too long'), { + status: 414, + statusCode: 414, + expose: true, + }); +} + +// Like an Express application, a router is itself a request listener: its +// constructor returns a function, whose call signature this interface adds. +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface NodeRouter { + (req: IncomingMessage, res: ServerResponse, done?: NextFunction): void; +} + +/** + * Minimal, Express-compatible request pipeline on top of the Node.js HTTP + * server: an ordered stack of middleware (`use()`, connect-style + * `(req, res, next)` and error-handling `(err, req, res, next)` functions) + * and routes. Contiguous route registrations are grouped into a + * {@link RouteTable}, so a request walks the (short) middleware list and does + * a single radix-tree lookup, however many routes the application has. + * + * @publicApi + */ +export class NodeRouter { + declare private stack: Layer[]; + declare private finalHandler: FinalHandler; + declare private routeTableOptions: RouteTableOptions; + + constructor(finalHandler: FinalHandler, options: NodeRouterOptions = {}) { + const { maxParamLength } = options; + if ( + maxParamLength !== undefined && + (typeof maxParamLength !== 'number' || !(maxParamLength >= 1)) + ) { + throw new TypeError( + `Invalid maxParamLength: ${String(maxParamLength)} (expected a number greater than 0)`, + ); + } + const router = (( + req: IncomingMessage, + res: ServerResponse, + done?: NextFunction, + ) => router.handle(req, res, done)) as NodeRouter; + Object.setPrototypeOf(router, new.target.prototype); + router.stack = []; + router.finalHandler = finalHandler; + router.routeTableOptions = { maxParamLength }; + return router; + } + + /** + * Request listener for `http.createServer()`. `done` is called instead of + * the final handler when the request falls off the stack, which allows + * mounting the router inside another framework. + */ + public handle( + req: IncomingMessage, + res: ServerResponse, + done?: NextFunction, + ) { + if (!(req instanceof NodeRequest)) { + Object.setPrototypeOf(req, NodeRequest.prototype); + } + if (!(res instanceof NodeResponse)) { + Object.setPrototypeOf(res, NodeResponse.prototype); + } + this.dispatch(req as NodeRequest, res as NodeResponse, done); + } + + /** + * Runs the error-handling middleware (then the final handler) for an error + * raised outside of the middleware chain, such as by `res.sendFile()` once + * the route handler returned. Express passes those to the `next()` it + * stores on every request. + */ + public handleError(err: unknown, req: NodeRequest, res: NodeResponse) { + this.dispatch(req, res, undefined, err); + } + + public get(...args: any[]) { + return this.addRoute('GET', args); + } + + public post(...args: any[]) { + return this.addRoute('POST', args); + } + + public put(...args: any[]) { + return this.addRoute('PUT', args); + } + + public delete(...args: any[]) { + return this.addRoute('DELETE', args); + } + + public patch(...args: any[]) { + return this.addRoute('PATCH', args); + } + + public options(...args: any[]) { + return this.addRoute('OPTIONS', args); + } + + public head(...args: any[]) { + return this.addRoute('HEAD', args); + } + + public all(...args: any[]) { + return this.addRoute(null, args); + } + + public search(...args: any[]) { + return this.addRoute('SEARCH', args); + } + + public query(...args: any[]) { + return this.addRoute('QUERY', args); + } + + public propfind(...args: any[]) { + return this.addRoute('PROPFIND', args); + } + + public proppatch(...args: any[]) { + return this.addRoute('PROPPATCH', args); + } + + public mkcol(...args: any[]) { + return this.addRoute('MKCOL', args); + } + + public copy(...args: any[]) { + return this.addRoute('COPY', args); + } + + public move(...args: any[]) { + return this.addRoute('MOVE', args); + } + + public lock(...args: any[]) { + return this.addRoute('LOCK', args); + } + + public unlock(...args: any[]) { + return this.addRoute('UNLOCK', args); + } + + public use(...args: any[]) { + const [path, handlers] = + typeof args[0] === 'string' ? [args[0], args.slice(1)] : ['/', args]; + const isRoot = path === '/' || path === ''; + const matcher = isRoot + ? null + : (match(loosen(path), { + decode: decodeParam, + end: false, + // Case-sensitive, as routes are (see RouteTable) + sensitive: true, + }) as PathMatcher); + + for (const handler of handlers.flat()) { + if (typeof handler !== 'function') { + throw new TypeError( + `NodeRouter.use() requires a middleware function, received ${typeof handler}`, + ); + } + this.stack.push({ + matcher, + isMount: !isRoot, + method: undefined, + handler, + isErrorHandler: handler.length === 4, + }); + } + return this; + } + + /** + * Registers a route. `method` is upper-case, or `null` for every method. + */ + public route(method: string | null, path: string, handler: RouteHandler) { + let last = this.stack[this.stack.length - 1]; + if (!last?.table) { + last = { table: new RouteTable(this.routeTableOptions) }; + this.stack.push(last); + } + last.table.add(method, path, handler); + return this; + } + + /** + * Registers a middleware that runs only for requests whose whole path + * matches `path` (like a route, unlike a `use()` mount), and, when given, + * whose method is `method`. + */ + public useExact(method: string | undefined, path: string, handler: Function) { + this.stack.push({ + matcher: match(loosen(path), { + decode: decodeParam, + end: true, + sensitive: true, + }) as PathMatcher, + isMount: false, + method, + handler, + isErrorHandler: false, + }); + return this; + } + + // `get(handler)`, `get(path, handler)` or `get(path, ...handlers)`; several + // handlers run in a row, passing control through `next()` + private addRoute(method: string | null, args: any[]) { + const [path, handlers] = + typeof args[0] === 'string' ? [args[0], args.slice(1)] : ['/', args]; + for (const handler of handlers.flat()) { + this.route(method, path, handler); + } + return this; + } + + /** + * Whether a middleware with the given function name is already registered. + */ + public hasMiddleware(name: string) { + return this.stack.some( + layer => !layer.table && (layer.handler as Function).name === name, + ); + } + + private dispatch( + req: NodeRequest, + res: NodeResponse, + done: NextFunction | undefined, + initialError?: unknown, + ) { + const stack = this.stack; + let index = 0; + let routeMatch: RouteMatch | undefined; + let routeIndex = 0; + // The other routes matching the path, once every handler of the matched + // route passed (see RouteTable.lookupRest) + let restEntries: RouteEntry[] | undefined; + let removed = ''; + let slashAdded = false; + let cachedUrl = ''; + let cachedPath = ''; + + if (req.originalUrl === undefined) { + req.originalUrl = req.url!; + } + if (req.baseUrl === undefined) { + req.baseUrl = ''; + } + if (req.params === undefined) { + req.params = {}; + } + const parentBaseUrl = req.baseUrl; + + const next: NextFunction = (err?: any) => { + // As with Express, a falsy value is no error: callbacks commonly + // pass on their "null" error ("cb(err => next(err))") + if (!err || err === 'route' || err === 'router') { + err = undefined; + } + if (removed) { + if (slashAdded) { + req.url = req.url!.slice(1); + slashAdded = false; + } + req.url = removed + req.url; + req.baseUrl = parentBaseUrl; + removed = ''; + } + // Continue with the next handler registered for the matched route + if (routeMatch !== undefined) { + if (err === undefined) { + if (routeIndex < routeMatch.handlers.length) { + return invoke(routeMatch.handlers[routeIndex++], req, res, next); + } + // Every handler passed: on to the other routes matching the path, + // as when a version- or host-filtered handler does not apply. The + // route's table is the layer just before "index". + restEntries ??= stack[index - 1].table!.lookupRest( + req.method!, + cachedPath, + routeMatch.seqs, + ); + const entry = restEntries.shift(); + if (entry !== undefined) { + req.params = entry.params; + return invoke(entry.handler, req, res, next); + } + } + routeMatch = undefined; + restEntries = undefined; + } + + while (index < stack.length) { + const layer = stack[index++]; + const url = req.url!; + // Read for every layer, as in Express, so that middleware such as + // "method-override" can change the method routes are matched with + const method = req.method!; + if (url !== cachedUrl) { + cachedUrl = url; + try { + cachedPath = getRoutingPath(url); + } catch (error) { + // A malformed URL goes to the error handlers (400) + cachedPath = ''; + err ??= error; + } + } + + if (layer.table) { + if (err !== undefined) { + continue; + } + let found; + try { + found = layer.table.lookup(method, cachedPath); + } catch (error) { + err = error; + continue; + } + if (found === null) { + continue; + } + if (found === PARAM_TOO_LONG) { + (req as any)[PARAM_TOO_LONG_KEY] = true; + continue; + } + req.params = found.params; + routeMatch = found; + routeIndex = 1; + return invoke(found.handlers[0], req, res, next); + } + + if ((err !== undefined) !== layer.isErrorHandler) { + continue; + } + if ( + layer.method !== undefined && + layer.method !== method && + !(method === 'HEAD' && layer.method === 'GET') + ) { + continue; + } + if (layer.matcher !== null) { + let result; + try { + result = layer.matcher(cachedPath); + } catch (error) { + err = error; + continue; + } + if (!result) { + continue; + } + req.params = result.params; + if (layer.isMount) { + // Mounted middleware (e.g. "serve-static") sees the URL relative + // to its mount path, as in Express. The mount path was matched + // against the decoded routing path, so find where it ends in the + // raw URL. + const mountPath = result.path.endsWith('/') + ? result.path.slice(0, -1) + : result.path; + const end = getRawPrefixEnd(url, mountPath); + removed = url.slice(0, end); + req.url = url.slice(end); + if (req.url[0] !== '/') { + req.url = '/' + req.url; + slashAdded = true; + } + req.baseUrl = parentBaseUrl + mountPath; + } + } + return invoke(layer.handler, req, res, next, err); + } + + if (done) { + return done(err); + } + this.finalHandler( + err === undefined && (req as any)[PARAM_TOO_LONG_KEY] + ? createUriTooLongError() + : err, + req, + res, + ); + }; + + next(initialError); + } +} + +// Routers are functions (see the constructor), so they keep `call()`, `apply()` +// and `bind()` +Object.setPrototypeOf(NodeRouter.prototype, Function.prototype); diff --git a/runtime/node/nest/platform-node/router/route-table.ts b/runtime/node/nest/platform-node/router/route-table.ts new file mode 100644 index 0000000..aaead78 --- /dev/null +++ b/runtime/node/nest/platform-node/router/route-table.ts @@ -0,0 +1,527 @@ +import FindMyWay from 'find-my-way'; +import { match, parse, type Token } from 'path-to-regexp'; +import { loosen } from './utils.js'; + +export type RouteHandler = ( + req: any, + res: any, + next: (err?: any) => void, +) => any; + +export interface RouteMatch { + handlers: RouteHandler[]; + params: Record; + /** Registration order of each handler, see {@link RouteTable.lookupRest}. */ + seqs: number[]; +} + +export interface RouteEntry { + handler: RouteHandler; + params: Record; +} + +interface RouteRecord { + handlers: RouteHandler[]; + seqs: number[]; + /** + * Parameter names of the route of each handler, in path order. Routes are + * registered in find-my-way under positional names ("p0", "p1", ...), so + * that routes differing only by their parameter names share one record and + * pass control to each other through `next()`, as in Express. + */ + paramNames: string[][]; + /** Whether the last parameter is a trailing wildcard (find-my-way's `*`). */ + hasWildcard: boolean; + /** Number of "/" in the route path before the wildcard. */ + wildcardSlashes: number; +} + +interface FoundRoute { + handlers: RouteHandler[]; + seqs: number[]; + params: Record; +} + +interface FindMyWayVariant { + path: string; + paramNames: string[]; + hasWildcard: boolean; + wildcardSlashes: number; +} + +// Route parameters live in an object without a prototype, as with +// find-my-way, which is cheaper to create than `Object.create(null)` +const ParamsObject = function () {} as unknown as new () => Record; +ParamsObject.prototype = Object.create(null); + +// Positional parameter names, see `RouteRecord` +const POSITIONAL_NAMES = Array.from({ length: 32 }, (_, i) => `p${i}`); + +function positionalName(index: number) { + return POSITIONAL_NAMES[index] ?? `p${index}`; +} + +type Matcher = (path: string) => false | { params: Record }; + +interface FallbackRoute { + method: string | null; + matcher: Matcher; + handler: RouteHandler; + seq: number; +} + +/** Every route, for {@link RouteTable.lookupRest}. */ +interface RegisteredRoute { + method: string | null; + path: string; + /** Compiled on first use. */ + matcher?: Matcher; + handler: RouteHandler; + seq: number; +} + +// Paths are case-sensitive everywhere, as with find-my-way's default (and +// Fastify): cheaper than lowercasing every request path, and the routes, the +// routes matched with path-to-regexp and the middleware all agree +function compileMatcher(path: string): Matcher { + const matcher = match(loosen(path), { + decode: decodeParam, + end: true, + sensitive: true, + }); + return (pathname: string) => matcher(pathname) as any; +} + +function acceptsMethod(routeMethod: string | null, method: string) { + return ( + routeMethod === null || + routeMethod === method || + (method === 'HEAD' && routeMethod === 'GET') + ); +} + +/** + * Default longest (decoded) route parameter a route matches, as with Fastify + * (see {@link RouteTableOptions.maxParamLength}). + */ +export const MAX_PARAM_LENGTH = 100; + +export interface RouteTableOptions { + /** + * Longest (decoded) route parameter a route matches. A request whose + * parameter is longer falls through to another matching route (a sibling + * wildcard, for instance), if any; when none matches, `lookup()` returns + * {@link PARAM_TOO_LONG} (Fastify answers those with a 414). Wildcards are + * not limited. Defaults to {@link MAX_PARAM_LENGTH}. + */ + maxParamLength?: number; +} + +/** + * What `lookup()` returns when no route matched, but one would have if a + * route parameter were not longer than the `maxParamLength` option. + */ +export const PARAM_TOO_LONG: RouteMatch = Object.freeze({ + handlers: [], + params: {}, + seqs: [], +}) as unknown as RouteMatch; + +// What `find()` returns in that case (find-my-way's onMaxParamLength result) +const TOO_LONG = {} as FoundRoute; + +function noop() {} + +const UNSAFE_TEXT = /[*?(]/; +// find-my-way reads a parameter name up to the next "/", "-" or "." +const PARAM_NAME_END = /^[/.-]/; + +function decodeParam(value: string) { + return decodeURIComponent(value); +} + +function decodeSegment(value: string) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +// The limit find-my-way applies to the routes it matches, for the routes +// matched with path-to-regexp (wildcards are arrays there) +function exceedsMaxParamLength(params: Record, limit: number) { + for (const key in params) { + const value = params[key]; + if (typeof value === 'string' && value.length > limit) { + return true; + } + } + return false; +} + +/** + * Expands the optional groups of a path-to-regexp token list into every + * concrete variant (`/u{/:id}` gives `/u` and `/u/:id`). + */ +// Each optional group doubles the number of variants; past this many groups, +// match the route with path-to-regexp instead of expanding it +const MAX_EXPANDED_GROUPS = 6; + +function countGroups(tokens: Token[]): number { + let count = 0; + for (const token of tokens) { + if (token.type === 'group') { + count += 1 + countGroups(token.tokens); + } + } + return count; +} + +function expandGroups(tokens: Token[]): Token[][] { + let variants: Token[][] = [[]]; + for (const token of tokens) { + if (token.type === 'group') { + const groupVariants = expandGroups(token.tokens); + const next: Token[][] = []; + // Variants with the group come first: when two variants match the same + // paths ("/a{/:x}{/:y}" gives "/a/:x" and "/a/:y"), the first one names + // the parameters, and path-to-regexp fills the earlier group first + for (const variant of variants) { + for (const groupVariant of groupVariants) { + next.push([...variant, ...groupVariant]); + } + next.push(variant); + } + variants = next; + } else { + variants = variants.map(variant => [...variant, token]); + } + } + return variants; +} + +/** + * Translates one group-free variant to find-my-way's syntax. Returns + * `undefined` when find-my-way cannot express it, in which case the route is + * matched with path-to-regexp instead. + */ +function toFindMyWayPath(tokens: Token[]): FindMyWayVariant | undefined { + let path = ''; + const paramNames: string[] = []; + let hasWildcard = false; + let wildcardSlashes = 0; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + switch (token.type) { + case 'text': + if (UNSAFE_TEXT.test(token.value)) { + return undefined; + } + path += token.value.replaceAll(':', '::'); + break; + case 'param': { + // Anything else right after a parameter (another parameter, a + // wildcard, "@", "~", ...) would end up in find-my-way's name for it + const next = tokens[i + 1]; + if ( + next !== undefined && + (next.type !== 'text' || !PARAM_NAME_END.test(next.value)) + ) { + return undefined; + } + path += `:${positionalName(paramNames.length)}`; + paramNames.push(token.name); + break; + } + case 'wildcard': + // find-my-way only supports a trailing wildcard + if (i !== tokens.length - 1) { + return undefined; + } + wildcardSlashes = path.split('/').length - 1; + path += '*'; + paramNames.push(token.name); + hasWildcard = true; + break; + default: + return undefined; + } + } + // A variant ending with "/" ("/admin/" of "/admin/{*path}") must not match + // "/admin", as with Express, but find-my-way's ignoreTrailingSlash would + if (path.length > 1 && path.endsWith('/')) { + return undefined; + } + return { path: path || '/', paramNames, hasWildcard, wildcardSlashes }; +} + +/** + * Route table of one contiguous block of routes registered on the + * `NodeRouter`. Paths use the path-to-regexp syntax (the same as Express), + * and are matched through a find-my-way radix tree, so the lookup cost does + * not grow with the number of routes. The few paths find-my-way cannot + * express fall back to a path-to-regexp matcher. + * + * Several handlers may be registered for the same method and path (as Nest + * does for host- and version-filtered routes); they run in registration order + * and pass control to each other through `next()`. + */ +export class RouteTable { + private readonly maxParamLength: number; + private readonly router: FindMyWay.Instance; + private readonly allRouter: FindMyWay.Instance; + private readonly records = new Map(); + private readonly fallbackRoutes: FallbackRoute[] = []; + private readonly routes: RegisteredRoute[] = []; + private hasAllRoutes = false; + private seq = 0; + + constructor(options: RouteTableOptions = {}) { + this.maxParamLength = options.maxParamLength ?? MAX_PARAM_LENGTH; + const routerOptions = { + ignoreTrailingSlash: true, + maxParamLength: this.maxParamLength, + // Makes find() return a result whose store is null when nothing matched + // because of a parameter's length (see TOO_LONG) + onMaxParamLength: noop, + }; + this.router = FindMyWay(routerOptions); + this.allRouter = FindMyWay(routerOptions); + } + + /** + * @param method Upper-case HTTP method, or `null` for every method. + */ + public add(method: string | null, path: string, handler: RouteHandler) { + const seq = this.seq++; + this.routes.push({ method, path, handler, seq }); + const { tokens } = parse(path); + const variants = + countGroups(tokens) > MAX_EXPANDED_GROUPS + ? [undefined] + : expandGroups(tokens).map(toFindMyWayPath); + + if (variants.some(variant => variant === undefined)) { + this.fallbackRoutes.push({ + method, + matcher: compileMatcher(path), + handler, + seq, + }); + return; + } + // Several variants may translate to the same path ("/a{/}" for instance); + // the first one wins + const uniqueVariants = new Map(); + for (const variant of variants as FindMyWayVariant[]) { + if (!uniqueVariants.has(variant.path)) { + uniqueVariants.set(variant.path, variant); + } + } + for (const { + path: routePath, + paramNames, + hasWildcard, + wildcardSlashes, + } of uniqueVariants.values()) { + const router = method === null ? this.allRouter : this.router; + const routerMethod = method ?? 'GET'; + const key = `${method} ${routePath}`; + + const record = this.records.get(key); + if (record) { + record.handlers.push(handler); + record.seqs.push(seq); + record.paramNames.push(paramNames); + continue; + } + const newRecord: RouteRecord = { + handlers: [handler], + seqs: [seq], + paramNames: [paramNames], + hasWildcard, + wildcardSlashes, + }; + try { + router.on(routerMethod as any, routePath, () => {}, newRecord); + } catch { + // Same shape as an existing route under different parameter names + this.fallbackRoutes.push({ + method, + matcher: compileMatcher(path), + handler, + seq, + }); + return; + } + this.records.set(key, newRecord); + } + if (method === null) { + this.hasAllRoutes = true; + } + } + + public lookup(method: string, path: string): RouteMatch | null { + let exceeded = false; + let found = this.find(this.router, method, path); + if (found === TOO_LONG) { + exceeded = true; + found = null; + } + if (found === null && method === 'HEAD') { + found = this.find(this.router, 'GET', path); + if (found === TOO_LONG) { + exceeded = true; + found = null; + } + } + if (this.hasAllRoutes) { + const foundAll = this.find(this.allRouter, 'GET', path); + if (foundAll === TOO_LONG) { + exceeded = true; + } else if (foundAll !== null) { + found = found === null ? foundAll : this.merge(found, foundAll); + } + } + if (found !== null) { + return found; + } + const fallback = + this.fallbackRoutes.length > 0 ? this.lookupFallback(method, path) : null; + if (fallback !== null) { + return fallback; + } + return exceeded ? PARAM_TOO_LONG : null; + } + + private find( + router: FindMyWay.Instance, + method: string, + path: string, + ): FoundRoute | null { + const result = router.find(method as any, path); + if (result === null) { + return null; + } + if (result.store === null) { + return TOO_LONG; + } + const record = result.store as RouteRecord; + const values = result.params as Record; + for (const key in values) { + // path-to-regexp (the middleware matcher, and Express) requires at + // least one character per parameter; find-my-way also accepts an empty + // one ("/users//profile"). Treat that as no match, or the route could + // run without the middleware guarding it. + if (key !== '*' && values[key] === '') { + return null; + } + } + let wildcard: string[] | undefined; + if (record.hasWildcard) { + // Split before decoding, as path-to-regexp does, so that an encoded "/" + // ("%2F") stays within its segment + let start = 0; + for (let i = 0; i < record.wildcardSlashes; i++) { + const slash = path.indexOf('/', start); + if (slash === -1) { + return null; + } + start = slash + 1; + } + let end = path.length; + if (end > start && path.charCodeAt(end - 1) === 47 /* / */) { + end -= 1; + } + // path-to-regexp wildcards match one or more characters + if (start === 0 || end <= start) { + return null; + } + wildcard = path.slice(start, end).split('/').map(decodeSegment); + } + // Every handler sees the parameters under the names its route uses + const params = new ParamsObject(); + for (const names of record.paramNames) { + const last = names.length - 1; + for (let i = 0; i <= last; i++) { + params[names[i]] = + wildcard !== undefined && i === last + ? wildcard + : values[positionalName(i)]; + } + } + return { handlers: record.handlers, seqs: record.seqs, params }; + } + + private merge(left: FoundRoute, right: FoundRoute): FoundRoute { + const entries = [ + ...left.handlers.map((handler, i) => ({ handler, seq: left.seqs[i] })), + ...right.handlers.map((handler, i) => ({ handler, seq: right.seqs[i] })), + ].sort((a, b) => a.seq - b.seq); + return { + handlers: entries.map(entry => entry.handler), + seqs: entries.map(entry => entry.seq), + params: { ...right.params, ...left.params }, + }; + } + + /** + * The first route matched with path-to-regexp (see `lookupRest()` for the + * others). + */ + private lookupFallback(method: string, path: string): RouteMatch | null { + let exceeded = false; + for (const route of this.fallbackRoutes) { + if (!acceptsMethod(route.method, method)) { + continue; + } + const result = route.matcher(path); + if (!result) { + continue; + } + if (exceedsMaxParamLength(result.params, this.maxParamLength)) { + exceeded = true; + continue; + } + return { + handlers: [route.handler], + seqs: [route.seq], + params: result.params, + }; + } + return exceeded ? PARAM_TOO_LONG : null; + } + + /** + * Every other route matching `path`, in registration order, each with its + * own parameters: where control goes once the handlers `lookup()` returned + * have all called `next()`, as when a host- or version-filtered handler + * does not apply. Express would try the same routes. Only runs then, so it + * compiles the path-to-regexp matchers lazily. + * + * @param exclude Registration order of the handlers that already ran. + */ + public lookupRest( + method: string, + path: string, + exclude: number[], + ): RouteEntry[] { + const entries: RouteEntry[] = []; + for (const route of this.routes) { + if (exclude.includes(route.seq) || !acceptsMethod(route.method, method)) { + continue; + } + route.matcher ??= compileMatcher(route.path); + const result = route.matcher(path); + if ( + !result || + exceedsMaxParamLength(result.params, this.maxParamLength) + ) { + continue; + } + entries.push({ handler: route.handler, params: result.params }); + } + return entries; + } +} diff --git a/runtime/node/nest/platform-node/router/utils.ts b/runtime/node/nest/platform-node/router/utils.ts new file mode 100644 index 0000000..4a0aca5 --- /dev/null +++ b/runtime/node/nest/platform-node/router/utils.ts @@ -0,0 +1,117 @@ +import urlSanitizer from 'find-my-way/lib/url-sanitizer.js'; + +/** + * Drops the trailing slash of a pattern, so that "/users/" matches "/users" + * as well (as the Express router does). + */ +export function loosen(path: string) { + return path === '/' ? path : path.replace(/\/+$/, ''); +} + +/** + * Returns the path of an absolute-form request target + * ("GET http://host/path HTTP/1.1"), `null` when it is malformed, and any other + * target unchanged. Mirrors find-my-way, so that middleware and routes always + * see the same path. + */ +function getPathFromAbsoluteUrl(url: string): string | null { + const schemeEnd = url.indexOf('://'); + if (schemeEnd === -1) { + return url; + } + const scheme = url.slice(0, schemeEnd).toLowerCase(); + if (scheme !== 'http' && scheme !== 'https') { + return url; + } + const authorityStart = schemeEnd + 3; + let authorityEnd = url.length; + const pathStart = url.indexOf('/', authorityStart); + if (pathStart !== -1) { + authorityEnd = pathStart; + } + const queryStart = url.indexOf('?', authorityStart); + if (queryStart !== -1 && queryStart < authorityEnd) { + authorityEnd = queryStart; + } + // Fragments are not valid in a request target + if ( + url.indexOf('#', authorityStart) !== -1 || + authorityEnd === authorityStart + ) { + return null; + } + if (!URL.canParse(url) || new URL(url).host.length === 0) { + return null; + } + if (authorityEnd === url.length) { + return '/'; + } + if (authorityEnd === queryStart) { + return '/' + url.slice(queryStart); + } + return url.slice(pathStart); +} + +/** + * The path a request is routed by: the normalization find-my-way applies + * before looking up a route (an absolute-form target resolved to its path, + * percent-encoding decoded except for reserved characters such as "%2F", the + * query string and any fragment dropped). Middleware is matched against this + * path as well; matching the raw URL instead would let "/%61dmin" reach the + * "/admin" route while skipping the middleware guarding it. + * + * Throws a `URIError` for a malformed target or percent-encoding. + */ +export function getRoutingPath(url: string): string { + // A fragment is not valid in a request target. Cutting it off here while + // other code sees the raw URL would let "GET /api#/" match differently in + // different places, so reject it (400). + if (url.indexOf('#') !== -1) { + throw new URIError(`Invalid request target: "${url}"`); + } + if (url.charCodeAt(0) === 47 /* / */ && url.indexOf('%') === -1) { + // Nothing to decode + const index = url.indexOf('?'); + return index === -1 ? url : url.slice(0, index); + } + let path: string | null = url; + if (url.charCodeAt(0) !== 47) { + path = getPathFromAbsoluteUrl(url); + if (path === null) { + throw new URIError(`Invalid request target: "${url}"`); + } + } + return urlSanitizer.safeDecodeURI(path, false).path; +} + +/** + * Returns the index in the raw request URL where `routingPrefix`, a prefix of + * its routing path (see `getRoutingPath()`), ends. Decoding never turns + * "%2F" into "/", so the raw URL has the same "/"-separated segments. + */ +export function getRawPrefixEnd(url: string, routingPrefix: string): number { + let start = 0; + if (url.charCodeAt(0) !== 47 /* / */) { + // Absolute-form target: skip "scheme://authority" + const schemeEnd = url.indexOf('://'); + const pathStart = schemeEnd === -1 ? -1 : url.indexOf('/', schemeEnd + 3); + start = pathStart === -1 ? url.length : pathStart; + } + let slashes = 0; + for (let i = 0; i < routingPrefix.length; i++) { + if (routingPrefix.charCodeAt(i) === 47) { + slashes++; + } + } + let i = start; + for (; i < url.length; i++) { + const char = url.charCodeAt(i); + if (char === 63 /* ? */ || char === 35 /* # */) { + break; + } + if (char === 47 && slashes-- === 0) { + break; + } + } + return i; +} From f45c99a398ba5f36dd5b94337dc821290cf8a48e Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Mon, 19 Oct 2026 01:51:16 +0200 Subject: [PATCH 2/8] Narrow body-parser's catch variable to Error for whole-program strict (UPSTREAM-DIFF) --- runtime/node/nest/platform-node/UPSTREAM-DIFF.md | 1 + runtime/node/nest/platform-node/body-parser/body-parser.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/runtime/node/nest/platform-node/UPSTREAM-DIFF.md b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md index 617e990..dfc4faf 100644 --- a/runtime/node/nest/platform-node/UPSTREAM-DIFF.md +++ b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md @@ -7,3 +7,4 @@ each edit is listed here with the upstream line. Behaviour is unchanged. | File | Upstream line | Edit | Why | |---|---|---|---| +| `body-parser/body-parser.ts` | 657 `throw toParseError(error, text);` | `error instanceof Error ? error : new SyntaxError(String(error))` | catch variable is `unknown` under `strict`; `toParseError` takes `Error`. `JSON.parse` and `secure-json-parse` only ever throw `SyntaxError`, so the fallback branch is unreachable in practice. | diff --git a/runtime/node/nest/platform-node/body-parser/body-parser.ts b/runtime/node/nest/platform-node/body-parser/body-parser.ts index 3d9b81b..29f2f1c 100644 --- a/runtime/node/nest/platform-node/body-parser/body-parser.ts +++ b/runtime/node/nest/platform-node/body-parser/body-parser.ts @@ -654,7 +654,7 @@ export function json(options?: NodeJsonParserOptions): Middleware { // only when the text contains one (the parser Fastify uses) return secureJson.parse(text, reviver, poisoningActions); } catch (error) { - throw toParseError(error, text); + throw toParseError(error instanceof Error ? error : new SyntaxError(String(error)), text); } }, 'jsonParser', From e31abe5109d7370764573a8e2f751a178e57a29f Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Mon, 19 Oct 2026 01:51:22 +0200 Subject: [PATCH 3/8] Type route-table's find-my-way store as RouteRecord and its methods as HTTPMethod (UPSTREAM-DIFF) --- .../node/nest/platform-node/UPSTREAM-DIFF.md | 16 ++++++++++++++++ .../nest/platform-node/router/route-table.ts | 18 +++++++++--------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/runtime/node/nest/platform-node/UPSTREAM-DIFF.md b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md index dfc4faf..392922a 100644 --- a/runtime/node/nest/platform-node/UPSTREAM-DIFF.md +++ b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md @@ -8,3 +8,19 @@ each edit is listed here with the upstream line. Behaviour is unchanged. | File | Upstream line | Edit | Why | |---|---|---|---| | `body-parser/body-parser.ts` | 657 `throw toParseError(error, text);` | `error instanceof Error ? error : new SyntaxError(String(error))` | catch variable is `unknown` under `strict`; `toParseError` takes `Error`. `JSON.parse` and `secure-json-parse` only ever throw `SyntaxError`, so the fallback branch is unreachable in practice. | + +## No-boxing typing + +The find-my-way shim (`runtime/node/nest/shims/find-my-way`) types the route +store as a type parameter instead of the package's `any`, so the vendored +route table names its store type and drops its `as any` casts. Behaviour is +unchanged. + +| File | Upstream line | Edit | Why | +|---|---|---|---| +| `router/route-table.ts` | 272-273 `FindMyWay.Instance` | `FindMyWay.Instance` | the shim's router is generic over the store; the package's typings are generic over the HTTP version (node-compat has no http2 server). | +| `router/route-table.ts` | 289-290 `FindMyWay(routerOptions)` | `FindMyWay(routerOptions)` | names the store type at construction. | +| `router/route-table.ts` | 347 `router.on(routerMethod as any, ...)` | `routerMethod as FindMyWay.HTTPMethod` | `any` is refused by geatsc; the method is one of the shim's `HTTPMethod` union (the registration is in a try/catch that falls back on a throw, as upstream). | +| `router/route-table.ts` | 399 `router: FindMyWay.Instance` | `FindMyWay.Instance` | as 272-273. | +| `router/route-table.ts` | 403 `router.find(method as any, path)` | `router.find(method, path)` | the shim's `find` takes the method as a string, as the package's runtime does. | +| `router/route-table.ts` | 410-411 `result.store as RouteRecord` / `result.params as Record` | `result.store` / `result.params` | typed by the generic result (`RouteRecord`, narrowed by the `null` check above; `{ [name: string]: string \| undefined }`). | diff --git a/runtime/node/nest/platform-node/router/route-table.ts b/runtime/node/nest/platform-node/router/route-table.ts index aaead78..48ae3cf 100644 --- a/runtime/node/nest/platform-node/router/route-table.ts +++ b/runtime/node/nest/platform-node/router/route-table.ts @@ -269,8 +269,8 @@ function toFindMyWayPath(tokens: Token[]): FindMyWayVariant | undefined { */ export class RouteTable { private readonly maxParamLength: number; - private readonly router: FindMyWay.Instance; - private readonly allRouter: FindMyWay.Instance; + private readonly router: FindMyWay.Instance; + private readonly allRouter: FindMyWay.Instance; private readonly records = new Map(); private readonly fallbackRoutes: FallbackRoute[] = []; private readonly routes: RegisteredRoute[] = []; @@ -286,8 +286,8 @@ export class RouteTable { // because of a parameter's length (see TOO_LONG) onMaxParamLength: noop, }; - this.router = FindMyWay(routerOptions); - this.allRouter = FindMyWay(routerOptions); + this.router = FindMyWay(routerOptions); + this.allRouter = FindMyWay(routerOptions); } /** @@ -344,7 +344,7 @@ export class RouteTable { wildcardSlashes, }; try { - router.on(routerMethod as any, routePath, () => {}, newRecord); + router.on(routerMethod as FindMyWay.HTTPMethod, routePath, () => {}, newRecord); } catch { // Same shape as an existing route under different parameter names this.fallbackRoutes.push({ @@ -396,19 +396,19 @@ export class RouteTable { } private find( - router: FindMyWay.Instance, + router: FindMyWay.Instance, method: string, path: string, ): FoundRoute | null { - const result = router.find(method as any, path); + const result = router.find(method, path); if (result === null) { return null; } if (result.store === null) { return TOO_LONG; } - const record = result.store as RouteRecord; - const values = result.params as Record; + const record = result.store; + const values = result.params; for (const key in values) { // path-to-regexp (the middleware matcher, and Express) requires at // least one character per parameter; find-my-way also accepts an empty From 93f654fbe0c95a58625335ee62bd2bcadece5e28 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Mon, 19 Oct 2026 01:51:30 +0200 Subject: [PATCH 4/8] Import content-type by name in the vendored body-parser: its TypeScript source has no default export (UPSTREAM-DIFF) --- runtime/node/nest/platform-node/UPSTREAM-DIFF.md | 1 + runtime/node/nest/platform-node/body-parser/body-parser.ts | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/runtime/node/nest/platform-node/UPSTREAM-DIFF.md b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md index 392922a..9575980 100644 --- a/runtime/node/nest/platform-node/UPSTREAM-DIFF.md +++ b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md @@ -8,6 +8,7 @@ each edit is listed here with the upstream line. Behaviour is unchanged. | File | Upstream line | Edit | Why | |---|---|---|---| | `body-parser/body-parser.ts` | 657 `throw toParseError(error, text);` | `error instanceof Error ? error : new SyntaxError(String(error))` | catch variable is `unknown` under `strict`; `toParseError` takes `Error`. `JSON.parse` and `secure-json-parse` only ever throw `SyntaxError`, so the fallback branch is unreachable in practice. | +| `body-parser/body-parser.ts` | 2 `import contentType from 'content-type';` and 218-219 `contentType.parse(header)` | `import { parse as parseContentType } from 'content-type';` / `parseContentType(header)` | the package compiles from its TypeScript source here, which has named exports only; the default import exists upstream through `esModuleInterop` over the published CommonJS build. | ## No-boxing typing diff --git a/runtime/node/nest/platform-node/body-parser/body-parser.ts b/runtime/node/nest/platform-node/body-parser/body-parser.ts index 29f2f1c..45f3813 100644 --- a/runtime/node/nest/platform-node/body-parser/body-parser.ts +++ b/runtime/node/nest/platform-node/body-parser/body-parser.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'http'; -import contentType from 'content-type'; +import { parse as parseContentType } from 'content-type'; import qs from 'qs'; import secureJson from 'secure-json-parse'; import type { Readable, Transform } from 'stream'; @@ -215,8 +215,7 @@ function getCharset(req: IncomingMessage): string | undefined { // RFC 9110 parameter parsing (quoted strings included), as body-parser // does, so that a "charset=" inside another parameter's quoted value is // not mistaken for the charset - cachedCharset = contentType - .parse(header) + cachedCharset = parseContentType(header) .parameters.charset?.toLowerCase(); } catch { cachedCharset = undefined; From 66e4beb0ed6b2722699b661cdf6c2e923405cf31 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Mon, 19 Oct 2026 01:53:47 +0200 Subject: [PATCH 5/8] Type the vendored route table and request for certification: RouteParams, ParsedQuery, a NodeApp class, no prototype-reassigned params object (UPSTREAM-DIFF) --- .../node/nest/platform-node/UPSTREAM-DIFF.md | 30 ++++++++++++- .../platform-node/adapters/node-request.ts | 42 ++++++++----------- .../nest/platform-node/router/node-router.ts | 3 +- .../nest/platform-node/router/route-table.ts | 33 ++++++++------- 4 files changed, 67 insertions(+), 41 deletions(-) diff --git a/runtime/node/nest/platform-node/UPSTREAM-DIFF.md b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md index 9575980..cc772d7 100644 --- a/runtime/node/nest/platform-node/UPSTREAM-DIFF.md +++ b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md @@ -7,8 +7,6 @@ each edit is listed here with the upstream line. Behaviour is unchanged. | File | Upstream line | Edit | Why | |---|---|---|---| -| `body-parser/body-parser.ts` | 657 `throw toParseError(error, text);` | `error instanceof Error ? error : new SyntaxError(String(error))` | catch variable is `unknown` under `strict`; `toParseError` takes `Error`. `JSON.parse` and `secure-json-parse` only ever throw `SyntaxError`, so the fallback branch is unreachable in practice. | -| `body-parser/body-parser.ts` | 2 `import contentType from 'content-type';` and 218-219 `contentType.parse(header)` | `import { parse as parseContentType } from 'content-type';` / `parseContentType(header)` | the package compiles from its TypeScript source here, which has named exports only; the default import exists upstream through `esModuleInterop` over the published CommonJS build. | ## No-boxing typing @@ -25,3 +23,31 @@ unchanged. | `router/route-table.ts` | 399 `router: FindMyWay.Instance` | `FindMyWay.Instance` | as 272-273. | | `router/route-table.ts` | 403 `router.find(method as any, path)` | `router.find(method, path)` | the shim's `find` takes the method as a string, as the package's runtime does. | | `router/route-table.ts` | 410-411 `result.store as RouteRecord` / `result.params as Record` | `result.store` / `result.params` | typed by the generic result (`RouteRecord`, narrowed by the `null` check above; `{ [name: string]: string \| undefined }`). | + +## Certification typing + +geatsc certifies only what it can type statically: no `any` that is read +dynamically, no object built through a reassigned `function () {}` prototype, +no `Object.assign` onto an Error, no `instanceof` against a typed-array +constructor, no `Array.from` with a mapping callback, and a missing key of a +map typed `{ [name: string]: string }` reads as the empty string. These edits +type what the package left dynamic; behaviour is unchanged except where a +row says so. + +| File | Upstream line | Edit | Why | +|---|---|---|---| +| `router/route-table.ts` | 11-21, 39-43 `params: Record` | `params: RouteParams`, a new exported interface `{ [name: string]: string \| string[] }` | the shapes path-to-regexp and find-my-way produce: a string per parameter, a string array per wildcard. | +| `router/route-table.ts` | 54-55 `const ParamsObject = function () {} as unknown as new () => Record; ParamsObject.prototype = Object.create(null);` and 444 `new ParamsObject()` | removed; `const params: RouteParams = {}` | a constructor made from a function with a reassigned prototype has no runtime conversion; a compiled map has no prototype to pollute, so a null-prototype object buys nothing here. | +| `router/route-table.ts` | 58 `Array.from({ length: 32 }, (_, i) => \`p${i}\`)` | a `for` loop pushing the 32 names | `Array.from`'s mapping callback is called with `(undefined, index)` by the runtime, which does not match the `(value, number)` callable. | +| `router/route-table.ts` | 64 `type Matcher = ... { params: Record }` and 153 `exceedsMaxParamLength(params: Record, ...)` | `RouteParams` | as 11-21. | +| `router/route-table.ts` | 92 `return (pathname: string) => matcher(pathname) as any;` | `return matcher;` | the path-to-regexp shim's match function already has the `Matcher` shape; the `as any` erased it. | +| `router/route-table.ts` | 448-451 `params[names[i]] = ... : values[positionalName(i)]` | the value is assigned only when defined | `RouteParams` has no `undefined`; find-my-way fills every positional parameter of a route it matched, so no key is left out in practice. | +| `router/node-router.ts` | 15 `type PathMatcher = ... params: any` | `params: RouteParams` (imported) | as route-table 11-21. | +| `adapters/node-request.ts` | 5 `import { parse as parseQueryString } from 'fast-querystring';` | adds `type ParsedQuery` | the query's static type (below). | +| `adapters/node-request.ts` | 11 | adds `import type { RouteParams } from '../router/route-table.js';` | the params' static type (below). | +| `adapters/node-request.ts` | 33-41 `const NODE_APP = Object.freeze({ get(setting: string): unknown {...} })` and 211 `get app(): { get(setting: string): unknown }` | `class NodeApp { get(setting) {...} }`, `Object.freeze(new NodeApp())`, `get app(): NodeApp` | a method of an object literal is a function-valued property, which has no runtime conversion to its declared return type; a class method does. | +| `adapters/node-request.ts` | 104 `declare params: Record;` | `declare params: RouteParams;` | as route-table 11-21. | +| `adapters/node-request.ts` | 108 `declare body: any;` and 229-233 `Object.defineProperty(NodeRequest.prototype, 'body', { value: undefined, writable: true, configurable: true })` | `body: any = undefined;` (a field) | `Object.defineProperty` on a class prototype is not lowered; a compiled instance has the slot in its layout, so the field costs no per-request property write. `'body' in req` stays true. | +| `adapters/node-request.ts` | 119, 124, 135 `Record` for `[QUERY]`, `get query()`, `set query()` | `ParsedQuery` (`{ [key: string]: string \| string[] }`) | what fast-querystring's `parse` returns; a map of `string \| string[]` has no runtime conversion to a map of `any`. | +| `adapters/node-request.ts` | 690-693 `return view instanceof Uint8Array ? view : new Uint8Array(view.buffer, view.byteOffset, view.byteLength)` | `return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)` | `instanceof Uint8Array` has no runtime helper. Behaviour: a `Uint8Array` body is sent through a new view over the same bytes rather than itself; the bytes written are the same. | +| `adapters/node-request.ts` | 386 `const text = JSON.stringify(body);` | `const text: string \| undefined = JSON.stringify(body);` | the lib types `JSON.stringify` as returning `string`; it returns `undefined` for an undefined body. The compiled program reads a cell typed `string` as present and throws for the absent result (`res.json(req.body)` with no body: a 500). Node's behaviour is unchanged. | diff --git a/runtime/node/nest/platform-node/adapters/node-request.ts b/runtime/node/nest/platform-node/adapters/node-request.ts index b0328d1..64071b9 100644 --- a/runtime/node/nest/platform-node/adapters/node-request.ts +++ b/runtime/node/nest/platform-node/adapters/node-request.ts @@ -2,12 +2,13 @@ import { serialize as serializeCookie } from 'cookie'; import { sign as signCookie } from 'cookie-signature'; import encodeUrl from 'encodeurl'; import { IncomingMessage, ServerResponse, STATUS_CODES } from 'http'; -import { parse as parseQueryString } from 'fast-querystring'; +import { parse as parseQueryString, type ParsedQuery } from 'fast-querystring'; import { contentType as lookupContentType } from 'mime-types'; import onFinished from 'on-finished'; import { isAbsolute } from 'path'; import send from 'send'; import typeIs from 'type-is'; +import type { RouteParams } from '../router/route-table.js'; import { getRoutingPath } from '../router/utils.js'; const QUERY = Symbol('query'); @@ -30,15 +31,17 @@ export const ROUTE_ERROR_HANDLER = Symbol('nestjs.node.routeErrorHandler'); * it (`express-rate-limit` checks `req.app.get('trust proxy')`). There are no * Express settings here; `get()` reports the adapter's fixed behavior. */ -const NODE_APP = Object.freeze({ +class NodeApp { /** * `'trust proxy'` is `false` (`req.ip` is the socket address); every other * setting is `undefined`. */ get(setting: string): unknown { return setting === 'trust proxy' ? false : undefined; - }, -}); + } +} + +const NODE_APP = Object.freeze(new NodeApp()); /** * Options of {@link NodeResponse.sendFile}, those of the `send` package, as @@ -101,11 +104,13 @@ export class NodeRequest extends IncomingMessage { /** * Route parameters of the matched route (or middleware path). */ - declare params: Record; + declare params: RouteParams; /** - * Parsed request body, set by the body parsers. + * Parsed request body, set by the body parsers. Every request has a "body" + * (Express libraries check "'body' in req"); it is a field here, where the + * package defines it on the prototype to avoid a per-request write. */ - declare body: any; + body: any = undefined; /** * Unparsed request body, set by the body parsers when the `rawBody` * application option is enabled. @@ -116,12 +121,12 @@ export class NodeRequest extends IncomingMessage { */ declare baseUrl: string; - declare [QUERY]: Record | undefined; + declare [QUERY]: ParsedQuery | undefined; /** * Query string parameters, parsed on first access. */ - get query(): Record { + get query(): ParsedQuery { let query = this[QUERY]; if (query === undefined) { const url = this.url!; @@ -132,7 +137,7 @@ export class NodeRequest extends IncomingMessage { return query!; } - set query(value: Record) { + set query(value: ParsedQuery) { this[QUERY] = value; } @@ -208,7 +213,7 @@ export class NodeRequest extends IncomingMessage { * settings: `req.app.get('trust proxy')` is `false`, every other setting * `undefined`. */ - get app(): { get(setting: string): unknown } { + get app(): NodeApp { return NODE_APP; } @@ -224,14 +229,6 @@ export class NodeRequest extends IncomingMessage { } } -// Every request has a "body" (Express libraries check "'body' in req"), with -// no per-request property write until a parser sets one -Object.defineProperty(NodeRequest.prototype, 'body', { - value: undefined, - writable: true, - configurable: true, -}); - /** * Response object handed to middleware and route handlers by the * `NodeAdapter`: a plain Node.js `ServerResponse`, plus the small subset of @@ -382,8 +379,7 @@ export class NodeResponse< if (endWithoutBody(this)) { return this; } - // Before any header, in case it throws (a BigInt, a circular reference) - const text = JSON.stringify(body); + const text: string | undefined = JSON.stringify(body); if (!this.hasHeader('Content-Type')) { this.setHeader('Content-Type', 'application/json; charset=utf-8'); } @@ -688,9 +684,7 @@ function escapeHtml(text: string) { * binary (as Fastify does) rather than serialized to JSON. */ export function toBytes(view: ArrayBufferView): Uint8Array { - return view instanceof Uint8Array - ? view - : new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); } // With an explicit "Content-Length", which Node.js leaves out in answer to diff --git a/runtime/node/nest/platform-node/router/node-router.ts b/runtime/node/nest/platform-node/router/node-router.ts index a661471..59a26d3 100644 --- a/runtime/node/nest/platform-node/router/node-router.ts +++ b/runtime/node/nest/platform-node/router/node-router.ts @@ -6,13 +6,14 @@ import { type RouteEntry, type RouteHandler, type RouteMatch, + type RouteParams, type RouteTableOptions, RouteTable, } from './route-table.js'; import { getRawPrefixEnd, getRoutingPath, loosen } from './utils.js'; type NextFunction = (err?: any) => void; -type PathMatcher = (path: string) => false | { path: string; params: any }; +type PathMatcher = (path: string) => false | { path: string; params: RouteParams }; interface MiddlewareLayer { table?: undefined; diff --git a/runtime/node/nest/platform-node/router/route-table.ts b/runtime/node/nest/platform-node/router/route-table.ts index 48ae3cf..26bc14b 100644 --- a/runtime/node/nest/platform-node/router/route-table.ts +++ b/runtime/node/nest/platform-node/router/route-table.ts @@ -8,16 +8,20 @@ export type RouteHandler = ( next: (err?: any) => void, ) => any; +export interface RouteParams { + [name: string]: string | string[]; +} + export interface RouteMatch { handlers: RouteHandler[]; - params: Record; + params: RouteParams; /** Registration order of each handler, see {@link RouteTable.lookupRest}. */ seqs: number[]; } export interface RouteEntry { handler: RouteHandler; - params: Record; + params: RouteParams; } interface RouteRecord { @@ -39,7 +43,7 @@ interface RouteRecord { interface FoundRoute { handlers: RouteHandler[]; seqs: number[]; - params: Record; + params: RouteParams; } interface FindMyWayVariant { @@ -49,19 +53,17 @@ interface FindMyWayVariant { wildcardSlashes: number; } -// Route parameters live in an object without a prototype, as with -// find-my-way, which is cheaper to create than `Object.create(null)` -const ParamsObject = function () {} as unknown as new () => Record; -ParamsObject.prototype = Object.create(null); - // Positional parameter names, see `RouteRecord` -const POSITIONAL_NAMES = Array.from({ length: 32 }, (_, i) => `p${i}`); +const POSITIONAL_NAMES: string[] = []; +for (let i = 0; i < 32; i++) { + POSITIONAL_NAMES.push(`p${i}`); +} function positionalName(index: number) { return POSITIONAL_NAMES[index] ?? `p${index}`; } -type Matcher = (path: string) => false | { params: Record }; +type Matcher = (path: string) => false | { params: RouteParams }; interface FallbackRoute { method: string | null; @@ -89,7 +91,7 @@ function compileMatcher(path: string): Matcher { end: true, sensitive: true, }); - return (pathname: string) => matcher(pathname) as any; + return matcher; } function acceptsMethod(routeMethod: string | null, method: string) { @@ -150,7 +152,7 @@ function decodeSegment(value: string) { // The limit find-my-way applies to the routes it matches, for the routes // matched with path-to-regexp (wildcards are arrays there) -function exceedsMaxParamLength(params: Record, limit: number) { +function exceedsMaxParamLength(params: RouteParams, limit: number) { for (const key in params) { const value = params[key]; if (typeof value === 'string' && value.length > limit) { @@ -441,14 +443,17 @@ export class RouteTable { wildcard = path.slice(start, end).split('/').map(decodeSegment); } // Every handler sees the parameters under the names its route uses - const params = new ParamsObject(); + const params: RouteParams = {}; for (const names of record.paramNames) { const last = names.length - 1; for (let i = 0; i <= last; i++) { - params[names[i]] = + const value = wildcard !== undefined && i === last ? wildcard : values[positionalName(i)]; + if (value !== undefined) { + params[names[i]] = value; + } } } return { handlers: record.handlers, seqs: record.seqs, params }; From 7d6202c83e5286d9473e3ed2fbbc3dcbcac3a6a9 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Mon, 19 Oct 2026 01:53:55 +0200 Subject: [PATCH 6/8] Type body-parser's errors, urlencoded decoder and poisoning scan for certification, with qs's Decoder and a ParsedQs scanQuery in secure-json-parse (UPSTREAM-DIFF) --- .../node/nest/platform-node/UPSTREAM-DIFF.md | 9 ++ .../platform-node/body-parser/body-parser.ts | 122 +++++++++++++----- 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/runtime/node/nest/platform-node/UPSTREAM-DIFF.md b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md index cc772d7..90439c7 100644 --- a/runtime/node/nest/platform-node/UPSTREAM-DIFF.md +++ b/runtime/node/nest/platform-node/UPSTREAM-DIFF.md @@ -7,6 +7,8 @@ each edit is listed here with the upstream line. Behaviour is unchanged. | File | Upstream line | Edit | Why | |---|---|---|---| +| `body-parser/body-parser.ts` | 657 `throw toParseError(error, text);` | `error instanceof Error ? error : new SyntaxError(String(error))` | catch variable is `unknown` under `strict`; `toParseError` takes `Error`. `JSON.parse` and `secure-json-parse` only ever throw `SyntaxError`, so the fallback branch is unreachable in practice. | +| `body-parser/body-parser.ts` | 2 `import contentType from 'content-type';` and 218-219 `contentType.parse(header)` | `import { parse as parseContentType } from 'content-type';` / `parseContentType(header)` | the package compiles from its TypeScript source here, which has named exports only; the default import exists upstream through `esModuleInterop` over the published CommonJS build. | ## No-boxing typing @@ -50,4 +52,11 @@ row says so. | `adapters/node-request.ts` | 108 `declare body: any;` and 229-233 `Object.defineProperty(NodeRequest.prototype, 'body', { value: undefined, writable: true, configurable: true })` | `body: any = undefined;` (a field) | `Object.defineProperty` on a class prototype is not lowered; a compiled instance has the slot in its layout, so the field costs no per-request property write. `'body' in req` stays true. | | `adapters/node-request.ts` | 119, 124, 135 `Record` for `[QUERY]`, `get query()`, `set query()` | `ParsedQuery` (`{ [key: string]: string \| string[] }`) | what fast-querystring's `parse` returns; a map of `string \| string[]` has no runtime conversion to a map of `any`. | | `adapters/node-request.ts` | 690-693 `return view instanceof Uint8Array ? view : new Uint8Array(view.buffer, view.byteOffset, view.byteLength)` | `return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)` | `instanceof Uint8Array` has no runtime helper. Behaviour: a `Uint8Array` body is sent through a new view over the same bytes rather than itself; the bytes written are the same. | +| `body-parser/body-parser.ts` | 171-185 `createHttpError(statusCode, message, type, properties: object = {}, ErrorClass: typeof Error = Error)` with `Object.assign(new ErrorClass(message), {...})` | `class BodyParserError extends Error` carrying `status`, `statusCode`, `expose`, `type` and the package's properties (`encoding`, `charset`, `expected`, `length`, `limit`, `received`, `code`, `body`) as fields; `createHttpError(..., properties: BodyParserErrorProperties = {}, name = 'Error')` | `Object.assign` onto an Error and a constructor passed as a value have no call ABI. Behaviour: the error's `name` is `'SyntaxError'` where the package threw a `SyntaxError` (649), but it is not `instanceof SyntaxError`. | +| `body-parser/body-parser.ts` | 430 `createHttpError(400, error.message, 'stream.error', {}, Error)` | `createHttpError(400, error.message, 'stream.error')` | the default class, spelled by the new signature's default name. | +| `body-parser/body-parser.ts` | 452-466 `toVerifyError`: `(failure as any).status \|\| (failure as any).statusCode \|\| 403`, `Object.assign(failure, {...})` | `verifyErrorStatus(failure)`/`verifyErrorType(failure)` read the fields through `in` + `typeof` guards; the result is a new `BodyParserError` with the thrown error's `message` and `name` | as 171-185. Behaviour: the error passed to `next()` is the new `BodyParserError`, not the `verify` callback's own object (its stack and other own properties are not carried). | +| `body-parser/body-parser.ts` | 498-505 `toParseError`: `Object.assign(error, {...})` | `new BodyParserError(400, error.message, 'entity.parse.failed', { body: text }, error.name)` | as 171-185; same behaviour note. | +| `body-parser/body-parser.ts` | 649 `SyntaxError,` | `'SyntaxError',` | the name the new signature takes in place of the class. | +| `body-parser/body-parser.ts` | 4, 823 `secureJson.scan(body, poisoningActions)` | `import secureJson, { scanQuery } from 'secure-json-parse'`; `scanQuery(body, poisoningActions)` | The generic `scan` instantiated at qs's `ParsedQs` (a recursive dictionary) has no conversion to its `Record` body; `scanQuery` is the same walk over `ParsedQs`, in the secure-json-parse shim. | +| `body-parser/body-parser.ts` | 3, 767-773 `const decoder = ... ? (value: string, defaultDecoder: (value: string, ...args: any[]) => string, charset: string, type) => {` | `import qs, { type Charset, type Decoder, type DefaultDecoder } from 'qs'`; `const decoder: Decoder \| undefined = ... ? (value: string, defaultDecoder: DefaultDecoder, charset: Charset, type): string \| null => {` | The arrow's own signature (`charset: string`, a `...args: any[]` decoder, a `string` result) is a different callable ABI from qs's `Decoder`, and the certifier has no structural conversion between two optional callables; stated as the type the option takes. Its `defaultDecoder(value, defaultDecoder, charset)` call becomes `defaultDecoder(value, undefined, charset)`: the qs shim's `DefaultDecoder` ignores (and types as `undefined`) the second argument, so the type is not recursive. | | `adapters/node-request.ts` | 386 `const text = JSON.stringify(body);` | `const text: string \| undefined = JSON.stringify(body);` | the lib types `JSON.stringify` as returning `string`; it returns `undefined` for an undefined body. The compiled program reads a cell typed `string` as present and throws for the absent result (`res.json(req.body)` with no body: a 500). Node's behaviour is unchanged. | diff --git a/runtime/node/nest/platform-node/body-parser/body-parser.ts b/runtime/node/nest/platform-node/body-parser/body-parser.ts index 45f3813..6ecded0 100644 --- a/runtime/node/nest/platform-node/body-parser/body-parser.ts +++ b/runtime/node/nest/platform-node/body-parser/body-parser.ts @@ -1,7 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { parse as parseContentType } from 'content-type'; -import qs from 'qs'; -import secureJson from 'secure-json-parse'; +import qs, { type Charset, type Decoder, type DefaultDecoder } from 'qs'; +import secureJson, { scanQuery } from 'secure-json-parse'; import type { Readable, Transform } from 'stream'; import typeIs from 'type-is'; import { createBrotliDecompress, createGunzip, createInflate } from 'zlib'; @@ -168,21 +168,63 @@ function parseLimit(limit: number | string | null | undefined): number { ); } +interface BodyParserErrorProperties { + encoding?: string; + charset?: string; + expected?: number; + length?: number; + limit?: number; + received?: number; + code?: string; + body?: string | Buffer; +} + +export class BodyParserError extends Error { + status: number; + statusCode: number; + expose: boolean; + type: string; + encoding: string | undefined; + charset: string | undefined; + expected: number | undefined; + length: number | undefined; + limit: number | undefined; + received: number | undefined; + code: string | undefined; + body: string | Buffer | undefined; + + constructor( + statusCode: number, + message: string, + type: string, + properties: BodyParserErrorProperties = {}, + name = 'Error', + ) { + super(message); + this.name = name; + this.status = statusCode; + this.statusCode = statusCode; + this.expose = statusCode < 500; + this.type = type; + this.encoding = properties.encoding; + this.charset = properties.charset; + this.expected = properties.expected; + this.length = properties.length; + this.limit = properties.limit; + this.received = properties.received; + this.code = properties.code; + this.body = properties.body; + } +} + function createHttpError( statusCode: number, message: string, type: string, - properties: object = {}, - ErrorClass: typeof Error = Error, + properties: BodyParserErrorProperties = {}, + name = 'Error', ) { - const error = new ErrorClass(message); - return Object.assign(error, { - status: statusCode, - statusCode, - expose: statusCode < 500, - type, - ...properties, - }); + return new BodyParserError(statusCode, message, type, properties, name); } /** @@ -426,7 +468,7 @@ function createReader( // client goes away mid-body req.destroyed && !req.complete ? aborted() - : createHttpError(400, error.message, 'stream.error', {}, Error), + : createHttpError(400, error.message, 'stream.error'), ); const onClose = () => { if (!req.complete) { @@ -453,16 +495,32 @@ function toVerifyError(error: unknown, buffer: Buffer) { error instanceof Error ? error : new Error(String(error ?? 'verify failed')); - const errorStatus = - (failure as any).status || (failure as any).statusCode || 403; + const errorStatus = verifyErrorStatus(failure) || 403; const status = errorStatus >= 400 && errorStatus < 600 ? errorStatus : 403; - return Object.assign(failure, { + return new BodyParserError( status, - statusCode: status, - expose: status < 500, - type: (failure as any).type || 'entity.verify.failed', - body: buffer, - }); + failure.message, + verifyErrorType(failure) || 'entity.verify.failed', + { body: buffer }, + failure.name, + ); +} + +function verifyErrorStatus(error: Error): number { + if (error instanceof BodyParserError) return error.status || error.statusCode; + if ('status' in error && typeof error.status === 'number' && error.status) { + return error.status; + } + if ('statusCode' in error && typeof error.statusCode === 'number') { + return error.statusCode; + } + return 0; +} + +function verifyErrorType(error: Error): string { + if (error instanceof BodyParserError) return error.type; + if ('type' in error && typeof error.type === 'string') return error.type; + return ''; } /** @@ -495,13 +553,7 @@ function getPoisoningActions(options?: NodePrototypePoisoningOptions) { } function toParseError(error: Error, text: string) { - return Object.assign(error, { - status: 400, - statusCode: 400, - expose: true, - type: 'entity.parse.failed', - body: text, - }); + return new BodyParserError(400, error.message, 'entity.parse.failed', { body: text }, error.name); } function decode(buffer: Buffer, charset: string): string { @@ -645,7 +697,7 @@ export function json(options?: NodeJsonParserOptions): Middleware { : `Unexpected token '${first}', "${text.slice(0, 20)}" is not valid JSON`, 'entity.parse.failed', { body: text }, - SyntaxError, + 'SyntaxError', ); } try { @@ -699,15 +751,15 @@ export function urlencoded(options?: NodeUrlencodedParserOptions): Middleware { } // "qs" silently drops "__proto__" keys; with 'error', reject them as the // JSON parser does - const decoder = + const decoder: Decoder | undefined = poisoningActions.protoAction === 'error' ? ( value: string, - defaultDecoder: (value: string, ...args: any[]) => string, - charset: string, + defaultDecoder: DefaultDecoder, + charset: Charset, type: 'key' | 'value', - ) => { - const decoded = defaultDecoder(value, defaultDecoder, charset); + ): string | null => { + const decoded = defaultDecoder(value, undefined, charset); if ( type === 'key' && (extended ? PROTO_KEY.test(decoded) : decoded === '__proto__') @@ -755,7 +807,7 @@ export function urlencoded(options?: NodeUrlencodedParserOptions): Middleware { // "qs" drops "__proto__" keys, but nests "constructor[prototype][x]" // like the JSON payload the JSON parser rejects return scanForPoisoning - ? secureJson.scan(body, poisoningActions) + ? scanQuery(body, poisoningActions) : body; } catch (error) { if (error instanceof SyntaxError) { From c93c105a8442b800962c54d27fab9db473d0e846 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Mon, 19 Oct 2026 01:57:43 +0200 Subject: [PATCH 7/8] Add tools/nest/build-platform-node.mjs building the @nestjs tarballs from the pinned nest commit --- .gitignore | 1 + tools/nest/build-platform-node.mjs | 72 ++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 tools/nest/build-platform-node.mjs diff --git a/.gitignore b/.gitignore index 2ab7a51..13d978c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ tools/tools *.bak *.orig *.rej +vendor/ diff --git a/tools/nest/build-platform-node.mjs b/tools/nest/build-platform-node.mjs new file mode 100644 index 0000000..fd577e6 --- /dev/null +++ b/tools/nest/build-platform-node.mjs @@ -0,0 +1,72 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +const PIN = { + repository: 'nestjs/nest', + branch: 'feat/platform-node', + commit: '2b10fb228353fcaedd2817cc22f89ad1cca9a900', + version: '12.1.3', + packages: ['common', 'core', 'platform-node'] +} + +const root = path.resolve(import.meta.dirname, '../..') +const vendorDir = path.join(root, 'vendor') +const sourceDir = path.join(vendorDir, 'nest-src') +const sourceFile = path.join(vendorDir, 'SOURCE') +const nestDir = path.resolve(process.env.GEA_NEST_DIR ?? path.join(root, '..', 'nest')) +const check = process.argv.includes('--check') + +const tarball = (name) => path.join(vendorDir, `nestjs-${name}-${PIN.version}.tgz`) +const sourceText = () => + `${PIN.repository} branch ${PIN.branch} commit ${PIN.commit}\n` + + `built by tools/nest/build-platform-node.mjs from a detached worktree of that commit: ` + + `npm install --legacy-peer-deps && npm run build, then npm pack in packages/{${PIN.packages.join(',')}}\n` + +const run = (command, args, cwd) => { + console.log(`$ ${command} ${args.join(' ')} (${path.relative(root, cwd) || '.'})`) + execFileSync(command, args, { cwd, stdio: 'inherit' }) +} +const git = (args, cwd = nestDir) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() + +const fail = (message) => { + console.error(`build-platform-node: ${message}`) + process.exit(1) +} + +if (check) { + const missing = PIN.packages.map(tarball).filter((file) => !fs.existsSync(file)) + if (missing.length > 0) fail(`missing ${missing.map((file) => path.relative(root, file)).join(', ')}; run without --check to build them`) + if (!fs.existsSync(sourceFile) || fs.readFileSync(sourceFile, 'utf8') !== sourceText()) fail(`${path.relative(root, sourceFile)} does not match the pin ${PIN.commit}`) + console.log(`ok: ${PIN.packages.length} tarballs at ${PIN.commit.slice(0, 7)}`) + process.exit(0) +} + +if (!fs.existsSync(path.join(nestDir, '.git'))) fail(`no nest clone at ${nestDir} (set GEA_NEST_DIR)`) +let type = '' +try { + type = git(['cat-file', '-t', PIN.commit]) +} catch {} +if (type !== 'commit') fail(`${nestDir} does not contain commit ${PIN.commit}; fetch ${PIN.repository} ${PIN.branch} into it first`) + +fs.mkdirSync(vendorDir, { recursive: true }) +if (fs.existsSync(path.join(sourceDir, '.git'))) { + const head = git(['rev-parse', 'HEAD'], sourceDir) + if (head !== PIN.commit) fail(`${path.relative(root, sourceDir)} is at ${head}, not the pin; remove it (git -C ${nestDir} worktree remove --force ${sourceDir}) and rerun`) +} else { + run('git', ['worktree', 'add', '--detach', sourceDir, PIN.commit], nestDir) +} + +run('npm', ['install', '--legacy-peer-deps', '--no-audit', '--no-fund'], sourceDir) +run('npm', ['run', 'build'], sourceDir) + +for (const name of PIN.packages) { + const packageDir = path.join(sourceDir, 'packages', name) + const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')) + if (manifest.name !== `@nestjs/${name}` || manifest.version !== PIN.version) fail(`${name} is ${manifest.name}@${manifest.version}, expected @nestjs/${name}@${PIN.version}`) + run('npm', ['pack', '--pack-destination', vendorDir], packageDir) + if (!fs.existsSync(tarball(name))) fail(`npm pack did not produce ${path.relative(root, tarball(name))}`) +} + +fs.writeFileSync(sourceFile, sourceText()) +console.log(`packed ${PIN.packages.map((name) => path.relative(root, tarball(name))).join(', ')}`) From beb3f1d8a36f0c16a27f5e4620a4aa37fd4e82fa Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Mon, 19 Oct 2026 01:57:44 +0200 Subject: [PATCH 8/8] Add nest-node-probe with the PR's three routes, a Node oracle, and node-vs-compiled probes for JSON.stringify of undefined and instanceof over an Error subclass --- .../correctness/error-subclass.ts | 29 + .../correctness/json-undefined.ts | 19 + apps/nest-node-probe/oracle.node.mjs | 1 + apps/nest-node-probe/package-lock.json | 1123 +++++++++++++++++ apps/nest-node-probe/package.json | 24 + apps/nest-node-probe/server.ts | 64 + apps/nest-node-probe/tsconfig.json | 13 + scripts/test-error-subclass.mjs | 26 + scripts/test-json-undefined.mjs | 26 + 9 files changed, 1325 insertions(+) create mode 100644 apps/nest-node-probe/correctness/error-subclass.ts create mode 100644 apps/nest-node-probe/correctness/json-undefined.ts create mode 100644 apps/nest-node-probe/oracle.node.mjs create mode 100644 apps/nest-node-probe/package-lock.json create mode 100644 apps/nest-node-probe/package.json create mode 100644 apps/nest-node-probe/server.ts create mode 100644 apps/nest-node-probe/tsconfig.json create mode 100644 scripts/test-error-subclass.mjs create mode 100644 scripts/test-json-undefined.mjs diff --git a/apps/nest-node-probe/correctness/error-subclass.ts b/apps/nest-node-probe/correctness/error-subclass.ts new file mode 100644 index 0000000..5c80edf --- /dev/null +++ b/apps/nest-node-probe/correctness/error-subclass.ts @@ -0,0 +1,29 @@ +class ParseFailure extends Error { + status: number + constructor(status: number, message: string, name = 'Error') { + super(message) + this.name = name + this.status = status + } +} +function fail(): never { + throw new ParseFailure(400, 'bad body', 'SyntaxError') +} +function describe(err: unknown): string { + return err instanceof Error ? `${err.name}: ${err.message}` : String(err) +} +try { + fail() +} catch (err) { + console.log('caught instanceof Error', err instanceof Error) + console.log('caught instanceof ParseFailure', err instanceof ParseFailure) + console.log('describe', describe(err)) + console.log('String', String(err)) +} +const direct: unknown = new ParseFailure(415, 'unsupported', 'Error') +console.log('direct instanceof Error', direct instanceof Error) +console.log('describe direct', describe(direct)) +const plain: unknown = new Error('plain') +console.log('plain describe', describe(plain)) +const wrapped: unknown = new TypeError('typed') +console.log('typed describe', describe(wrapped)) diff --git a/apps/nest-node-probe/correctness/json-undefined.ts b/apps/nest-node-probe/correctness/json-undefined.ts new file mode 100644 index 0000000..199c879 --- /dev/null +++ b/apps/nest-node-probe/correctness/json-undefined.ts @@ -0,0 +1,19 @@ +class Holder { + body: any = undefined +} +const holder = new Holder() +const fromField: string | undefined = JSON.stringify(holder.body) +console.log('field undefined === undefined', fromField === undefined) +console.log('field typeof', typeof holder.body) +function stringifyUnknown(body: unknown): string | undefined { + return JSON.stringify(body) +} +console.log('param unknown === undefined', stringifyUnknown(holder.body) === undefined) +console.log('param unknown object', stringifyUnknown({ a: 1 })) +const dynamic: unknown = undefined +const fromUnknown: string | undefined = JSON.stringify(dynamic) +console.log('unknown undefined === undefined', fromUnknown === undefined) +holder.body = { a: [1, null, 'x'] } +const fromObject: string | undefined = JSON.stringify(holder.body) +console.log('object', fromObject) +console.log('null', JSON.stringify(null)) diff --git a/apps/nest-node-probe/oracle.node.mjs b/apps/nest-node-probe/oracle.node.mjs new file mode 100644 index 0000000..a38f0d3 --- /dev/null +++ b/apps/nest-node-probe/oracle.node.mjs @@ -0,0 +1 @@ +import './dist/server.js' diff --git a/apps/nest-node-probe/package-lock.json b/apps/nest-node-probe/package-lock.json new file mode 100644 index 0000000..73afad6 --- /dev/null +++ b/apps/nest-node-probe/package-lock.json @@ -0,0 +1,1123 @@ +{ + "name": "nest-node-probe", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nest-node-probe", + "dependencies": { + "@nestjs/common": "file:../../vendor/nestjs-common-12.1.3.tgz", + "@nestjs/core": "file:../../vendor/nestjs-core-12.1.3.tgz", + "@nestjs/platform-node": "file:../../vendor/nestjs-platform-node-12.1.3.tgz", + "content-type": "2.1.0", + "encodeurl": "2.0.0", + "fast-querystring": "1.1.2", + "path-to-regexp": "8.4.2", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.2" + }, + "devDependencies": { + "@types/node": "24.19.2", + "typescript": "5.9.3" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@nestjs/common": { + "version": "12.1.3", + "resolved": "file:../../vendor/nestjs-common-12.1.3.tgz", + "integrity": "sha512-8w4tI5o1iJkdxRXSGpS+5+2eKyxm1uQIWMZ4ciWIxIeK2gxUg0iMy3HR4CnL+7rys7tidGs64TkfaLALSeRK1A==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "file-type": "22.1.1", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/core": { + "version": "12.1.3", + "resolved": "file:../../vendor/nestjs-core-12.1.3.tgz", + "integrity": "sha512-aYwIezZwFVTZLc71dX5ABRI85+9r2y8ojUM5z0XMS54KtPDNro6e2YdypE56FnojyqKeIBzQWzFlW/nTs9EVIA==", + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.0", + "@nestjs/microservices": "^12.0.0", + "@nestjs/platform-express": "^12.0.0", + "@nestjs/websockets": "^12.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-node": { + "version": "12.1.3", + "resolved": "file:../../vendor/nestjs-platform-node-12.1.3.tgz", + "integrity": "sha512-pQA9kVun/H/pWiZr+wq9OUePrZBdCAVUVLNJTFELpTwK3E7g/RAX40BWAaXkjRlDivdXAFL2/8hhhJqO8njrEA==", + "license": "MIT", + "dependencies": { + "content-type": "2.1.0", + "cookie": "0.7.2", + "cookie-signature": "1.2.2", + "cors": "2.8.6", + "encodeurl": "2.0.0", + "fast-querystring": "1.1.2", + "find-my-way": "9.9.0", + "mime-types": "3.0.2", + "multer": "2.4.0", + "on-finished": "2.4.1", + "path-to-regexp": "8.4.2", + "qs": "6.15.3", + "secure-json-parse": "4.1.0", + "send": "1.2.1", + "serve-static": "2.2.1", + "tslib": "2.8.1", + "type-is": "2.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.0", + "@nestjs/core": "^12.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/file-type": { + "version": "22.1.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.1.1.tgz", + "integrity": "sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/find-my-way": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", + "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/strtok3": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.4.1.tgz", + "integrity": "sha512-MeSp4vHXiZRXM9K0rSWqTvXMU+YzmGICc5+briMVK+RY8qQ1GRTffFYVpK6cSgV/ueImFIEh3uCJJD3xBh1TaQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", + "integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/apps/nest-node-probe/package.json b/apps/nest-node-probe/package.json new file mode 100644 index 0000000..3a92e40 --- /dev/null +++ b/apps/nest-node-probe/package.json @@ -0,0 +1,24 @@ +{ + "name": "nest-node-probe", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js" + }, + "dependencies": { + "@nestjs/common": "file:../../vendor/nestjs-common-12.1.3.tgz", + "@nestjs/core": "file:../../vendor/nestjs-core-12.1.3.tgz", + "@nestjs/platform-node": "file:../../vendor/nestjs-platform-node-12.1.3.tgz", + "content-type": "2.1.0", + "encodeurl": "2.0.0", + "fast-querystring": "1.1.2", + "path-to-regexp": "8.4.2", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.2" + }, + "devDependencies": { + "@types/node": "24.19.2", + "typescript": "5.9.3" + } +} diff --git a/apps/nest-node-probe/server.ts b/apps/nest-node-probe/server.ts new file mode 100644 index 0000000..b5aba3b --- /dev/null +++ b/apps/nest-node-probe/server.ts @@ -0,0 +1,64 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { RouteTable } from '@nestjs/platform-node/router/route-table' +import { NodeRequest, NodeResponse } from '@nestjs/platform-node/adapters/node-request' +import { json } from '@nestjs/platform-node/body-parser/body-parser' + +class AppService { + hello(): string { + return 'Hello Nest!' + } + user(id: string): { id: string; name: string } { + return { id, name: `user-${id}` } + } +} + +const service = new AppService() +const routes = new RouteTable() +const parseJson = json() + +routes.add('GET', '/', (req: NodeRequest, res: NodeResponse) => { + res.send(service.hello()) +}) +routes.add('GET', '/json', (req: NodeRequest, res: NodeResponse) => { + res.json({ hello: 'world' }) +}) +routes.add('GET', '/users/:id', (req: NodeRequest, res: NodeResponse) => { + const id = req.params.id + res.json(service.user(typeof id === 'string' ? id : id.join('/'))) +}) +routes.add('POST', '/echo', (req: NodeRequest, res: NodeResponse, next: (err?: unknown) => void) => { + parseJson(req, res, (err?: unknown) => { + if (err !== undefined) return next(err) + res.json(req.body) + }) +}) + +function notFound(req: NodeRequest, res: NodeResponse): void { + res.status(404).json({ message: `Cannot ${req.method} ${req.path}`, error: 'Not Found', statusCode: 404 }) +} + +function failed(err: unknown, req: NodeRequest, res: NodeResponse): void { + console.error(`failed ${req.method} ${req.path}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`) + const status = typeof err === 'object' && err !== null && 'status' in err && typeof err.status === 'number' ? err.status : 500 + res.status(status).json({ statusCode: status, message: status === 500 ? 'Internal server error' : String((err as Error).message) }) +} + +const server = createServer( + { IncomingMessage: NodeRequest, ServerResponse: NodeResponse }, + (incoming: IncomingMessage, outgoing: ServerResponse) => { + const req = incoming as NodeRequest + const res = outgoing as NodeResponse + const matched = routes.lookup(req.method ?? 'GET', req.path) + if (matched === null) return notFound(req, res) + req.params = matched.params + const handler = matched.handlers[0] + try { + handler(req, res, (err?: unknown) => (err === undefined ? notFound(req, res) : failed(err, req, res))) + } catch (err) { + failed(err, req, res) + } + } +) +server.listen(3902, () => { + console.log('nest-node-probe listening on http://127.0.0.1:3902') +}) diff --git a/apps/nest-node-probe/tsconfig.json b/apps/nest-node-probe/tsconfig.json new file mode 100644 index 0000000..1d973f8 --- /dev/null +++ b/apps/nest-node-probe/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "." + }, + "include": ["server.ts"] +} diff --git a/scripts/test-error-subclass.mjs b/scripts/test-error-subclass.mjs new file mode 100644 index 0000000..492c221 --- /dev/null +++ b/scripts/test-error-subclass.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') +const output = resolve(root, 'apps/nest-node-probe/dist') +const source = resolve(root, 'apps/nest-node-probe/correctness/error-subclass.ts') +const executable = resolve(output, 'error-subclass') +const options = { cwd: root, encoding: 'utf8', env: { ...process.env, TMPDIR: output } } +mkdirSync(output, { recursive: true }) + +execFileSync(process.execPath, [resolve(root, 'scripts/build.mjs'), source, '--out', output, '--exe', executable], { + ...options, + stdio: 'inherit' +}) +const expected = spawnSync(process.execPath, [source], { ...options, timeout: 15000 }) +const actual = spawnSync(executable, [], { ...options, timeout: 15000 }) +assert.equal(expected.status, 0, `node status\n${expected.stderr}`) +assert.equal(actual.status, 0, `compiled status\n${actual.stderr}`) +const expectedLines = expected.stdout.trim().split('\n') +const actualLines = actual.stdout.trim().split('\n') +assert.equal(actualLines.length, expectedLines.length, `line count\n--- node\n${expected.stdout}\n--- compiled\n${actual.stdout}`) +for (let i = 0; i < expectedLines.length; i++) assert.equal(actualLines[i], expectedLines[i], `line ${i + 1}`) +assert.ok(expectedLines.includes('caught instanceof Error true'), 'the oracle covered the thrown subclass') +console.log(`ERROR_SUBCLASS_PARITY_OK:${expectedLines.length}`) diff --git a/scripts/test-json-undefined.mjs b/scripts/test-json-undefined.mjs new file mode 100644 index 0000000..7ccfb83 --- /dev/null +++ b/scripts/test-json-undefined.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') +const output = resolve(root, 'apps/nest-node-probe/dist') +const source = resolve(root, 'apps/nest-node-probe/correctness/json-undefined.ts') +const executable = resolve(output, 'json-undefined') +const options = { cwd: root, encoding: 'utf8', env: { ...process.env, TMPDIR: output } } +mkdirSync(output, { recursive: true }) + +execFileSync(process.execPath, [resolve(root, 'scripts/build.mjs'), source, '--out', output, '--exe', executable], { + ...options, + stdio: 'inherit' +}) +const expected = spawnSync(process.execPath, [source], { ...options, timeout: 15000 }) +const actual = spawnSync(executable, [], { ...options, timeout: 15000 }) +assert.equal(expected.status, 0, `node status\n${expected.stderr}`) +assert.equal(actual.status, 0, `compiled status\n${actual.stderr}`) +const expectedLines = expected.stdout.trim().split('\n') +const actualLines = actual.stdout.trim().split('\n') +assert.equal(actualLines.length, expectedLines.length, `line count\n--- node\n${expected.stdout}\n--- compiled\n${actual.stdout}`) +for (let i = 0; i < expectedLines.length; i++) assert.equal(actualLines[i], expectedLines[i], `line ${i + 1}`) +assert.ok(expectedLines.includes('field undefined === undefined true'), 'the oracle covered the undefined field') +console.log(`JSON_UNDEFINED_PARITY_OK:${expectedLines.length}`)