feat(node): add a native node http platform adapter (#18063)

* feat(node): add a native node http platform adapter

Add @nestjs/platform-node, an HTTP adapter built directly on the
node:http and node:https modules. Routes use the same path syntax as
with the Express adapter and are matched through a find-my-way radix
tree; middleware runs in an Express-compatible stack. Body parsing,
CORS, static assets and template rendering are built in.

* test(node): run the shared integration suites against the adapter

* chore: add node and nest-node targets to the benchmarks

* feat(node): limit route parameters to 100 characters

As with Fastify, a route no longer matches when one of its parameters
is longer than 100 characters (decoded): the request falls through to
the next matching route, or gets a 404. Wildcards are not limited.

* feat(node): add the multer file upload interceptors

Ship FileInterceptor, FilesInterceptor, FileFieldsInterceptor,
AnyFilesInterceptor, NoFilesInterceptor and MulterModule with the
package, as @nestjs/platform-express does, instead of borrowing them
from it.

* docs(node): add the package readme

* test(node): map @nestjs/platform-node in the integration tsconfigs

* fix(node): match routes that differ only by parameter names

Routes of the same shape registered with different parameter names
(GET /users/:id for one version, GET /users/:userId for another) now
share one route and pass control to each other through next(), as with
Express, and each handler sees the parameter names of its own route.
Text right after a parameter ("/users/:id@profile") no longer ends up
in the parameter's name.

* test(node): listen on 127.0.0.1 in the node adapter specs

Use listenOnLoopback() in the Node adapter's own supertest specs, as

* fix(node): fix edge cases in responses, routing and body parsing

- next(null), next(false) and other falsy values are no errors, as
  with Express, so that "cb(err => next(err))" works
- req.path is the path the router matches (decoded, absolute-form
  targets resolved), so that middleware checking it sees that path
- an empty 204, 205 or 304 reply drops its Content-Type, as does
  sendStatus() for those statuses; a StreamableFile error response
  sends its own Content-Length rather than the file's
- res.type() looks up extensions as Express does, send() and json()
  send a Content-Length in answer to HEAD requests, and a Referrer
  header wins over Referer
- body parsers call next() when the client is already gone, report a
  mid-body abort as "request.aborted", validate parameterLimit and
  depth, take a null limit for the default, keep the status of errors
  thrown by verify, reject whitespace-only JSON, drop a UTF-8 BOM, and
  reject "__proto__" keys in urlencoded bodies with 'error'

* test(node): add unit tests

Cover the adapter, the request and response helpers, the router and
its route tables, the body parsers and the view renderer.

* chore: write the raw node benchmark headers in a single call

* fix(node): decode windows-1252 bodies the same on every node version

TextDecoder in some Node.js 22 and 24 releases (22.15, 24.4 among them) decodes windows-1252, and labels mapped to it such as cp1252, as ISO-8859-1, so bytes 0x80-0x9F came out as C1 controls instead of characters like the euro sign. Map those bytes ourselves.

* feat(node): add res.redirect(), res.locals and req.is()

Express-compatible helpers that common middleware relies on (passport and OAuth flows redirect, middleware passes data along in res.locals, some check the request type with req.is()). res.redirect() encodes the Location, negotiates a plain text or HTML note from Accept and adds Accept to Vary, matching Express 5.

* fix(node): match routes with the method set by earlier middleware

The router read req.method once per request; Express reads it for every layer, so middleware such as method-override could not change which route runs. Read it per layer.

* feat(node): add res.append(), res.cookie() and req.app for middleware

Popular Express middleware calls these: express-rate-limit sets its headers with res.append() and reads req.app.get('trust proxy'), csrf-csrf sets its cookie with res.cookie(). res.clearCookie() comes along. They follow Express 5, using the same cookie and cookie-signature packages; req.app only reports the adapter's fixed settings ('trust proxy' is false).

* fix(node): match wildcard routes and fall through to other routes

- a route such as "/admin/{*path}" or "/admin/{:id}" no longer matches "/admin" (find-my-way's ignoreTrailingSlash matched its "/admin/" variant there), so it matches the same paths as its middleware, as with Express
- once every handler of the most specific route calls next(), the other routes matching the path run, each with its own parameters, so a version- or host-filtered handler that does not apply falls through to ":id" as with Express and Fastify
- a wildcard is split before it is decoded, keeping an encoded "/" within its segment

* fix(node): pass errors from middleware on to exception filters as is

mapException() turned http-errors-style errors into a new HttpException, so a @Catch(MyError) filter and anything reading the error's code (EBADCSRFTOKEN, ...) no longer saw them. Return them unchanged, as the Express adapter does; Nest's exception filter answers them with their status and message as before.

* fix(node): pass the views directory to view engines such as hbs

res.render() in Express passes its settings, including "views" and "view engine", to the engine's __express(); hbs reads "views" to find layouts, so @Render() with hbs failed with a 500. Pass them the same way.

* fix(node): align the body parsers with body-parser 2

- read the charset with content-type 2, as body-parser does, so a Content-Type with a trailing ";" keeps its charset
- accept limit: Infinity and the tb/pb units (NaN and negative limits still throw)
- add the urlencoded defaultCharset, charsetSentinel and interpretNumericEntities options
- pick the byte order of "utf-16" bodies from the byte order mark, or else the content, as iconv-lite does

* fix(node): send binary, redirects and 404s as other adapters do

- send typed arrays and DataViews as binary, as Buffers already were (as Fastify does)
- @Redirect() sends the note and "Vary: Accept" res.redirect() sends, as with Express
- res.send() sends numbers and booleans as JSON, as Express 5 does
- res.json() sets no Content-Type when serializing the body throws
- the 404 for a request outside the global prefix echoes the path as received rather than decoded, with "X-Content-Type-Options: nosniff" and "Content-Security-Policy: default-src 'none'", as Express' final handler does

* test(node): expect the bare path to miss an optional wildcard route

As with Express, "/v1/users/" reaches the converted "{*path}" catch-all but "/v1/users" does not; the spec had encoded the old Node adapter behavior.

* fix(node): match every path case-sensitively

find-my-way routes were case-sensitive while the routes matched with path-to-regexp (wildcards, optional groups) and the middleware were not, so "GET /CATS" ran the middleware of "cats" and then got a 404, and "/ADMIN/x" reached the "admin" catch-all. Match all of them case-sensitively, as find-my-way does by default (and Fastify); the case-insensitive alternative would lowercase every request path.

* feat(node): make the parameter limit configurable and answer 414

new NodeAdapter({ maxParamLength }) sets the longest route parameter a route matches (100 by default, as with Fastify). A request whose parameter is longer still falls through to another matching route, such as a sibling wildcard; when none matches, it now gets a 414 (URI Too Long) rather than a 404, as with Fastify. The 414 goes through Nest's exception handling like other errors, so filters can catch it.

* test: compare the http adapters on routing, errors, views and parsing

A new integration suite runs the same applications and requests on the Express, Fastify and Node adapters, where they support the feature, with the same expectations:
- wildcard and optional-group routes, and which requests their middleware sees
- falling through to less specific routes from version filters (header, media type, custom, neutral, several versions, all methods, global prefix, URI) and host filters
- errors raised by middleware, as @Catch() filters and Nest's exception handling see them
- @Render() with an engine that finds partials through the views directory
- @Redirect(), res.send() and binary bodies
- body parser charsets, limits and urlencoded charset options
- Node specifics: the route parameter limit and its 414, case-sensitive paths, and the final 404 outside the global prefix

* feat(node): let responses send files as express does

Adds Express' res.sendFile() on the same send package: Content-Type, Content-Length, Last-Modified, ETag, ranges and 304s, the root, headers, maxAge, dotfiles and etag options, and a callback for the outcome. Without a callback, a failure goes to the error-handling middleware and Nest's exception filters, as Express does through next(): a missing file is a 404 and a directory answers like an unmatched route. The adapter reaches its router from the request's server for that, so nothing is stored per request.

@nestjs/serve-static calls it for its index.html fallback. send and on-finished were already loaded through serve-static; an interleaved wrk comparison (4 rounds), the router microbenchmark and the import time show no difference.

Also sends the JSON error body of a reply whose Content-Type was not JSON as application/json; charset=utf-8, as Express ends up doing.

* test(node): cover the shutdown 503 while the hooks run

The adapter already answered 503 with Connection: close once beforeClose() ran, from the first layer of the router, before Nest middleware, and destroyed connections only in close(). Cover what #18101 covers for Fastify's return503OnClosing: a request during a slow onModuleDestroy() gets the 503 without reaching middleware, requests keep being served without the option, and forceCloseConnections leaves kept-alive sockets open during the hooks.

The 503 now says Content-Type: text/plain; charset=utf-8, like Fastify's and the adapter's final handler.

* fix(node): match excluded routes like the router does

Report through HttpServer.getRouteMatchingOptions() that the router
matches paths case-sensitively and ignores a trailing slash, so that
MiddlewareConsumer.exclude() compiles the excluded routes the same way.

* fix(node): expose package.json in the exports map

As the other packages do since #18122.

* chore(node): align the package version with the 12.1.3 release

The v12.1.3 release bumped the exact @nestjs/common and @nestjs/core
devDependencies of the other packages. Left at 12.1.2 here, they no
longer matched the workspace once master was merged in, so npm
installed both from the registry under packages/platform-node and the
adapter ran against its own copies: its HttpException, StreamableFile
and VERSION_NEUTRAL were not the ones the core checks for.

* Update packages/platform-node/body-parser/body-parser.ts

Co-authored-by: Roman Khegay <rk@garuna.dev>

* perf(node): parse a repeated content-type charset once

Clients send the same Content-Type with every request, so the charset of the last header parsed is kept instead of parsing the header again. Media types without parameters still return before it.

Co-authored-by: Roman Khegay <rk@garuna.dev>

* test(node): cover a content-type that changes between requests

The same parser reads Latin-1, UTF-8, no charset and Latin-1 again, so a charset cache that misses a change fails.

* Update packages/platform-node/body-parser/body-parser.ts

Co-authored-by: Roman Khegay <rk@garuna.dev>

* Revert "Update packages/platform-node/body-parser/body-parser.ts"

This reverts commit 88e446db1b, which applied the charset cache suggestion a second time on top of 13619b662e, declaring the cache twice and leaving a partial copy of getCharset().

* chore(node): align the package version with the 12.2.0 release

As every sibling did in the release, so that the workspace links the
adapter to this checkout's @nestjs/common and @nestjs/core.

* fix(node): answer request targets that are not a path with 404

Node.js accepts request targets such as "*admin/users", which
find-my-way routes as "/admin/users", while no middleware path matches
them, so the route ran without its middleware. "OPTIONS *" got the
"OPTIONS /" route. RouteTable.lookup() now matches no path that does not
start with "/", so these get the not-found handler, as on Express and
Fastify (a03c7a1db).

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): send the standard body for an exception wrapping an error

JSON.stringify() sends the enumerable properties of an Error, e.g. the
request config of an HTTP client error, so an Error body with an error
status now gets the standard body of the status, as on Express
(876a182c6). An object error body without a "statusCode" of its own also
goes out as JSON when the route set another Content-Type (7dc6e349d).

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): handle a failed streamable file and one for a head request

A source that failed before the reply (e.g. while an interceptor
awaited) has already emitted its "error" event, so the request hung until
the client gave up; it now goes to the error handler of the file, as on
Express and Fastify (1933cc9ed). A HEAD response no longer pipes the whole
source, which Node discards without applying backpressure; it ends once
the source is readable (8b8e8396f). The specs expect the "Bad Request" the
default error handler now sends (2f28d91af).

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): run wildcard middleware on "/api/" for a named wildcard

The exact "/api" entry skipped "/api/" so that the middleware would not
run twice next to "/api/{*path}", which left "/api/*path" (one more
segment needed) running it 0 times there. The core now runs a middleware
for the first of its paths that matches a request only (fb8c2cac7), so
the entry is registered as is, as on Express (b75244ffd).

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): parse quoted media type version parameters

The copy of getMediaTypeVersion() is master's (c8923bd7c): quoted
values are unescaped, and "," or ";" inside quotes no longer split the
header, so `Accept: application/json;v="2"` matches version 2, as on
Express and Fastify.

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): copy the multer fixes of platform-express

Copied again from platform-express: the limit fields of the options
(#18074), the default fieldNestingDepth and fieldArrayIndexLimit
(94fcc9377), removing the uploads of a request a pipe rejected
(acb98de89), and a route's dest replacing the module's storage
(e075cda3c). The parity spec checks the upload directory of Node too.

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): set the security headers on router error responses

The router answers a request target it cannot decode (a malformed URL,
400) without running any middleware, so the response had none of the
headers set by useSecurityHeaders(). NodeRouter now reports such a
request to onRejectedTarget, where the adapter runs the security hook
for its headers, as the FastifyAdapter does for the errors its router
answers (c04a9182b).

* test(node): expect the sse error event and csrf answers of master

The SSE error event carries "Internal server error" (97ab04494). Node
serves "/api/hooks/github/" with the "github" route, as Express does, so
an exclusion of "hooks/github/*" still leaves it to CSRF protection: a 403
(7b01d0e7a9).

* fix(node): write the html redirect note as express 5.3 does

Express 5.3 (in master's lockfile) sends the HTML note of res.redirect()
with a doctype, a title and a body. The Node response does the same, and
the spec shared by both expects it.

* test(node): wrap the source in a streamable file before destroying it

The source emitted 'error' before StreamableFile attached its listener,
so Vitest reported an uncaught exception and the unit test jobs exited
with code 1 although every test passed. The Express spec on master
creates the file first as well.

* fix(node): map 4xx http-errors errors to http exceptions

As master now does for Express (#18214, #18232) and Fastify (#18239): an
http-errors-style client error, such as a 413 from a body parser or a 403
a middleware passes to next(), reaches exception filters as an
HttpException with its status and the original error as its cause, so
@Catch(HttpException) filters run for it. A 5xx one stays unmapped, so the
built-in exception filter still logs it. The shared middleware errors spec
expects the new behavior on all three adapters.

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): end a head response for an empty streamable file

A source that has no data, or has already ended, never emits 'readable'
for a listener added that late, so a HEAD request for it never got an
answer. As master now does for Express (#18234), the response also ends on
'end', or right away when the source has already ended.

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): abort the response when a streamable file fails midway

Since #18220, the default StreamableFile error handler destroys a response
whose headers are out, but the response object the Node adapter handed it
had no destroy(), so it fell back to end() and a file that failed midway
reached the client as a complete 200. The response now has destroy().

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): handle async request and response hook errors

A request hook that threw or rejected was fire and forget, so an async hook
throwing an UnauthorizedException took the process down with an unhandled
rejection, done(err) still routed the request, and a second done() ran the
handler twice. As the ExpressAdapter does since #18110, the first
completion routes the request or hands its error to the error handlers,
and later errors are logged; a failing response hook is logged too.

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): resume the error handlers after the one that failed

handleError() always started over from the first layer, so an exception
filter answering with res.sendFile() looped without end when that call
failed as well (a global @Catch() error page and "Range: bytes=99999-" ran
the filter about 44k times in 1.5 s). Like Express' next() does, it now
resumes after the error handler that ran last, and the final handler
answers with a 416 there.

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): pass res.locals to the views

The res.locals a middleware set never reached a @Render() view, while
Express and @fastify/view merge them into the view data. The data the
handler returns still wins over them, and they win over the engine's
defaultContext, as with Express' res.render().

Co-authored-by: Roman Khegay <rk@garuna.dev>

* fix(node): parse a body with an empty content-encoding or charset

An empty Content-Encoding header got a 415 and an empty charset parameter
was taken as a charset, while Express and Fastify parse such bodies:
body-parser reads an empty encoding as "identity" and an empty charset as
the default one, and so do the Node adapter's parsers now.

Co-authored-by: Roman Khegay <rk@garuna.dev>

* perf(node): skip the qs decoder for form tokens without escapes

The decoder of "qs" replaces "+" and calls decodeURIComponent() for every
key and value of a urlencoded body. A token without "%" or "+" stays as it
is, so it is now returned right away: qs.parse() of a 10-field form runs
1.7x as fast when no field is escaped, and 1.3x as fast with three escaped
fields, with the same output.

Co-authored-by: Roman Khegay <rk@garuna.dev>

* perf(node): decode middleware path parameters only when escaped

decodeURIComponent() is a call into the runtime (about 80 ns) even for a
value without any "%" to decode, which is most of them. The parameters of
middleware paths are now returned as they are in that case.

Co-authored-by: Roman Khegay <rk@garuna.dev>

* test(node): run the cors and middleware spec on the node adapter

The spec #18217 added for Fastify passes on the Node adapter as it is: enableCors() registers the cors middleware ahead of Nest middleware there, as on Express.

* fix(node): leave the text before a wildcard out of it

A wildcard after text in its segment ("v-*rest", or "ab*cd") also matched
that text alone, so GET /files/v- ran the handler without the middleware
of the route, and the value kept the text (['v-abc', 'def']). The
wildcard now starts after it, as with path-to-regexp and Express, and the
route stays in the tree, ahead of a parameter of the same segment. After
a parameter ("/tags/:id-*rest"), where it starts varies, so such a route,
which never matched, is now matched with path-to-regexp.

* fix(node): match routes for every method by specificity

Routes for every method lived in a tree of their own, merged with the
method routes by registration order, so an @All('*') fallback served
GET /cats of a module registered after it. Each method now has one tree,
which also holds the routes for every method and, for HEAD, the GET
routes, so the most specific route wins whatever its method, as with
Fastify. A single find() replaces the HEAD and @All() lookups.

* fix(node): keep catch-all routes with an optional wildcard in the tree

A route with a variant ending with "/" ("/files/{*path}", what the legacy
"files/*" becomes) was only matched with path-to-regexp once the tree
found nothing, so it lost to any other route: GET /files/readme went to
":section/:id", and GET /api/users to an earlier "{*path}" SPA route. Its
other variants now go in the tree, and path-to-regexp only matches the
paths ending with "/", with its own parameters there, as with Express.

* fix(node): try the other routes when one leaves a parameter empty

find-my-way stops at the most specific route, even when it then leaves a
parameter empty, which path-to-regexp and Express never match. For GET
/ext/.gitignore it picked the "/ext/:file.:ext" variant of
"/ext/:file{.:ext}" with an empty "file", and the lookup gave a 404 while
Express answers with { file: '.gitignore' }. Likewise, GET /docs/.env
missed "/docs/*path" next to "/docs/:name.:ext", and GET /files/v-
missed "/files/:id" next to "/files/v-*rest". The first route that
path-to-regexp matches, in registration order, now answers such a
request, as with Express, and "/users//profile" still matches no
"/users/:id/profile" route.

* perf(node): skip the middleware layers and routes of other prefixes

Nest registers a middleware bound to a controller once per route path, so
forRoutes() on 100 controllers ran about 500 path matchers on every
request. lookupRest() ran the matcher of every route after a version miss
(613 RegExp executions with 1000 routes), and so did lookup() for a path
leaving a parameter empty. A layer or route whose literal prefix the path
does not start with now skips the ones registered right after it that
share that prefix, with a single startsWith(): from 30.1k to 69.4k req/s
for the forRoutes() case, and from 19.6k to 48.9k req/s for the version
miss.

* perf(node): decode route parameters only when escaped

As for the middleware paths, decodeParam() and decodeSegment() of the
route table now return a value without "%" as it is, so a wildcard route
no longer calls decodeURIComponent() for each segment of the path (about
5% more req/s for a 5-segment one).

* fix(node): route a path ending with "/" as the path without it

A route with a variant ending with "/" ("/files/{*path}", what the legacy
"files/*" becomes) took every path ending with "/" from the routes
registered after it, as its whole path-to-regexp pattern matched them: GET
/files/readme went to "/files/readme", but GET /files/readme/ to the
catch-all. It now only takes the paths its variants ending with "/" match
("/files/"), so "/admin/" still goes to an "/admin/{*path}" registered
before "/admin", as with Express, and any other path ending with "/" goes
to the route it would go to without it, as exclude() assumes.

* perf(node): try only the routes that can match an empty segment

When find-my-way's route leaves a parameter empty, lookup() tried every
route with path-to-regexp, so GET /acme/res500//profile took about 190 µs
with 2000 "/:tenant/..." routes, and 15 µs with distinct prefixes. A
parameter or wildcard that is a whole segment of its route is only empty
for "/" or a path with an empty segment ("//"), which only routes with a
wildcard, a variant ending with "/" or "//" can match: those are now the
only ones tried, and none for "/" (about 200 ns either way). Routes with
a segment holding text and a parameter ("/ext/:file.:ext") or a wildcard
after text still try every route.

* test(node): cover trailing bytes after a compressed body

Ports body-parser's tests for bytes that arrive after a compressed frame,
which zlib ends the decompression stream on since Node.js 11: the request
still finishes, a late decoder error is swallowed instead of ending the
process, and a body rejected for its charset never creates a decoder. The
parser already keeps an error listener on the decoder once it has read the
body; without it, these tests report the decoder's error as uncaught.

* fix(node): copy the multer fixes of platform-express again

The multer interceptors are copies of platform-express' (the file upload
parity spec checks it). Two fixes landed there since: a multipart request
with an unsupported content type is answered with a 415 (#18264), and
isRejectedByPipes() is no longer a named import from @nestjs/core/internal,
which made the package fail to load with a core older than 12.2 (#18251).

* fix(node): update multer to 2.5.0 and find-my-way to 9.10.0

As platform-express (#18268) and platform-fastify pin them. multer 2.5.0
fixes GHSA-q7qp-rjg7-q26m: a request aborted while an asynchronous
fileFilter is pending could crash the process.

* test(node): run master's new spec cases on the node adapter

The Node twins of the disconnect specs now wait for the producer teardown
as the Express and Fastify ones do since #18260, which fixes the same
off-by-one tick under load. getUrl() is checked for an https server
(#18252), and the specs of #18276 for middleware on routes that differ
only in their version run on the Node adapter too; both pass as they are.

* feat(node): add an option to trust proxies

Behind a reverse proxy, req.ip was always the proxy's address and
req.protocol "http" behind one that terminates TLS, so the throttler
counted every client as one, express-session would not set secure
cookies, and passport built http:// callback URLs. As Express' "trust
proxy" setting and Fastify's option of the same name, new NodeAdapter({
trustProxy }) takes true, a hop count, addresses or subnets, or a
function. For a request from a trusted proxy, req.ip, req.ips,
req.protocol, req.secure and req.hostname are read from the
X-Forwarded-* headers the way Express reads them, and
req.app.get('trust proxy') returns the setting. Unlike Fastify, which no
longer trusts a hop count, the adapter trusts it as Express does.

Without the option, the requests are NodeRequests as before: the getters
that read the headers belong to a subclass the option creates, so the
default requests run the same code. req.ips is new (empty there), and
req.protocol is typed as a string, as with Express.

* feat(node): add an option to parse the query string

The Node adapter parses query strings with fast-querystring into flat
keys, as Express and Fastify do by default, but offered no way to parse
nested objects and arrays, which Express gets from its "query parser"
setting and Fastify from its querystringParser option. As with Fastify,
new NodeAdapter({ querystringParser }) takes a function the query string
is passed to (without the "?", and "" when the URL has none), such as
str => qs.parse(str). Without it, requests run the same code as before.

* fix(node): trust no proxy for a hop count, as fastify does

A hop count cannot tell the socket's peer from a client that sends its
own X-Forwarded-For, so Fastify 5.12.1 and later trust no proxy for one,
and leave numbers out of the type of its trustProxy option. The Node
adapter now does the same, instead of trusting that many hops as Express
does. Addresses, subnets, true and functions are unchanged.

---------

Co-authored-by: Roman Khegay <rk@garuna.dev>
This commit is contained in:
Kamil MysliwiecandRoman Khegay authored and GitHub committed 2026-10-27 09:59:47 +01:00
1 parent 10adb73c57
commit 5f4075074f
138 files changed
+22634 -17

No files matched your search

+1
View File
@@ -34,6 +34,7 @@
"microservices",
"express",
"fastify",
"node",
"socket.io",
"ws",
"testing",
+1
View File
@@ -283,6 +283,7 @@ The following is the list of supported scopes:
- **microservices**: for changes made on `packages/microservices` directory
- **express**: for changes made on `packages/platform-express` directory
- **fastify**: for changes made on `packages/platform-fastify` directory
- **node**: for changes made on `packages/platform-node` directory
- **socket.io**: for changes made on `packages/platform-socket.io` directory
- **ws**: for changes made on `packages/platform-ws` directory
- **testing**: for changes made on `packages/testing` directory
@@ -24,6 +24,7 @@ const packageRoots = new Map<string, string>([
'@nestjs/platform-fastify',
path.join(workspaceRoot, 'packages/platform-fastify'),
],
['@nestjs/platform-node', path.join(workspaceRoot, 'packages/platform-node')],
[
'@nestjs/platform-socket.io',
path.join(workspaceRoot, 'packages/platform-socket.io'),
+3 -1
View File
@@ -1,6 +1,7 @@
import type { INestApplication, NestApplicationOptions } from '@nestjs/common';
import { ExpressAdapter } from '@nestjs/platform-express';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import { createRequire } from 'module';
import request from 'supertest';
@@ -36,11 +37,12 @@ function emulateCookieParser(req: any, secret: string) {
}
}
type AdapterFactory = () => ExpressAdapter | FastifyAdapter;
type AdapterFactory = () => ExpressAdapter | FastifyAdapter | NodeAdapter;
const adapters: [string, AdapterFactory][] = [
['express', () => new ExpressAdapter()],
['fastify', () => new FastifyAdapter()],
['node', () => new NodeAdapter()],
];
async function createApp(
+3 -1
View File
@@ -28,7 +28,9 @@
"@nestjs/platform-express": ["../../packages/platform-express"],
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-fastify": ["../../packages/platform-fastify"],
"@nestjs/platform-fastify/*": ["../../packages/platform-fastify/*"]
"@nestjs/platform-fastify/*": ["../../packages/platform-fastify/*"],
"@nestjs/platform-node": ["../../packages/platform-node"],
"@nestjs/platform-node/*": ["../../packages/platform-node/*"]
}
},
"include": ["src/**/*", "e2e/**/*"],
+2
View File
@@ -10,6 +10,7 @@ import {
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { NodeAdapter } from '@nestjs/platform-node';
import request from 'supertest';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
@@ -51,6 +52,7 @@ class AppModule implements NestModule {
const adapters = [
['Express', () => new ExpressAdapter()],
['Fastify', () => new FastifyAdapter()],
['Node', () => new NodeAdapter()],
] as const;
describe.each(adapters)(
+188
View File
@@ -0,0 +1,188 @@
import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
describe('Node Cors', () => {
let app: NestNodeApplication;
const configs = [
{
origin: 'example.com',
methods: 'GET',
credentials: true,
exposedHeaders: ['foo', 'bar'],
allowedHeaders: ['baz', 'woo'],
maxAge: 123,
},
{
origin: 'sample.com',
methods: 'GET',
credentials: true,
exposedHeaders: ['zoo', 'bar'],
allowedHeaders: ['baz', 'foo'],
maxAge: 321,
},
];
describe('Dynamic config', () => {
describe('enableCors', () => {
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
);
let requestId = 0;
const configDelegation = function (req, cb) {
const config = configs[requestId];
requestId++;
cb(null, config);
};
app.enableCors(configDelegation);
await listenOnLoopback(app);
});
it(`should add cors headers based on the first config`, async () => {
return request(app.getHttpServer())
.get('/')
.expect('access-control-allow-origin', 'example.com')
.expect('vary', 'Origin')
.expect('access-control-allow-credentials', 'true')
.expect('access-control-expose-headers', 'foo,bar')
.expect('content-length', '0');
});
it(`should add cors headers based on the second config`, async () => {
return request(app.getHttpServer())
.options('/')
.expect('access-control-allow-origin', 'sample.com')
.expect('vary', 'Origin')
.expect('access-control-allow-credentials', 'true')
.expect('access-control-expose-headers', 'zoo,bar')
.expect('access-control-allow-methods', 'GET')
.expect('access-control-allow-headers', 'baz,foo')
.expect('access-control-max-age', '321')
.expect('content-length', '0');
});
afterAll(async () => {
await app.close();
});
});
describe('Application Options', () => {
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
let requestId = 0;
const configDelegation = function (req, cb) {
const config = configs[requestId];
requestId++;
cb(null, config);
};
app = module.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
{
cors: configDelegation,
},
);
await listenOnLoopback(app);
});
it(`should add cors headers based on the first config`, async () => {
return request(app.getHttpServer())
.get('/')
.expect('access-control-allow-origin', 'example.com')
.expect('vary', 'Origin')
.expect('access-control-allow-credentials', 'true')
.expect('access-control-expose-headers', 'foo,bar')
.expect('content-length', '0');
});
it(`should add cors headers based on the second config`, async () => {
return request(app.getHttpServer())
.options('/')
.expect('access-control-allow-origin', 'sample.com')
.expect('vary', 'Origin')
.expect('access-control-allow-credentials', 'true')
.expect('access-control-expose-headers', 'zoo,bar')
.expect('access-control-allow-methods', 'GET')
.expect('access-control-allow-headers', 'baz,foo')
.expect('access-control-max-age', '321')
.expect('content-length', '0');
});
afterAll(async () => {
await app.close();
});
});
});
describe('Static config', () => {
describe('enableCors', () => {
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
);
app.enableCors(configs[0]);
await listenOnLoopback(app);
});
it(`CORS headers`, async () => {
return request(app.getHttpServer())
.get('/')
.expect('access-control-allow-origin', 'example.com')
.expect('vary', 'Origin')
.expect('access-control-allow-credentials', 'true')
.expect('access-control-expose-headers', 'foo,bar')
.expect('content-length', '0');
});
afterAll(async () => {
await app.close();
});
});
describe('Application Options', () => {
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
{
cors: configs[0],
},
);
await listenOnLoopback(app);
});
it(`CORS headers`, async () => {
return request(app.getHttpServer())
.get('/')
.expect('access-control-allow-origin', 'example.com')
.expect('vary', 'Origin')
.expect('access-control-allow-credentials', 'true')
.expect('access-control-expose-headers', 'foo,bar')
.expect('content-length', '0');
});
afterAll(async () => {
await app.close();
});
});
});
});
+2
View File
@@ -27,6 +27,8 @@
"@nestjs/testing/*": ["../../packages/testing/*"],
"@nestjs/platform-express": ["../../packages/platform-express"],
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-node": ["../../packages/platform-node"],
"@nestjs/platform-node/*": ["../../packages/platform-node/*"],
"@nestjs/platform-socket.io": ["../../packages/platform-socket.io"],
"@nestjs/platform-socket.io/*": ["../../packages/platform-socket.io/*"],
"@nestjs/platform-ws": ["../../packages/platform-ws"],
+9 -5
View File
@@ -13,16 +13,17 @@ import { type AdapterName, createApp, PNG, txt } from './utils.js';
type Send = (http: ReturnType<typeof request>) => request.Test;
const adapterNames = ['express', 'fastify'] as const;
const adapterNames = ['express', 'fastify', 'node'] as const;
/**
* Every scenario sends the same request to an Express application (the
* multer interceptors from @nestjs/platform-express) and to a Fastify
* application (the interceptors from @nestjs/platform-fastify/multipart),
* both built from one controller, and asserts that the responses are
* multer interceptors from @nestjs/platform-express), to a Fastify
* application (the interceptors from @nestjs/platform-fastify/multipart) and
* to a Node application (the multer interceptors from @nestjs/platform-node),
* all built from one controller, and asserts that the responses are
* identical.
*/
describe('File upload: Express (multer) and Fastify parity', () => {
describe('File upload: Express (multer), Fastify and Node parity', () => {
const apps = {} as Record<AdapterName, INestApplication>;
const dirs = {} as Record<AdapterName, string>;
@@ -62,6 +63,7 @@ describe('File upload: Express (multer) and Fastify parity', () => {
results[adapterName] = { status: res.status, body: res.body };
}
expect(results.fastify).toEqual(results.express);
expect(results.node).toEqual(results.express);
return results.express;
}
@@ -641,6 +643,7 @@ describe('File upload: Express (multer) and Fastify parity', () => {
expect(res.status).toBe(413);
expect(readdirSync(dirs.express)).toEqual([]);
expect(readdirSync(dirs.fastify)).toEqual([]);
expect(readdirSync(dirs.node)).toEqual([]);
});
it('should remove the stored files when a pipe rejects the request', async () => {
@@ -663,6 +666,7 @@ describe('File upload: Express (multer) and Fastify parity', () => {
await vi.waitFor(() => {
expect(readdirSync(dirs.express)).toEqual([]);
expect(readdirSync(dirs.fastify)).toEqual([]);
expect(readdirSync(dirs.node)).toEqual([]);
});
});
+6 -3
View File
@@ -4,10 +4,11 @@ import {
FastifyAdapter,
type NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
export type AdapterName = 'express' | 'fastify';
export type AdapterName = 'express' | 'fastify' | 'node';
export const PNG = Buffer.from(
// 1x1 transparent PNG, so FileTypeValidator's magic-number check runs for real
@@ -38,8 +39,10 @@ export async function createApp(
const moduleRef = await Test.createTestingModule({
imports: [module],
}).compile();
if (adapterName === 'express') {
const app = moduleRef.createNestApplication(new ExpressAdapter());
if (adapterName === 'express' || adapterName === 'node') {
const app = moduleRef.createNestApplication(
adapterName === 'express' ? new ExpressAdapter() : new NodeAdapter(),
);
await listenOnLoopback(app);
return app;
}
+3 -1
View File
@@ -1,6 +1,7 @@
import type { NestInterceptor, Type } from '@nestjs/common';
import * as express from '@nestjs/platform-express';
import * as fastify from '@nestjs/platform-fastify/multipart';
import * as node from '@nestjs/platform-node';
/**
* The part of the upload API both platforms share, by name and signature.
@@ -24,7 +25,8 @@ export interface UploadApi {
OptionsModule: { register(options: any): any };
}
export const uploadApis: Record<'express' | 'fastify', UploadApi> = {
export const uploadApis: Record<'express' | 'fastify' | 'node', UploadApi> = {
express: { ...express, OptionsModule: express.MulterModule },
fastify: { ...fastify, OptionsModule: fastify.MultipartModule },
node: { ...node, OptionsModule: node.MulterModule },
};
+2
View File
@@ -29,6 +29,8 @@
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-fastify": ["../../packages/platform-fastify"],
"@nestjs/platform-fastify/*": ["../../packages/platform-fastify/*"],
"@nestjs/platform-node": ["../../packages/platform-node"],
"@nestjs/platform-node/*": ["../../packages/platform-node/*"],
"@nestjs/platform-socket.io": ["../../packages/platform-socket.io"],
"@nestjs/platform-socket.io/*": ["../../packages/platform-socket.io/*"],
"@nestjs/platform-ws": ["../../packages/platform-ws"],
@@ -0,0 +1,182 @@
import {
INestApplication,
Injectable,
Module,
OnModuleDestroy,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NodeAdapter } from '@nestjs/platform-node';
import * as http from 'http';
import * as net from 'net';
import { AppModule } from '../src/app.module.js';
let shutdownHookEntered: Promise<void>;
let enterShutdownHook: () => void;
let shutdownHookReleased: Promise<void>;
let releaseShutdownHook: () => void;
@Injectable()
class SlowShutdownHook implements OnModuleDestroy {
onModuleDestroy() {
enterShutdownHook();
return shutdownHookReleased;
}
}
@Module({ imports: [AppModule], providers: [SlowShutdownHook] })
class SlowShutdownModule {}
const requestSlow = (port: number, agent: http.Agent | false = false) =>
new Promise<http.IncomingMessage>((resolve, reject) => {
http
.get({ host: '127.0.0.1', port, path: '/slow', agent }, res => {
res.resume();
res.on('end', () => resolve(res));
})
.on('error', reject);
});
describe('Graceful Shutdown (Node)', () => {
let app: INestApplication;
beforeEach(() => {
shutdownHookEntered = new Promise(resolve => (enterShutdownHook = resolve));
shutdownHookReleased = new Promise(
resolve => (releaseShutdownHook = resolve),
);
});
afterEach(async () => {
// Lets a close() that is stuck on a held hook settle
releaseShutdownHook();
if (app) {
await app.close();
}
});
it('should allow in-flight requests to complete when return503OnClosing is enabled', async () => {
app = await NestFactory.create(AppModule, new NodeAdapter() as any, {
return503OnClosing: true,
logger: false,
});
await app.listen(0);
const port = app.getHttpServer().address().port;
const requestPromise = new Promise<string>((resolve, reject) => {
http
.get(
`http://localhost:${port}/slow`,
{
// Explicitly close connection after response to speed up server shutdown
headers: { Connection: 'close' },
},
res => {
let data = '';
res.on('data', c => (data += c));
res.on('end', () => resolve(data));
},
)
.on('error', reject);
});
// Wait to ensure request is processing
await new Promise(r => setTimeout(r, 100));
const closePromise = app.close();
// The in-flight request should finish successfully
const response = await requestPromise;
expect(response).toBe('ok');
await closePromise;
}, 10000);
it('should return 503 for NEW queued requests on existing connections during shutdown', async () => {
app = await NestFactory.create(AppModule, new NodeAdapter() as any, {
return503OnClosing: true,
logger: false,
});
await app.listen(0);
const port = app.getHttpServer().address().port;
// Force 1 socket to ensure queuing/reuse
const agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
// 1. Send Request A (slow) - occupies the socket
const req1 = http.get(`http://localhost:${port}/slow`, { agent });
// 2. Wait so Request A is definitely "in flight"
await new Promise(r => setTimeout(r, 100));
// 3. Trigger Shutdown (don't await yet)
const closePromise = app.close();
// Allow the microtask for prepareClose() to flush (sets isShuttingDown)
await new Promise(r => setTimeout(r, 0));
// 4. Send Request B immediately using the same agent.
const statusPromise = new Promise<number>((resolve, reject) => {
const req = http.get(`http://localhost:${port}/slow`, { agent }, res => {
resolve(res.statusCode || 0);
});
req.on('error', reject);
});
// 5. Cleanup Request A
req1.on('error', () => {});
const status = await statusPromise;
expect(status).toBe(503);
await closePromise;
agent.destroy();
}, 10000);
it('should return 503 with Connection: close while the shutdown hooks run when return503OnClosing is enabled', async () => {
app = await NestFactory.create(SlowShutdownModule, new NodeAdapter(), {
return503OnClosing: true,
logger: false,
});
let middlewareHits = 0;
app.use((_req: unknown, _res: unknown, next: () => void) => {
middlewareHits += 1;
next();
});
await app.listen(0, '127.0.0.1');
const { port } = app.getHttpServer().address() as net.AddressInfo;
const agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
expect((await requestSlow(port, agent)).statusCode).toBe(200);
expect(middlewareHits).toBe(1);
const closePromise = app.close();
await shutdownHookEntered;
// Sent on the kept-alive socket while onModuleDestroy() is still pending
const res = await requestSlow(port, agent);
expect(res.statusCode).toBe(503);
expect(res.headers.connection).toBe('close');
// Rejected before Nest middleware runs
expect(middlewareHits).toBe(1);
releaseShutdownHook();
await closePromise;
agent.destroy();
});
it('should keep serving requests while the shutdown hooks run when return503OnClosing is not set', async () => {
app = await NestFactory.create(SlowShutdownModule, new NodeAdapter(), {
logger: false,
});
await app.listen(0, '127.0.0.1');
const { port } = app.getHttpServer().address() as net.AddressInfo;
const closePromise = app.close();
await shutdownHookEntered;
expect((await requestSlow(port)).statusCode).toBe(200);
releaseShutdownHook();
await closePromise;
});
});
@@ -21,6 +21,8 @@
"@nestjs/core/*": ["../../packages/core/*"],
"@nestjs/platform-express": ["../../packages/platform-express"],
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-node": ["../../packages/platform-node"],
"@nestjs/platform-node/*": ["../../packages/platform-node/*"],
"@nestjs/testing": ["../../packages/testing"],
"@nestjs/testing/*": ["../../packages/testing/*"]
}
@@ -1,5 +1,6 @@
import {
ArgumentsHost,
Body,
Catch,
Controller,
ExceptionFilter,
@@ -17,6 +18,7 @@ import {
NestFastifyApplication,
RouteSchema,
} from '@nestjs/platform-fastify';
import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import { RawServerDefault } from 'fastify';
import createError from 'http-errors';
@@ -58,6 +60,14 @@ class SchemaController {
create() {}
}
@Controller()
class EchoController {
@Post('echo')
echo(@Body() body: unknown) {
return body;
}
}
@Controller()
class SlowController {
@Get('slow')
@@ -400,4 +410,158 @@ describe('Error messages', () => {
vi.restoreAllMocks();
});
});
describe('Node', () => {
let app: INestApplication;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [
ErrorsController,
ErrorBodyController,
ErrorCauseController,
],
}).compile();
app = module.createNestApplication(new NodeAdapter());
app.use('/middleware-error', (req, res, next) =>
next(createError(503, 'connect ECONNREFUSED 127.0.0.1:6379')),
);
server = app.getHttpServer();
await listenOnLoopback(app);
});
it(`/GET`, () => {
return request(server)
.get('/sync')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
error: 'Bad Request',
message: 'Integration test',
});
});
it(`/GET (Promise/async)`, () => {
return request(server)
.get('/async')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
error: 'Bad Request',
message: 'Integration test',
});
});
it(`/GET (InternalServerError despite custom content-type)`, async () => {
return request(server)
.get('/unexpected-error')
.expect(HttpStatus.INTERNAL_SERVER_ERROR)
.expect('Content-Type', /application\/json/)
.expect({
statusCode: 500,
message: 'Internal server error',
});
});
it(`/GET (InternalServerError when Error instance has statusCode)`, async () => {
return request(server)
.get('/error-with-status-code')
.expect(HttpStatus.INTERNAL_SERVER_ERROR)
.expect({
statusCode: 500,
message: 'Internal server error',
});
});
it(`/GET (Unauthorized when the body of the HttpException is an Error)`, async () => {
return request(server)
.get('/error-body')
.expect(HttpStatus.UNAUTHORIZED)
.expect({
statusCode: 401,
message: 'Unauthorized',
});
});
it(`/GET (ServiceUnavailable logged when a middleware passes an "http-errors" error to next())`, async () => {
const logError = vi
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => {});
await request(server)
.get('/middleware-error')
.expect(HttpStatus.SERVICE_UNAVAILABLE)
.expect({
statusCode: 503,
message: 'Service Unavailable',
});
expect(logError).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
statusCode: 503,
message: 'connect ECONNREFUSED 127.0.0.1:6379',
}),
);
});
it(`/GET (InternalServerError not logged when its cause is a 4xx "http-errors" error)`, async () => {
const logError = vi
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => {});
await request(server)
.get('/error-cause')
.expect(HttpStatus.INTERNAL_SERVER_ERROR)
.expect({
statusCode: 500,
error: 'Internal Server Error',
message: 'Oops',
});
expect(logError).not.toHaveBeenCalled();
});
afterEach(async () => {
await app.close();
vi.restoreAllMocks();
});
});
describe('Node (HttpException filter)', () => {
let app: NestNodeApplication;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [ErrorsController, EchoController],
}).compile();
app = module.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useBodyParser('json', { limit: 16 });
app.use('/middleware-error', (req, res, next) =>
next(createError(413, 'request entity too large')),
);
server = app.getHttpServer();
await listenOnLoopback(app);
});
it(`/GET (a 4xx "http-errors" error from a middleware reaches the filter)`, () => {
return request(server)
.get('/middleware-error')
.expect(HttpStatus.PAYLOAD_TOO_LARGE)
.expect({ caught: 413 });
});
it(`/POST (a body parser error reaches the filter)`, () => {
return request(server)
.post('/echo')
.set('content-type', 'application/json')
.send(JSON.stringify({ name: 'x'.repeat(32) }))
.expect(HttpStatus.PAYLOAD_TOO_LARGE)
.expect({ caught: 413 });
});
afterEach(async () => {
await app.close();
});
});
});
@@ -0,0 +1,220 @@
import { NodeAdapter } from '@nestjs/platform-node';
import {
Controller,
Get,
INestApplication,
MiddlewareConsumer,
Module,
Post,
RequestMethod,
} from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
const RETURN_VALUE = 'test';
const MIDDLEWARE_VALUE = 'middleware';
@Controller()
class TestController {
@Get('test')
test() {
return RETURN_VALUE;
}
@Get('test2')
test2() {
return RETURN_VALUE;
}
@Get('middleware')
middleware() {
return RETURN_VALUE;
}
@Post('middleware')
noMiddleware() {
return RETURN_VALUE;
}
@Get('wildcard/overview')
testOverview() {
return RETURN_VALUE;
}
@Get('legacy-wildcard/overview')
testLegacyWildcard() {
return RETURN_VALUE;
}
@Get('splat-wildcard/overview')
testSplatWildcard() {
return RETURN_VALUE;
}
@Get('overview/:id')
overviewById() {
return RETURN_VALUE;
}
@Get('multiple/exclude')
multipleExclude() {
return RETURN_VALUE;
}
}
@Module({
imports: [AppModule],
controllers: [TestController],
})
class TestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req, res, next) => res.send(MIDDLEWARE_VALUE))
.exclude(
'test',
'overview/:id',
'wildcard/*',
'legacy-wildcard/(.*)',
'splat-wildcard/*splat',
{
path: 'middleware',
method: RequestMethod.POST,
},
)
.exclude('multiple/exclude')
.forRoutes('*path');
}
}
@Controller('users')
class UsersController {
@Get('login')
login() {
return RETURN_VALUE;
}
@Get(':id')
findOne() {
return RETURN_VALUE;
}
}
@Module({
controllers: [UsersController],
})
class RouteMatchingModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req, res, next) => res.send(MIDDLEWARE_VALUE))
.exclude('users/login')
.forRoutes(UsersController);
}
}
describe('Exclude middleware - route matching', () => {
let app: INestApplication;
beforeEach(async () => {
app = (
await Test.createTestingModule({
imports: [RouteMatchingModule],
}).compile()
).createNestApplication(new NodeAdapter());
await listenOnLoopback(app);
});
afterEach(async () => {
await app.close();
});
it(`should exclude "/users/login" endpoint`, () => {
return request(app.getHttpServer())
.get('/users/login')
.expect(200, RETURN_VALUE);
});
it(`should exclude "/users/login/", which the router serves with "/users/login"`, () => {
return request(app.getHttpServer())
.get('/users/login/')
.expect(200, RETURN_VALUE);
});
it(`should not exclude "/users/LOGIN", which the router serves with "/users/:id"`, () => {
return request(app.getHttpServer())
.get('/users/LOGIN')
.expect(200, MIDDLEWARE_VALUE);
});
});
describe('Exclude middleware', () => {
let app: INestApplication;
beforeEach(async () => {
app = (
await Test.createTestingModule({
imports: [TestModule],
}).compile()
).createNestApplication(new NodeAdapter());
await listenOnLoopback(app);
});
it(`should exclude "/test" endpoint`, () => {
return request(app.getHttpServer()).get('/test').expect(200, RETURN_VALUE);
});
it(`should not exclude "/test2" endpoint`, () => {
return request(app.getHttpServer())
.get('/test2')
.expect(200, MIDDLEWARE_VALUE);
});
it(`should run middleware for "/middleware" endpoint`, () => {
return request(app.getHttpServer())
.get('/middleware')
.expect(200, MIDDLEWARE_VALUE);
});
it(`should exclude POST "/middleware" endpoint`, () => {
return request(app.getHttpServer())
.post('/middleware')
.expect(201, RETURN_VALUE);
});
it(`should exclude "/overview/:id" endpoint (by param)`, () => {
return request(app.getHttpServer())
.get('/overview/1')
.expect(200, RETURN_VALUE);
});
it(`should exclude "/wildcard/overview" endpoint (by wildcard)`, () => {
return request(app.getHttpServer())
.get('/wildcard/overview')
.expect(200, RETURN_VALUE);
});
it(`should exclude "/legacy-wildcard/overview" endpoint (by wildcard, legacy syntax)`, () => {
return request(app.getHttpServer())
.get('/legacy-wildcard/overview')
.expect(200, RETURN_VALUE);
});
it(`should exclude "/splat-wildcard/overview" endpoint (by wildcard, new syntax)`, () => {
return request(app.getHttpServer())
.get('/splat-wildcard/overview')
.expect(200, RETURN_VALUE);
});
it(`should exclude "/multiple/exclude" endpoint`, () => {
return request(app.getHttpServer())
.get('/multiple/exclude')
.expect(200, RETURN_VALUE);
});
afterEach(async () => {
await app.close();
});
});
@@ -0,0 +1,117 @@
import {
Controller,
Get,
Injectable,
MiddlewareConsumer,
Module,
NestModule,
} from '@nestjs/common';
import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
describe('Middleware before init (NodeAdapter)', () => {
let app: NestNodeApplication;
@Injectable()
class TestService {
getData(): string {
return 'test_data';
}
}
@Controller()
class TestController {
constructor(private readonly testService: TestService) {}
@Get('test')
test() {
return { data: this.testService.getData() };
}
@Get('health')
health() {
return { status: 'ok' };
}
}
@Module({
controllers: [TestController],
providers: [TestService],
})
class TestModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req, res, next) => {
res.setHeader('x-middleware', 'applied');
next();
})
.forRoutes('*');
}
}
describe('should apply middleware registered before init', () => {
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [TestModule],
}).compile();
app = module.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
);
// Register middleware before init
app.use((req, res, next) => {
res.setHeader('x-global-middleware', 'applied');
next();
});
await listenOnLoopback(app);
});
it('should apply the middleware registered before init', async () => {
const { headers } = await request(app.getHttpServer())
.get('/test')
.expect(200, { data: 'test_data' });
// Verify both module-level and global middleware were applied
expect(headers['x-middleware']).toBe('applied');
expect(headers['x-global-middleware']).toBe('applied');
});
afterEach(async () => {
await app.close();
});
});
describe('should work when app is initialized before middleware registration', () => {
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [TestModule],
}).compile();
app = module.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
);
// Initialize app first
await listenOnLoopback(app);
// Now middleware registration should work
app.use((req, res, next) => {
res.setHeader('x-global-middleware', 'applied');
next();
});
});
it('should register middleware successfully after init', () => {
return request(app.getHttpServer())
.get('/test')
.expect(200, { data: 'test_data' });
});
afterEach(async () => {
await app.close();
});
});
});
@@ -0,0 +1,175 @@
import { NodeAdapter } from '@nestjs/platform-node';
import {
Controller,
Get,
INestApplication,
MiddlewareConsumer,
Module,
Req,
} from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
const RETURN_VALUE = 'test';
const SCOPED_VALUE = 'test_scoped';
const WILDCARD_VALUE = 'test_wildcard';
const EXCLUDE_VALUE = 'test_exclude';
@Controller()
class TestController {
@Get('test')
test() {
return RETURN_VALUE;
}
@Get('tests/wildcard_nested')
wildcard_nested() {
return RETURN_VALUE;
}
@Get('legacy-wildcard/overview')
legacyWildcard() {
return RETURN_VALUE;
}
@Get('exclude')
exclude() {
return EXCLUDE_VALUE;
}
}
@Module({
imports: [AppModule],
controllers: [TestController],
})
class TestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req, res, next) => res.send(WILDCARD_VALUE))
.forRoutes('tests/*path', 'legacy-wildcard/*')
.apply((req, res, next) => res.send(SCOPED_VALUE))
.exclude('exclude')
.forRoutes(TestController)
.apply((req, res, next) => res.send(RETURN_VALUE))
.exclude('exclude')
.forRoutes('*');
}
}
describe('Middleware', () => {
let app: INestApplication;
it(`forRoutes(*)`, async () => {
app = await createApp();
await request(app.getHttpServer()).get('/hello').expect(200, RETURN_VALUE);
await request(app.getHttpServer())
.get('/exclude')
.expect(200, EXCLUDE_VALUE);
});
it(`forRoutes(*) with global prefix`, async () => {
app = await createApp(app => app.setGlobalPrefix('api'));
await request(app.getHttpServer())
.get('/api/hello')
.expect(200, RETURN_VALUE);
await request(app.getHttpServer()).get('/api').expect(200, RETURN_VALUE);
await request(app.getHttpServer())
.get('/api/exclude')
.expect(200, EXCLUDE_VALUE);
});
it(`forRoutes(TestController)`, async () => {
app = await createApp();
await request(app.getHttpServer()).get('/test').expect(200, SCOPED_VALUE);
await request(app.getHttpServer())
.get('/exclude')
.expect(200, EXCLUDE_VALUE);
});
it(`forRoutes(tests/*)`, async () => {
app = await createApp();
return request(app.getHttpServer())
.get('/tests/wildcard')
.expect(200, WILDCARD_VALUE);
});
it(`forRoutes(legacy-wildcard/*)`, async () => {
app = await createApp();
return request(app.getHttpServer())
.get('/legacy-wildcard/overview')
.expect(200, WILDCARD_VALUE);
});
afterEach(async () => {
await app.close();
});
});
async function createApp(
beforeInit?: (app: INestApplication) => void,
): Promise<INestApplication> {
const app = (
await Test.createTestingModule({
imports: [TestModule],
}).compile()
).createNestApplication(new NodeAdapter());
if (beforeInit) {
beforeInit(app);
}
await listenOnLoopback(app);
return app;
}
@Controller()
class RootController {
@Get()
root(@Req() req: { runs?: number }) {
return `${req.runs ?? 0}`;
}
}
// As in middleware.spec.ts (Express)
describe('Middleware - wildcard at the global prefix root (NodeAdapter)', () => {
let app: INestApplication;
const createAppWithWildcard = async (wildcard: string) => {
@Module({
controllers: [RootController],
})
class RootModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req, res, next) => {
req.runs = (req.runs ?? 0) + 1;
next();
})
.forRoutes(wildcard);
}
}
app = (
await Test.createTestingModule({
imports: [RootModule],
}).compile()
).createNestApplication(new NodeAdapter());
app.setGlobalPrefix('api');
await listenOnLoopback(app);
};
afterEach(async () => {
await app.close();
});
it.each(['*', '{*path}', '*path', '/*splat'])(
'should run the middleware once for "/api" and "/api/" (%s)',
async wildcard => {
await createAppWithWildcard(wildcard);
await request(app.getHttpServer()).get('/api').expect(200, '1');
await request(app.getHttpServer()).get('/api/').expect(200, '1');
},
);
});
@@ -15,6 +15,7 @@ import {
} from '@nestjs/common';
import { CustomVersioningOptions } from '@nestjs/common/interfaces/index.js';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
@@ -273,6 +274,7 @@ class BirdsController {
describe.each([
['Express', () => undefined],
['Fastify', () => new FastifyAdapter()],
['Node', () => new NodeAdapter()],
])(
'Middleware - routes that differ only in their version (%s)',
(_, createAdapter) => {
@@ -0,0 +1,84 @@
import { NodeAdapter } from '@nestjs/platform-node';
import request from 'supertest';
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { AppModule } from '../src/app.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
describe('Hello world (default adapter)', () => {
let server;
let app: INestApplication;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication(new NodeAdapter());
server = app.getHttpServer();
await listenOnLoopback(app);
});
[
{
host: 'example.com',
path: '/hello',
greeting: 'Hello world!',
},
{
host: 'acme.example.com',
path: '/host',
greeting: 'Host Greeting! tenant=acme',
},
{
host: 'acme.example1.com',
path: '/host-array',
greeting: 'Host Greeting! tenant=acme',
},
{
host: 'acme.example2.com',
path: '/host-array',
greeting: 'Host Greeting! tenant=acme',
},
].forEach(({ host, path, greeting }) => {
describe(`host=${host}`, () => {
describe('/GET', () => {
it(`should return "${greeting}"`, () => {
return request(server)
.get(path)
.set('Host', host)
.expect(200)
.expect(greeting);
});
it(`should attach response header`, () => {
return request(server)
.get(path)
.set('Host', host)
.expect(200)
.expect('Authorization', 'Bearer');
});
});
it(`/GET (Promise/async) returns "${greeting}"`, () => {
return request(server)
.get(`${path}/async`)
.set('Host', host)
.expect(200)
.expect(greeting);
});
it(`/GET (Observable stream) "${greeting}"`, () => {
return request(server)
.get(`${path}/stream`)
.set('Host', host)
.expect(200)
.expect(greeting);
});
});
});
afterEach(async () => {
await app.close();
});
});
@@ -0,0 +1,63 @@
import { INestApplication } from '@nestjs/common';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
describe('Hello world (node adapter instance)', () => {
let server: App;
let app: INestApplication;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication(new NodeAdapter());
server = app.getHttpServer();
await listenOnLoopback(app);
});
it(`/GET`, () => {
return request(server).get('/hello').expect(200).expect('Hello world!');
});
it(`/GET (Promise/async)`, () => {
return request(server)
.get('/hello/async')
.expect(200)
.expect('Hello world!');
});
it(`/GET (Observable stream)`, () => {
return request(server)
.get('/hello/stream')
.expect(200)
.expect('Hello world!');
});
it(`/GET { host: ":tenant.example.com" } not matched`, () => {
return request(server).get('/host').expect(404).expect({
statusCode: 404,
error: 'Not Found',
message: 'Cannot GET /host',
});
});
it('/HEAD should respond to with a 200', () => {
return request(server).head('/hello').expect(200);
});
it('serves requests through the router instance alone', () => {
// The instance is a request listener, like an Express application, so it
// can be mounted on another server (serverless handlers, tests)
const router = app.getHttpAdapter().getInstance();
return request(router).get('/hello').expect(200).expect('Hello world!');
});
afterEach(async () => {
await app.close();
});
});
@@ -0,0 +1,178 @@
import { ArgumentsHost, HttpException, INestApplication } from '@nestjs/common';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
describe('Hello world (node adapter with multiple applications)', () => {
let server;
let apps: INestApplication[];
beforeEach(async () => {
const module1 = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const module2 = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const adapter = new NodeAdapter();
apps = [
module1.createNestApplication(adapter),
module2.createNestApplication(adapter).setGlobalPrefix('/app2'),
];
await Promise.all(apps.map(app => app.init()));
server = adapter.getInstance();
});
it(`/GET`, () => {
return request(server).get('/hello').expect(200).expect('Hello world!');
});
it(`/GET (app2)`, () => {
return request(server)
.get('/app2/hello')
.expect(200)
.expect('Hello world!');
});
it(`/GET (Promise/async)`, () => {
return request(server)
.get('/hello/async')
.expect(200)
.expect('Hello world!');
});
it(`/GET (app2 Promise/async)`, () => {
return request(server)
.get('/app2/hello/async')
.expect(200)
.expect('Hello world!');
});
it(`/GET (Observable stream)`, () => {
return request(server)
.get('/hello/stream')
.expect(200)
.expect('Hello world!');
});
it(`/GET (app2 Observable stream)`, () => {
return request(server)
.get('/app2/hello/stream')
.expect(200)
.expect('Hello world!');
});
afterEach(async () => {
await Promise.all(apps.map(app => app.close()));
});
});
describe('Hello world (node not-found handler ownership)', () => {
const apps: INestApplication[] = [];
afterEach(async () => {
await Promise.all(apps.splice(0).map(app => app.close()));
});
const createApplication = async (
adapter: NodeAdapter,
handledBy: string,
prefix?: string,
) => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = module.createNestApplication(adapter);
apps.push(app);
if (prefix !== undefined) {
app.setGlobalPrefix(prefix);
}
app.useGlobalFilters({
catch(exception: HttpException, host: ArgumentsHost) {
host
.switchToHttp()
.getResponse()
.status(exception.getStatus())
.json({ handledBy });
},
});
await app.init();
return app;
};
describe.each([undefined, '', '/'])(
'with root application prefix %j',
rootPrefix => {
it.each(['api', '/api', 'api/', '/api/'])(
'preserves routes and not-found ownership with prefix %s',
async prefix => {
const adapter = new NodeAdapter();
await createApplication(adapter, 'root', rootPrefix);
await createApplication(adapter, 'prefixed', prefix);
const server = adapter.getInstance();
await request(server)
.get('/api/hello')
.expect(200)
.expect('Hello world!');
await request(server)
.get('/hello')
.expect(200)
.expect('Hello world!');
await request(server)
.get('/api/missing')
.expect(404)
.expect({ handledBy: 'prefixed' });
await request(server)
.get('/apiary/missing')
.expect(404)
.expect({ handledBy: 'root' });
await request(server)
.get('/')
.expect(404)
.expect({ handledBy: 'root' });
},
);
},
);
// The ownership block above always builds the ROOT application first, which
// is the one order where a root-mounted not-found handler cannot shadow
// anything. These three are the orders where it can, and each is a request
// that reached a real handler before and would stop doing so if the handler
// were mounted at the root.
it('leaves a root application alone when the prefixed one is built first', async () => {
const adapter = new NodeAdapter();
await createApplication(adapter, 'prefixed', 'api');
await createApplication(adapter, 'root');
const server = adapter.getInstance();
await request(server).get('/hello').expect(200).expect('Hello world!');
await request(server).get('/api/hello').expect(200).expect('Hello world!');
});
it('leaves a raw route registered after init alone', async () => {
const adapter = new NodeAdapter();
await createApplication(adapter, 'prefixed', 'api');
const server = adapter.getInstance();
server.get('/metrics', (_req: unknown, res: any) => res.send('metrics'));
await request(server).get('/metrics').expect(200).expect('metrics');
});
it('leaves a subtree mounted after init alone', async () => {
// The shape a docs UI takes: `app.use('/docs', serveSomething())` once the
// application is up.
const adapter = new NodeAdapter();
await createApplication(adapter, 'prefixed', 'api');
const server = adapter.getInstance();
server.use('/docs', (_req: unknown, res: any) => res.send('docs'));
await request(server).get('/docs').expect(200).expect('docs');
await request(server).get('/docs/index.html').expect(200).expect('docs');
});
});
@@ -0,0 +1,126 @@
import { NodeAdapter } from '@nestjs/platform-node';
import {
ArgumentsHost,
HttpException,
INestApplication,
RequestMethod,
} from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
const jsonFilter = {
catch(exception: HttpException, host: ArgumentsHost) {
host
.switchToHttp()
.getResponse()
.status(exception.getStatus())
.json({ handledBy: 'nest' });
},
};
describe('Hello world (node not-found handling)', () => {
let app: INestApplication;
afterEach(async () => {
await app.close();
});
async function createApp(
configure: (app: INestApplication) => void,
): Promise<INestApplication> {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication(new NodeAdapter());
configure(app);
app.useGlobalFilters(jsonFilter);
await listenOnLoopback(app);
return app;
}
it.each([
{ prefix: 'api', path: '/api/missing' },
{ prefix: '/api', path: '/api/missing' },
{ prefix: 'api', path: '/api' },
{ prefix: '/api', path: '/api/' },
{ prefix: 'api/', path: '/api/missing' },
{ prefix: '/api/', path: '/api/missing' },
{ prefix: 'api/', path: '/api' },
{ prefix: '/api/', path: '/api/' },
{ prefix: 'api/v1', path: '/api/v1/missing' },
{ prefix: 'api/v1/', path: '/api/v1/missing' },
{ prefix: undefined, path: '/missing' },
{ prefix: '', path: '/missing' },
{ prefix: '/', path: '/missing' },
{ prefix: '/', path: '/' },
])(
'uses the global exception filter for $path with prefix $prefix',
async ({ prefix, path }) => {
await createApp(app => {
if (prefix !== undefined) {
app.setGlobalPrefix(prefix);
}
});
await request(app.getHttpServer())
.get(path)
.expect(404)
.expect({ handledBy: 'nest' });
},
);
// A path taken out of the global prefix is still a path Nest owns, so a
// request it declares no handler for is an unmatched request rather than
// something for the framework to answer. The controller declares only GET
// methods on "hello", which makes POST the miss.
describe('a method miss on an excluded route', () => {
it('is answered by the exception filter', async () => {
await createApp(app =>
app.setGlobalPrefix('api', { exclude: ['hello'] }),
);
await request(app.getHttpServer())
.post('/hello')
.expect(404)
.expect({ handledBy: 'nest' });
});
it('is answered when the exclusion names a method', async () => {
// The exclusion names GET, and GET is the one method that is NOT a miss
// here. Mounting by the named method would leave every other one to
// the adapter's own final handler, which is why it mounts for all of
// them.
await createApp(app =>
app.setGlobalPrefix('api', {
exclude: [{ path: 'hello', method: RequestMethod.GET }],
}),
);
await request(app.getHttpServer())
.post('/hello')
.expect(404)
.expect({ handledBy: 'nest' });
});
it('is answered under a wildcard exclusion', async () => {
await createApp(app =>
app.setGlobalPrefix('api', { exclude: ['hello/{*splat}'] }),
);
await request(app.getHttpServer())
.post('/hello/async')
.expect(404)
.expect({ handledBy: 'nest' });
});
it('still serves the excluded route itself', async () => {
await createApp(app =>
app.setGlobalPrefix('api', { exclude: ['hello'] }),
);
await request(app.getHttpServer()).get('/hello').expect(200);
});
});
});
@@ -0,0 +1,122 @@
import {
Controller,
Get,
type INestApplication,
type MiddlewareConsumer,
Module,
type NestModule,
Options,
RequestMethod,
} from '@nestjs/common';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import type { AddressInfo } from 'net';
import { connect } from 'net';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
const MIDDLEWARE_RETURN_VALUE = 'middleware_return';
const HANDLER_RETURN_VALUE = 'handler_return';
@Controller('users')
class UsersController {
@Get()
findAll() {
return HANDLER_RETURN_VALUE;
}
}
@Controller()
class RootController {
@Options()
options() {
return 'root options';
}
}
@Module({
controllers: [UsersController, RootController],
})
class RequestTargetModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req, res, next) => res.end(MIDDLEWARE_RETURN_VALUE))
.forRoutes({ path: 'users', method: RequestMethod.GET });
}
}
// As in middleware-fastify.spec.ts: a route never runs without the middleware
// mounted on its path, whatever form the request target takes.
describe('Request target forms (NodeAdapter)', () => {
let app: INestApplication;
let port: number;
/**
* "supertest" always sends origin-form request targets, so the request has
* to be written to the socket manually.
*/
const sendRawRequest = (requestTarget: string, method = 'GET') =>
new Promise<{ statusCode: number; body: string }>((resolve, reject) => {
let response = '';
const socket = connect(port, '127.0.0.1', () => {
socket.write(
`${method} ${requestTarget} HTTP/1.1\r\n` +
`Host: 127.0.0.1:${port}\r\n` +
`Connection: close\r\n\r\n`,
);
});
socket.setEncoding('utf8');
socket.on('data', chunk => (response += chunk));
socket.on('error', reject);
socket.on('end', () => {
const [head, body = ''] = response.split('\r\n\r\n');
resolve({ statusCode: Number(head.split(' ')[1]), body });
});
});
beforeEach(async () => {
app = (
await Test.createTestingModule({
imports: [RequestTargetModule],
}).compile()
).createNestApplication(new NodeAdapter());
await listenOnLoopback(app);
port = (app.getHttpServer().address() as AddressInfo).port;
});
afterEach(async () => {
await app.close();
});
it('runs middleware for an origin-form request target', async () => {
const response = await sendRawRequest('/users');
expect(response.statusCode).toBe(200);
expect(response.body).toBe(MIDDLEWARE_RETURN_VALUE);
});
it('does not bypass middleware with an absolute-form request target', async () => {
const response = await sendRawRequest(`http://127.0.0.1:${port}/users`);
expect(response.statusCode).toBe(200);
expect(response.body).toBe(MIDDLEWARE_RETURN_VALUE);
});
it('does not run the route without its middleware for a request target starting with "*"', async () => {
const response = await sendRawRequest('*users');
expect(response.statusCode).toBe(404);
expect(JSON.parse(response.body)).toEqual({
message: 'Cannot GET *users',
error: 'Not Found',
statusCode: 404,
});
});
it('answers "OPTIONS *" with the not-found handler', async () => {
const response = await sendRawRequest('*', 'OPTIONS');
expect(response.statusCode).toBe(404);
expect(JSON.parse(response.body)).toEqual({
message: 'Cannot OPTIONS *',
error: 'Not Found',
statusCode: 404,
});
});
});
+2
View File
@@ -27,6 +27,8 @@
"@nestjs/testing/*": ["../../packages/testing/*"],
"@nestjs/platform-express": ["../../packages/platform-express"],
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-node": ["../../packages/platform-node"],
"@nestjs/platform-node/*": ["../../packages/platform-node/*"],
"@nestjs/platform-socket.io": ["../../packages/platform-socket.io"],
"@nestjs/platform-socket.io/*": ["../../packages/platform-socket.io/*"],
"@nestjs/platform-ws": ["../../packages/platform-ws"],
@@ -0,0 +1,189 @@
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { adapters, createApp } from '../src/adapters.js';
import { BodyParsersModule } from '../src/body-parsers/body-parsers.module.js';
// The Node adapter's parsers follow body-parser 2 (the Express adapter's):
// the same charsets, limits and options, with the same results. Fastify has
// its own content type parsers and options.
describe.each(adapters('express', 'node'))('Body parsers (%s)', name => {
let app: INestApplication;
// The body as given: supertest would otherwise serialize a Buffer for JSON
// and form content types
const post = (contentType: string, body: string | Buffer) =>
request(app.getHttpServer())
.post('/echo')
.set('content-type', contentType)
.serialize((raw: any) => raw)
.send(body as any);
afterEach(async () => {
await app.close();
});
describe('charsets', () => {
beforeEach(async () => {
app = await createApp(name, BodyParsersModule, app =>
(app as any).useBodyParser('text'),
);
});
const latin1 = (text: string) => Buffer.from(text, 'latin1');
it('should read the charset of a Content-Type with a trailing ";"', async () => {
const form = await post(
'application/x-www-form-urlencoded; charset=iso-8859-1;',
latin1('a=café'),
);
const text = await post(
'text/plain; charset=iso-8859-1;',
latin1('café'),
);
expect(form.text).toBe('{"a":"café"}');
expect(text.text).toBe('café');
});
it('should take the first of two charset parameters', async () => {
const res = await post(
'text/plain; charset=iso-8859-1; charset=utf-8',
latin1('café'),
);
expect(res.text).toBe('café');
});
it('should read upper-case, spaced and quoted charset parameters', async () => {
for (const contentType of [
'text/plain; CHARSET=ISO-8859-1',
'text/plain; charset = iso-8859-1',
'text/plain;charset="iso-8859-1"',
]) {
expect((await post(contentType, latin1('café'))).text).toBe('café');
}
});
it('should match the media type case-insensitively', async () => {
expect((await post('Application/JSON', '{"a":1}')).text).toBe('{"a":1}');
});
it('should read JSON in any utf-* charset', async () => {
const res = await post(
'application/json; charset=utf-16le',
Buffer.from('{"a":"é"}', 'utf16le'),
);
expect(res.text).toBe('{"a":"é"}');
});
it('should pick the byte order of "utf-16" from the byte order mark, or else the content', async () => {
const withBom = await post(
'text/plain; charset=utf-16',
Buffer.from([0xfe, 0xff, 0, 0x68, 0, 0x69]),
);
const bigEndian = await post(
'text/plain; charset=utf-16',
Buffer.from([0, 0x68, 0, 0x69]),
);
expect(withBom.text).toBe('hi');
expect(bigEndian.text).toBe('hi');
});
it.each([
['application/json; charset=iso-8859-1', 'ISO-8859-1'],
['application/x-www-form-urlencoded; charset=utf-16', 'UTF-16'],
['text/plain; charset=x-unknown', 'X-UNKNOWN'],
])(
'should answer an unsupported charset (%s) with 415',
async (contentType, charset) => {
const res = await post(contentType, 'a=1');
expect(res.status).toBe(415);
expect(res.body).toEqual({
statusCode: 415,
message: `unsupported charset "${charset}"`,
});
},
);
});
describe('limits', () => {
it.each([Infinity, '1tb', '2PB'])(
'should accept the limit %s',
async limit => {
app = await createApp(
name,
BodyParsersModule,
app => (app as any).useBodyParser('json', { limit }),
{ bodyParser: false },
);
const res = await post(
'application/json',
JSON.stringify({ data: 'x'.repeat(200 * 1024) }),
);
expect(res.status).toBe(201);
},
);
it('should answer a body over the limit with 413', async () => {
app = await createApp(
name,
BodyParsersModule,
app => (app as any).useBodyParser('json', { limit: 10 }),
{ bodyParser: false },
);
const res = await post('application/json', '{"a":"xxxxxxxxxx"}');
expect(res.status).toBe(413);
expect(res.body).toEqual({
statusCode: 413,
message: 'request entity too large',
});
});
});
describe('urlencoded charset options', () => {
const create = (options: object) =>
createApp(
name,
BodyParsersModule,
app => (app as any).useBodyParser('urlencoded', options),
{ bodyParser: false },
);
const form = 'application/x-www-form-urlencoded';
it('should decode a body without a charset with defaultCharset', async () => {
app = await create({ defaultCharset: 'iso-8859-1' });
const res = await post(form, Buffer.from('a=café&b=caf%E9', 'latin1'));
expect(res.text).toBe('{"a":"café","b":"café"}');
});
it('should pick the charset from a "utf8" sentinel with charsetSentinel', async () => {
app = await create({
defaultCharset: 'iso-8859-1',
charsetSentinel: true,
});
const res = await post(form, 'utf8=%E2%9C%93&a=caf%C3%A9');
expect(res.text).toBe('{"a":"café"}');
});
it('should decode numeric entities with interpretNumericEntities', async () => {
app = await create({
defaultCharset: 'iso-8859-1',
interpretNumericEntities: true,
});
const res = await post(form, 'a=%26%239786%3B');
expect(res.text).toBe('{"a":"☺"}');
});
});
});
@@ -0,0 +1,163 @@
import { HttpException, type INestApplication } from '@nestjs/common';
import request from 'supertest';
import { adapters, createApp } from '../src/adapters.js';
import {
ClassMiddlewareErrorsModule,
CsrfError,
CsrfErrorFilter,
ErrorPageFilter,
ErrorsModule,
} from '../src/errors/errors.module.js';
// Errors raised by middleware reach Nest's exception handling: a client
// error shaped like the http-errors ones is mapped to an HttpException with
// its status, whose cause is the original error (class and code), and without
// a filter, http-errors-style errors answer with their own status.
describe.each(adapters('express', 'fastify', 'node'))(
'Errors from middleware (%s)',
name => {
let app: INestApplication;
const get = (path: string) => request(app.getHttpServer()).get(path);
afterEach(async () => {
await app.close();
});
it('should hand an error passed to next() by app.use() middleware to filters as the cause of an HttpException', async () => {
app = await createApp(name, ErrorsModule, app => {
app.use((_req: unknown, _res: unknown, next: (e: unknown) => void) =>
next(new CsrfError()),
);
app.useGlobalFilters(new CsrfErrorFilter());
});
const res = await get('/ok');
expect(res.status).toBe(499);
expect(res.body).toEqual({
status: 403,
cause: 'CsrfError',
code: 'EBADCSRFTOKEN',
isCsrfError: true,
});
});
it('should hand an error from a class middleware to filters as the cause of an HttpException', async () => {
app = await createApp(name, ClassMiddlewareErrorsModule, app =>
app.useGlobalFilters(new CsrfErrorFilter()),
);
const res = await get('/guarded');
expect(res.status).toBe(499);
expect(res.body.isCsrfError).toBe(true);
});
it('should answer an http-errors-style error with its status and message without a filter', async () => {
app = await createApp(name, ErrorsModule, app =>
app.use((_req: unknown, _res: unknown, next: (e: unknown) => void) =>
next(new CsrfError()),
),
);
const res = await get('/ok');
expect(res.status).toBe(403);
expect(res.body).toEqual({
statusCode: 403,
message: 'invalid csrf token',
});
});
it('should answer an error thrown synchronously by middleware the same way', async () => {
app = await createApp(name, ErrorsModule, app =>
app.use(() => {
throw new CsrfError();
}),
);
expect((await get('/ok')).status).toBe(403);
});
it('should answer a plain { statusCode, message } object with its status', async () => {
app = await createApp(name, ErrorsModule, app =>
app.use((_req: unknown, _res: unknown, next: (e: unknown) => void) =>
next({ statusCode: 422, message: 'plain object' }),
),
);
const res = await get('/ok');
expect(res.status).toBe(422);
expect(res.body).toEqual({ statusCode: 422, message: 'plain object' });
});
it('should answer an HttpException from middleware with its response', async () => {
app = await createApp(name, ErrorsModule, app =>
app.use((_req: unknown, _res: unknown, next: (e: unknown) => void) =>
next(new HttpException({ reason: 'teapot' }, 418)),
),
);
const res = await get('/ok');
expect(res.status).toBe(418);
expect(res.body).toEqual({ reason: 'teapot' });
});
},
);
// Fastify's middleware engine leaves the rejection of an async middleware
// unhandled
describe.each(adapters('express', 'node'))(
'Errors from async middleware (%s)',
name => {
let app: INestApplication;
afterEach(async () => {
await app.close();
});
it('should hand the rejection to filters as the cause of an HttpException', async () => {
app = await createApp(name, ErrorsModule, app => {
app.use(async () => {
throw new CsrfError();
});
app.useGlobalFilters(new CsrfErrorFilter());
});
const res = await request(app.getHttpServer()).get('/ok');
expect(res.status).toBe(499);
expect(res.body.isCsrfError).toBe(true);
});
},
);
// res.sendFile() is an Express API (Fastify's reply has its own)
describe.each(adapters('express', 'node'))(
'Exception filters that fail as well (%s)',
name => {
let app: INestApplication;
afterEach(async () => {
await app.close();
});
it('should not run a filter again and again when its res.sendFile() fails', async () => {
const filter = new ErrorPageFilter();
app = await createApp(name, ErrorsModule, app =>
app.useGlobalFilters(filter),
);
// A range that the error page cannot satisfy either
const res = await request(app.getHttpServer())
.get('/missing')
.set('Range', 'bytes=99999-')
.timeout(2000);
expect(res.status).toBe(416);
expect(filter.calls).toBe(2);
});
},
);
@@ -0,0 +1,150 @@
import type { INestApplication } from '@nestjs/common';
import { NodeAdapter } from '@nestjs/platform-node';
import request from 'supertest';
import { adapters, createApp } from '../src/adapters.js';
import {
CatchEverythingFilter,
NodeSpecificsModule,
} from '../src/node-specifics/node-specifics.module.js';
const long = (length: number) => 'x'.repeat(length);
// Where the Node adapter follows Fastify rather than Express
describe.each(adapters('fastify', 'node'))(
'Route parameter length (%s)',
name => {
let app: INestApplication;
const get = (path: string) => request(app.getHttpServer()).get(path);
beforeAll(async () => {
app = await createApp(name, NodeSpecificsModule);
});
afterAll(async () => {
await app.close();
});
it('should match parameters of up to 100 characters', async () => {
const res = await get(`/items/${long(100)}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ length: 100 });
});
it('should answer a longer parameter no other route matches with 414', async () => {
expect((await get(`/items/${long(101)}`)).status).toBe(414);
// The decoded length counts
expect((await get(`/items/${'%2F'.repeat(101)}`)).status).toBe(414);
});
it('should let a sibling catch-all route take a longer parameter', async () => {
const res = await get(`/files/${long(101)}`);
expect(res.status).toBe(200);
expect(res.text).toBe('catch-all');
});
it('should answer paths that are not routes with 404, however long', async () => {
expect((await get(`/nowhere/${long(101)}`)).status).toBe(404);
});
it('should match paths case-sensitively', async () => {
expect((await get('/cats')).status).toBe(200);
expect((await get('/CATS')).status).toBe(404);
expect((await get('/admin/x')).status).toBe(200);
expect((await get('/ADMIN/x')).status).toBe(404);
});
},
);
describe('Node adapter specifics', () => {
let app: INestApplication;
const get = (path: string) => request(app.getHttpServer()).get(path);
afterEach(async () => {
await app.close();
});
describe('maxParamLength', () => {
it('should answer with a 414 Nest error body', async () => {
app = await createApp('node', NodeSpecificsModule);
const res = await get(`/items/${long(101)}`);
expect(res.status).toBe(414);
expect(res.body).toEqual({
statusCode: 414,
message: 'Route parameter too long',
});
});
it('should let exception filters handle the 414', async () => {
app = await createApp('node', NodeSpecificsModule, app =>
app.useGlobalFilters(new CatchEverythingFilter()),
);
const res = await get(`/items/${long(101)}`);
expect(res.status).toBe(414);
expect(res.body).toEqual({ filtered: true, status: 414 });
});
it('should take the limit from the adapter options', async () => {
app = await createApp(
'node',
NodeSpecificsModule,
undefined,
{},
new NodeAdapter({ maxParamLength: 200 }),
);
expect((await get(`/items/${long(150)}`)).body).toEqual({
length: 150,
});
expect((await get(`/items/${long(201)}`)).status).toBe(414);
});
});
describe('case sensitivity', () => {
beforeEach(async () => {
app = await createApp('node', NodeSpecificsModule);
});
it('should not run the middleware of a route for a path in another case', async () => {
const lower = await get('/cats');
const upper = await get('/CATS');
const catchAll = await get('/ADMIN/x');
expect(lower.headers['x-middleware']).toBe('ran');
expect(upper.status).toBe(404);
expect(upper.headers['x-middleware']).toBeUndefined();
expect(catchAll.status).toBe(404);
expect(catchAll.headers['x-middleware']).toBeUndefined();
});
});
describe('requests outside the global prefix', () => {
beforeEach(async () => {
app = await createApp('node', NodeSpecificsModule, app =>
app.setGlobalPrefix('api'),
);
});
it('should echo the path as received, and forbid sniffing and scripts', async () => {
const res = await get('/%3Cscript%3Ealert(1)%3C/script%3E?x=1');
expect(res.status).toBe(404);
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8');
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['content-security-policy']).toBe("default-src 'none'");
expect(res.text).toBe('Cannot GET /%3Cscript%3Ealert(1)%3C/script%3E');
});
it('should send no body in answer to HEAD', async () => {
const res = await request(app.getHttpServer()).head('/elsewhere');
expect(res.status).toBe(404);
expect(res.text ?? '').toBe('');
});
});
});
@@ -0,0 +1,127 @@
import type { INestApplication } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { NodeAdapter } from '@nestjs/platform-node';
import qs from 'qs';
import request from 'supertest';
import { type AdapterName, adapters, createApp } from '../src/adapters.js';
import { RequestOptionsModule } from '../src/request-options/request-options.module.js';
// Each adapter set up to trust the proxy on the loopback interface, where the
// test requests come from: Express through its "trust proxy" setting,
// Fastify and Node through their "trustProxy" option
const trustingLoopback: Record<AdapterName, () => Promise<INestApplication>> = {
express: () =>
createApp('express', RequestOptionsModule, app =>
(app as NestExpressApplication).set('trust proxy', 'loopback'),
),
fastify: () =>
createApp(
'fastify',
RequestOptionsModule,
undefined,
{},
new FastifyAdapter({ trustProxy: 'loopback' }),
),
node: () =>
createApp(
'node',
RequestOptionsModule,
undefined,
{},
new NodeAdapter({ trustProxy: 'loopback' }),
),
};
// Each adapter set up to parse nested objects and arrays with "qs"
const parsingWithQs: Record<AdapterName, () => Promise<INestApplication>> = {
express: () =>
createApp('express', RequestOptionsModule, app =>
(app as NestExpressApplication).set('query parser', 'extended'),
),
fastify: () =>
createApp(
'fastify',
RequestOptionsModule,
undefined,
{},
new FastifyAdapter({ querystringParser: str => qs.parse(str) }),
),
node: () =>
createApp(
'node',
RequestOptionsModule,
undefined,
{},
new NodeAdapter({ querystringParser: str => qs.parse(str) }),
),
};
const FORWARDED = {
'X-Forwarded-For': '203.0.113.7',
'X-Forwarded-Proto': 'https',
'X-Forwarded-Host': 'example.com',
};
const QUERY = 'filter[where][name]=John&item[]=1&item[]=2';
describe.each(adapters('express', 'fastify', 'node'))(
'Adapter options for requests (%s)',
name => {
let app: INestApplication;
afterEach(async () => {
await app.close();
});
it('should read the client address, protocol and host a trusted proxy forwards', async () => {
app = await trustingLoopback[name]();
const res = await request(app.getHttpServer())
.get('/client')
.set(FORWARDED);
expect(res.body).toEqual({
ip: '203.0.113.7',
protocol: 'https',
hostname: 'example.com',
});
});
it('should ignore the X-Forwarded-* headers by default', async () => {
app = await createApp(name, RequestOptionsModule);
const res = await request(app.getHttpServer())
.get('/client')
.set(FORWARDED);
expect(res.body).toEqual({
ip: '127.0.0.1',
protocol: 'http',
hostname: '127.0.0.1',
});
});
it('should parse nested objects and arrays in the query string with the parser it is given', async () => {
app = await parsingWithQs[name]();
const res = await request(app.getHttpServer()).get(`/search?${QUERY}`);
expect(res.body).toEqual({
filter: { where: { name: 'John' } },
item: ['1', '2'],
});
});
it('should leave the query string flat by default', async () => {
app = await createApp(name, RequestOptionsModule);
const res = await request(app.getHttpServer()).get(`/search?${QUERY}`);
expect(res.body).toEqual({
'filter[where][name]': 'John',
'item[]': ['1', '2'],
});
});
},
);
@@ -0,0 +1,171 @@
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { adapters, createApp } from '../src/adapters.js';
import { ResponsesModule } from '../src/responses/responses.module.js';
const binary = (
res: request.Response,
callback: (err: any, body: Buffer) => void,
) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => callback(null, Buffer.concat(chunks)));
};
describe.each(adapters('express', 'node'))(
'Redirects and res.send() (%s)',
name => {
let app: INestApplication;
beforeAll(async () => {
app = await createApp(name, ResponsesModule);
});
afterAll(async () => {
await app.close();
});
it('should send @Redirect() with an encoded Location, a short note and "Vary: Accept"', async () => {
const res = await request(app.getHttpServer()).get('/redirect');
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/target?from=a%20b');
expect(res.headers.vary).toBe('Accept');
expect(res.text).toBe('Found. Redirecting to /target?from=a%20b');
});
it('should send the note as HTML to clients that prefer it', async () => {
const res = await request(app.getHttpServer())
.get('/redirect')
.set('accept', 'text/html');
expect(res.headers['content-type']).toMatch(/^text\/html/);
// The markup of Express 5.3
expect(res.text).toBe(
'<!DOCTYPE html><head><title>Found</title></head>' +
'<body><p>Found. Redirecting to /target?from=a%20b</p></body>',
);
});
it('should take the URL and status a @Redirect() handler returns', async () => {
const res = await request(app.getHttpServer()).get('/redirect-dynamic');
expect(res.status).toBe(301);
expect(res.headers.location).toBe('/dynamic');
});
it('should send no body in answer to HEAD', async () => {
const res = await request(app.getHttpServer()).head('/redirect');
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/target?from=a%20b');
expect(res.text ?? '').toBe('');
});
it.each([
['/send-number', '42'],
['/send-boolean', 'true'],
])('should send %s as JSON through res.send()', async (path, text) => {
const res = await request(app.getHttpServer()).get(path);
expect(res.headers['content-type']).toBe(
'application/json; charset=utf-8',
);
expect(res.text).toBe(text);
});
},
);
describe.each(adapters('express', 'node'))('res.sendFile() (%s)', name => {
let app: INestApplication;
const get = (path: string) => request(app.getHttpServer()).get(path);
beforeAll(async () => {
app = await createApp(name, ResponsesModule);
});
afterAll(async () => {
await app.close();
});
it('should stream the file with its type, length, Last-Modified and ETag', async () => {
const res = await get('/files/hello.txt');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8');
expect(res.headers['content-length']).toBe('17');
expect(res.headers['accept-ranges']).toBe('bytes');
expect(res.headers['last-modified']).toBeDefined();
expect(res.headers.etag).toBeDefined();
expect(res.text).toBe('hello from a file');
});
it('should resolve a relative path against the root option', async () => {
expect((await get('/rooted/hello.txt')).text).toBe('hello from a file');
});
it('should answer ranges and conditional requests', async () => {
const range = await get('/files/hello.txt').set('range', 'bytes=0-4');
const { etag } = (await get('/files/hello.txt')).headers;
const fresh = await get('/files/hello.txt').set('if-none-match', etag);
expect(range.status).toBe(206);
expect(range.text).toBe('hello');
expect(fresh.status).toBe(304);
});
it('should hand a missing file to Nest as a 404 without a callback', async () => {
const res = await get('/files/missing.txt');
expect(res.status).toBe(404);
expect(res.body.statusCode).toBe(404);
});
it('should answer a directory like an unmatched route', async () => {
const res = await get('/files/folder');
expect(res.status).toBe(404);
expect(res.body).toEqual({
message: 'Cannot GET /files/folder',
error: 'Not Found',
statusCode: 404,
});
});
it('should leave the response to the callback when given one', async () => {
const res = await get('/files-or-gone/missing.txt');
expect(res.status).toBe(410);
expect(res.text).toBe('gone');
});
});
// Express serializes typed arrays to JSON; Node, like Fastify, sends their bytes
describe.each(adapters('fastify', 'node'))('Binary bodies (%s)', name => {
let app: INestApplication;
beforeAll(async () => {
app = await createApp(name, ResponsesModule);
});
afterAll(async () => {
await app.close();
});
it.each([
['/typed-array', [1, 2, 3, 4]],
['/data-view', [1, 2, 3]],
['/buffer', [1, 2, 3]],
])(
'should send %s as application/octet-stream bytes',
async (path, bytes) => {
const res = await request(app.getHttpServer())
.get(path)
.buffer(true)
.parse(binary);
expect(res.headers['content-type']).toBe('application/octet-stream');
expect([...res.body]).toEqual(bytes);
},
);
});
@@ -0,0 +1,159 @@
import { type INestApplication, VersioningType } from '@nestjs/common';
import request from 'supertest';
import { adapters, createApp } from '../src/adapters.js';
import {
FallThroughModule,
HostFallThroughModule,
} from '../src/fall-through/fall-through.module.js';
// When the most specific route matching a path does not apply (its version
// or host filter calls `next()`), the request falls through to the other
// routes matching the path, here "/<prefix>/:id".
const versioning = [
{
type: 'header',
options: { type: VersioningType.HEADER, header: 'v' },
version: (v: string) => ({ v }),
},
{
type: 'media type',
options: { type: VersioningType.MEDIA_TYPE, key: 'v=' },
version: (v: string) => ({ accept: `application/json;v=${v}` }),
},
{
type: 'custom',
options: {
type: VersioningType.CUSTOM,
extractor: (req: any) =>
String((req.headers ?? req.raw.headers)['x-version'] ?? ''),
},
version: (v: string) => ({ 'x-version': v }),
},
] as const;
describe.each(adapters('express', 'fastify', 'node'))(
'Falling through to less specific routes (%s)',
name => {
let app: INestApplication;
afterEach(async () => {
await app.close();
});
describe.each(versioning)(
'with $type versioning',
({ options, version }) => {
beforeEach(async () => {
app = await createApp(name, FallThroughModule, app =>
app.enableVersioning(options as any),
);
});
const send = (
method: 'get' | 'delete',
path: string,
v: string | undefined,
) =>
request(app.getHttpServer())
[method](path)
.set(v ? version(v) : {});
it('should fall through to ":id" when the literal route is for another version', async () => {
const v1 = await send('get', '/items/me', '1');
const v2 = await send('get', '/items/me', '2');
expect(v1.status).toBe(200);
expect(v1.text).toBe('id v1 me');
expect(v2.text).toBe('me v2');
});
it('should answer 404 when no route matches the version', async () => {
expect((await send('get', '/items/me', '9')).status).toBe(404);
expect((await send('get', '/items/5', '9')).status).toBe(404);
});
it('should fall through to a version-neutral route', async () => {
expect((await send('get', '/neutral/me', '1')).text).toBe(
'id neutral me',
);
expect((await send('get', '/neutral/me', undefined)).text).toBe(
'id neutral me',
);
expect((await send('get', '/neutral/me', '2')).text).toBe('me v2');
});
it('should fall through to a route for several versions', async () => {
expect((await send('get', '/many/me', '3')).text).toBe('id v1|v3 me');
expect((await send('get', '/many/me', '4')).status).toBe(404);
});
it('should fall through to a route for every method', async () => {
expect((await send('get', '/any/me', '1')).text).toBe('all v1 me');
expect((await send('delete', '/any/me', '1')).text).toBe('all v1 me');
});
},
);
describe('with a global prefix and header versioning', () => {
beforeEach(async () => {
app = await createApp(name, FallThroughModule, app => {
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.HEADER, header: 'v' });
});
});
it('should fall through under the prefix', async () => {
const res = await request(app.getHttpServer())
.get('/api/items/me')
.set('v', '1');
expect(res.text).toBe('id v1 me');
});
});
describe('with URI versioning', () => {
beforeEach(async () => {
app = await createApp(name, FallThroughModule, app =>
app.enableVersioning({ type: VersioningType.URI }),
);
});
it('should route each version by its path', async () => {
const get = (path: string) => request(app.getHttpServer()).get(path);
expect((await get('/v1/items/me')).text).toBe('id v1 me');
expect((await get('/v2/items/me')).text).toBe('me v2');
expect((await get('/v2/items/5')).status).toBe(404);
});
});
},
);
// Fastify does not support host filters
describe.each(adapters('express', 'node'))(
'Falling through from a host-filtered route (%s)',
name => {
let app: INestApplication;
beforeEach(async () => {
app = await createApp(name, HostFallThroughModule);
});
afterEach(async () => {
await app.close();
});
it('should fall through to ":id" when the host does not match', async () => {
const tenant = await request(app.getHttpServer())
.get('/tenants/me')
.set('host', 'acme.example.com');
const other = await request(app.getHttpServer())
.get('/tenants/me')
.set('host', 'other.org');
expect(tenant.text).toBe('tenant me');
expect(other.text).toBe('any host me');
});
},
);
@@ -0,0 +1,77 @@
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { adapters, createApp } from '../src/adapters.js';
import { TrailingSlashModule } from '../src/trailing-slash/trailing-slash.module.js';
// A catch-all registered before more specific routes of its controller, with
// middleware excluded for one of them ("files/public"). Express and Node
// treat "/files/public/" as "/files/public" (as their exclude() does), while
// Fastify treats it as another path.
const ignoresTrailingSlash = { express: true, fastify: false, node: true };
describe.each(adapters('express', 'fastify', 'node'))(
'Paths ending with "/" (%s)',
name => {
let app: INestApplication;
const get = (path: string) => request(app.getHttpServer()).get(path);
beforeEach(async () => {
app = await createApp(name, TrailingSlashModule);
});
afterEach(async () => {
await app.close();
});
it('should run the middleware for every path but the excluded one', async () => {
const excluded = ignoresTrailingSlash[name]
? ['/files/public', '/files/public/']
: ['/files/public'];
for (const path of [
'/files/public',
'/files/public/',
'/files/42',
'/files/42/',
'/files/a/b',
'/files/a/b/',
]) {
const res = await get(path);
expect(res.status).toBe(200);
expect(res.headers['x-middleware']).toBe(
excluded.includes(path) ? undefined : 'ran',
);
}
});
it('should give the more specific routes their paths', async () => {
const handlers = await Promise.all(
['/files/public', '/files/42', '/files/a/b'].map(
async path => (await get(path)).text,
),
);
expect(handlers).toEqual(
name === 'express'
? ['catch-all', 'catch-all', 'catch-all']
: ['public', 'by id', 'catch-all'],
);
});
if (ignoresTrailingSlash[name]) {
it('should send a path ending with "/" to the route it would go to without it', async () => {
for (const path of ['/files/public', '/files/42', '/files/a/b']) {
const [res, withSlash] = await Promise.all([
get(path),
get(`${path}/`),
]);
expect(withSlash.text).toBe(res.text);
expect(withSlash.headers['x-middleware']).toBe(
res.headers['x-middleware'],
);
}
});
}
},
);
@@ -0,0 +1,58 @@
import type { INestApplication } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type { NestNodeApplication } from '@nestjs/platform-node';
import request from 'supertest';
import { adapters, createApp } from '../src/adapters.js';
import {
partialsEngine,
VIEWS_DIR,
ViewsModule,
} from '../src/views/views.module.js';
// @Render() through an Express-style engine (hbs, ejs, ...), which reads the
// views directory from the settings Express passes to `__express()`
describe.each(adapters('express', 'node'))('@Render() (%s)', name => {
let app: INestApplication;
beforeEach(async () => {
app = await createApp(name, ViewsModule, app => {
app.use(
(req: unknown, res: { locals: any }, next: (err?: unknown) => void) => {
res.locals.user = 'Kamil';
// The data the handler returns wins
res.locals.message = 'Bye';
next();
},
);
if (name === 'express') {
const expressApp = app as NestExpressApplication;
expressApp.engine('tpl', partialsEngine.__express);
expressApp.setBaseViewsDir(VIEWS_DIR);
expressApp.setViewEngine('tpl');
} else {
const nodeApp = app as NestNodeApplication;
nodeApp.setBaseViewsDir(VIEWS_DIR);
nodeApp.setViewEngine({ engine: { tpl: partialsEngine } });
}
});
});
afterEach(async () => {
await app.close();
});
it('should render the view, with the partials the engine finds through the views directory', async () => {
const res = await request(app.getHttpServer()).get('/page');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/^text\/html/);
expect(res.text).toBe('<h1>Hello</h1><footer>2026</footer>');
});
it('should pass res.locals to the view, under the data the handler returns', async () => {
const res = await request(app.getHttpServer()).get('/locals');
expect(res.status).toBe(200);
expect(res.text).toBe('<p>Hello Kamil</p>');
});
});
@@ -0,0 +1,120 @@
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { adapters, createApp } from '../src/adapters.js';
import {
RootCatchAllModule,
WildcardsModule,
} from '../src/wildcards/wildcards.module.js';
// Express and Node only: Fastify rejects the "{*path}" and "{:id}" syntax.
// A wildcard or optional group after a "/" ("/admin/{*path}") matches
// "/admin/" and below, never the bare "/admin", and the middleware of the
// route sees exactly the requests the route does.
describe.each(adapters('express', 'node'))(
'Wildcard and optional routes (%s)',
name => {
let app: INestApplication;
const get = (path: string) => request(app.getHttpServer()).get(path);
afterEach(async () => {
await app.close();
});
describe('with middleware applied to their controllers', () => {
beforeEach(async () => {
app = await createApp(name, WildcardsModule);
});
it('should route the bare path to the index route, and below it to the legacy "*" catch-all', async () => {
const index = await get('/admin');
const catchAll = await get('/admin/x');
const withSlash = await get('/admin/x/');
const doubleSlash = await get('/admin//x');
expect(index.status).toBe(200);
expect(index.text).toBe('admin index');
expect(index.headers['x-middleware']).toBe('ran');
for (const res of [catchAll, withSlash, doubleSlash]) {
expect(res.status).toBe(200);
expect(res.text).toBe('admin catch-all');
expect(res.headers['x-middleware']).toBe('ran');
}
});
it('should run the middleware for HEAD requests to a catch-all', async () => {
const res = await request(app.getHttpServer()).head('/admin/x');
expect(res.status).toBe(200);
expect(res.headers['x-middleware']).toBe('ran');
});
it('should not match a "{*path}" route on the bare path, nor run its middleware', async () => {
const bare = await get('/named');
const root = await get('/named/');
const nested = await get('/named/a/b');
expect(bare.status).toBe(404);
expect(bare.headers['x-middleware']).toBeUndefined();
expect(root.status).toBe(200);
expect(root.body).toEqual({ path: null });
expect(root.headers['x-middleware']).toBe('ran');
expect(nested.body).toEqual({ path: ['a', 'b'] });
expect(nested.headers['x-middleware']).toBe('ran');
});
it('should not match a "{:id}" route on the bare path, nor run its middleware', async () => {
const bare = await get('/optional');
const root = await get('/optional/');
const withId = await get('/optional/1');
expect(bare.status).toBe(404);
expect(bare.headers['x-middleware']).toBeUndefined();
expect(root.body).toEqual({ id: null });
expect(root.headers['x-middleware']).toBe('ran');
expect(withId.body).toEqual({ id: '1' });
});
});
describe('optional groups and wildcards', () => {
beforeEach(async () => {
app = await createApp(name, WildcardsModule);
});
it('should match an optional trailing slash group with and without the slash', async () => {
expect((await get('/docs')).text).toBe('docs');
expect((await get('/docs/')).text).toBe('docs');
});
it('should match an optional extension group with and without the extension only', async () => {
expect((await get('/files')).text).toBe('files');
expect((await get('/files.json')).text).toBe('files');
expect((await get('/files.xml')).status).toBe(404);
});
it('should match an optional parameter in the middle of the path', async () => {
expect((await get('/users/posts')).body).toEqual({});
expect((await get('/users/1/posts')).body).toEqual({ id: '1' });
expect((await get('/users//posts')).status).toBe(404);
});
it('should split a wildcard before decoding it, keeping an encoded "/" in its segment', async () => {
expect((await get('/wild/a%2Fb/c')).body).toEqual(['a/b', 'c']);
expect((await get('/wild/100%25/a%20b')).body).toEqual(['100%', 'a b']);
});
});
describe('a root catch-all under a global prefix', () => {
beforeEach(async () => {
app = await createApp(name, RootCatchAllModule, app =>
app.setGlobalPrefix('api'),
);
});
it('should not match the bare prefix', async () => {
expect((await get('/api')).status).toBe(404);
expect((await get('/api/')).text).toBe('catch-all');
expect((await get('/api/x')).text).toBe('catch-all');
});
});
},
);
+60
View File
@@ -0,0 +1,60 @@
import type {
INestApplication,
NestApplicationOptions,
Type,
} from '@nestjs/common';
import { ExpressAdapter } from '@nestjs/platform-express';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
export type AdapterName = 'express' | 'fastify' | 'node';
const factories = {
express: () => new ExpressAdapter(),
fastify: () => new FastifyAdapter(),
node: () => new NodeAdapter(),
};
/**
* `[name]` tuples for `describe.each()`, for the adapters a behavior must be
* the same on.
*/
export function adapters(...names: AdapterName[]): [AdapterName][] {
return names.map(name => [name]);
}
/**
* Creates an application for `module` on the given adapter, lets `setup`
* configure it, and listens on 127.0.0.1.
*/
export async function createApp(
name: AdapterName,
module: Type<unknown>,
setup?: (app: INestApplication) => unknown,
options: NestApplicationOptions = {},
adapter = factories[name](),
): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
imports: [module],
}).compile();
const app = moduleRef.createNestApplication(adapter, {
logger: false,
...options,
});
await setup?.(app);
await listenOnLoopback(app);
if (adapter instanceof FastifyAdapter) {
await adapter.getInstance().ready();
}
return app;
}
/**
* Sets `X-Middleware: ran`, to tell which requests a middleware saw.
*/
export function markMiddleware(_req: unknown, res: any, next: () => void) {
(res.raw ?? res).setHeader('X-Middleware', 'ran');
next();
}
@@ -0,0 +1,12 @@
import { Body, Controller, Module, Post } from '@nestjs/common';
@Controller()
export class EchoController {
@Post('echo')
echo(@Body() body: unknown) {
return typeof body === 'string' ? body : JSON.stringify(body ?? null);
}
}
@Module({ controllers: [EchoController] })
export class BodyParsersModule {}
@@ -0,0 +1,101 @@
import {
ArgumentsHost,
Catch,
Controller,
ExceptionFilter,
Get,
HttpException,
Injectable,
MiddlewareConsumer,
Module,
NestMiddleware,
NestModule,
} from '@nestjs/common';
import { join } from 'path';
/**
* An http-errors-style error, as csrf, body-parser and many Express
* middleware raise: a status, a message that may be exposed, and a code.
*/
export class CsrfError extends Error {
readonly status = 403;
readonly statusCode = 403;
readonly expose = true;
readonly code = 'EBADCSRFTOKEN';
constructor() {
super('invalid csrf token');
}
}
/**
* Answers the `HttpException` that a client error from a middleware is mapped
* to with 499 and what it saw of the exception and of its cause, the original
* error, writing to the raw response so that it works the same on every
* adapter.
*/
@Catch(HttpException)
export class CsrfErrorFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse();
const raw = response.raw ?? response;
const cause = exception.cause as CsrfError | undefined;
raw.writeHead(499, { 'Content-Type': 'application/json' });
raw.end(
JSON.stringify({
status: exception.getStatus(),
cause: cause?.constructor.name,
code: cause?.code,
isCsrfError: cause instanceof CsrfError,
}),
);
}
}
/**
* Answers every exception with an error page sent through res.sendFile(), as
* applications do for their own 404 page, and counts its calls.
*/
@Catch()
export class ErrorPageFilter implements ExceptionFilter {
calls = 0;
catch(_exception: unknown, host: ArgumentsHost) {
this.calls++;
host
.switchToHttp()
.getResponse()
.status(404)
.sendFile(join(process.cwd(), 'Readme.md'));
}
}
@Injectable()
export class CsrfMiddleware implements NestMiddleware {
use(_req: unknown, _res: unknown, next: (error?: unknown) => void) {
next(new CsrfError());
}
}
@Controller()
export class ErrorsController {
@Get('ok')
ok() {
return 'ok';
}
@Get('guarded')
guarded() {
return 'guarded';
}
}
@Module({ controllers: [ErrorsController] })
export class ErrorsModule {}
@Module({ controllers: [ErrorsController] })
export class ClassMiddlewareErrorsModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(CsrfMiddleware).forRoutes('guarded');
}
}
@@ -0,0 +1,102 @@
import {
All,
Controller,
Get,
Module,
Param,
Version,
VERSION_NEUTRAL,
} from '@nestjs/common';
// Each controller has a literal route for version 2 and a less specific
// ":id" route for other versions: a request for "/<prefix>/me" in another
// version skips the literal route and falls through to ":id".
@Controller('items')
export class VersionedController {
@Get('me')
@Version('2')
me() {
return 'me v2';
}
@Get(':id')
@Version('1')
byId(@Param('id') id: string) {
return `id v1 ${id}`;
}
}
@Controller('neutral')
export class NeutralController {
@Get('me')
@Version('2')
me() {
return 'me v2';
}
@Get(':id')
@Version(VERSION_NEUTRAL)
byId(@Param('id') id: string) {
return `id neutral ${id}`;
}
}
@Controller('many')
export class ManyVersionsController {
@Get('me')
@Version('2')
me() {
return 'me v2';
}
@Get(':id')
@Version(['1', '3'])
byId(@Param('id') id: string) {
return `id v1|v3 ${id}`;
}
}
@Controller('any')
export class AllMethodsController {
@Get('me')
@Version('2')
me() {
return 'me v2';
}
@All(':id')
@Version('1')
byId(@Param('id') id: string) {
return `all v1 ${id}`;
}
}
@Module({
controllers: [
VersionedController,
NeutralController,
ManyVersionsController,
AllMethodsController,
],
})
export class FallThroughModule {}
@Controller({ path: 'tenants', host: ':tenant.example.com' })
export class TenantController {
@Get('me')
me() {
return 'tenant me';
}
}
@Controller('tenants')
export class AnyHostController {
@Get(':id')
byId(@Param('id') id: string) {
return `any host ${id}`;
}
}
@Module({ controllers: [TenantController, AnyHostController] })
export class HostFallThroughModule {}
@@ -0,0 +1,79 @@
import {
ArgumentsHost,
Catch,
Controller,
ExceptionFilter,
Get,
MiddlewareConsumer,
Module,
NestModule,
Param,
} from '@nestjs/common';
import { markMiddleware } from '../adapters.js';
@Controller('items')
export class ItemsController {
@Get(':id')
byId(@Param('id') id: string) {
return { length: id.length };
}
}
@Controller('files')
export class FilesController {
@Get(':id')
byId(@Param('id') id: string) {
return { length: id.length };
}
@Get('*')
catchAll() {
return 'catch-all';
}
}
@Controller('cats')
export class CatsController {
@Get()
list() {
return 'cats';
}
}
@Controller('admin')
export class AdminCatchAllController {
@Get('*')
catchAll() {
return 'admin catch-all';
}
}
@Module({
controllers: [
ItemsController,
FilesController,
CatsController,
AdminCatchAllController,
],
})
export class NodeSpecificsModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(markMiddleware).forRoutes('cats', AdminCatchAllController);
}
}
/**
* Catches everything, to show that the 414 goes through Nest's exception
* handling like any other error.
*/
@Catch()
export class CatchEverythingFilter implements ExceptionFilter {
catch(error: any, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse();
const raw = response.raw ?? response;
// A client error reaches filters as an HttpException
const status = error.getStatus?.() ?? error.statusCode ?? 500;
raw.writeHead(status, { 'Content-Type': 'application/json' });
raw.end(JSON.stringify({ filtered: true, status }));
}
}
@@ -0,0 +1,17 @@
import { Controller, Get, Ip, Module, Query, Req } from '@nestjs/common';
@Controller()
export class RequestOptionsController {
@Get('client')
client(@Ip() ip: string, @Req() req: { protocol: string; hostname: string }) {
return { ip, protocol: req.protocol, hostname: req.hostname };
}
@Get('search')
search(@Query() query: unknown) {
return query;
}
}
@Module({ controllers: [RequestOptionsController] })
export class RequestOptionsModule {}
@@ -0,0 +1 @@
kept
@@ -0,0 +1 @@
hello from a file
@@ -0,0 +1,65 @@
import { Controller, Get, Module, Param, Redirect, Res } from '@nestjs/common';
import { join } from 'path';
import { fileURLToPath } from 'url';
export const FILES_DIR = fileURLToPath(new URL('./files', import.meta.url));
@Controller()
export class ResponsesController {
@Get('redirect')
@Redirect('/target?from=a b', 302)
redirect() {}
@Get('redirect-dynamic')
@Redirect('/default')
redirectDynamic() {
return { url: '/dynamic', statusCode: 301 };
}
@Get('send-number')
sendNumber(@Res() res: any) {
res.send(42);
}
@Get('send-boolean')
sendBoolean(@Res() res: any) {
res.send(true);
}
@Get('typed-array')
typedArray() {
return new Uint16Array(new Uint8Array([1, 2, 3, 4]).buffer);
}
@Get('data-view')
dataView() {
return new DataView(new Uint8Array([9, 1, 2, 3, 9]).buffer, 1, 3);
}
@Get('buffer')
buffer() {
return Buffer.from([1, 2, 3]);
}
@Get('files/:name')
sendFile(@Param('name') name: string, @Res() res: any) {
res.sendFile(join(FILES_DIR, name));
}
@Get('rooted/:name')
sendRootedFile(@Param('name') name: string, @Res() res: any) {
res.sendFile(name, { root: FILES_DIR });
}
@Get('files-or-gone/:name')
sendFileOrGone(@Param('name') name: string, @Res() res: any) {
res.sendFile(join(FILES_DIR, name), (err?: Error) => {
if (err && !res.headersSent) {
res.status(410).send('gone');
}
});
}
}
@Module({ controllers: [ResponsesController] })
export class ResponsesModule {}
@@ -0,0 +1,39 @@
import {
Controller,
Get,
MiddlewareConsumer,
Module,
NestModule,
} from '@nestjs/common';
import { markMiddleware } from '../adapters.js';
// The catch-all comes first, so that Express (registration order) gives it
// every path, while Fastify and Node pick the most specific route
@Controller('files')
export class FilesController {
// The legacy form, converted to "{*path}" where supported
@Get('*')
catchAll() {
return 'catch-all';
}
@Get('public')
public() {
return 'public';
}
@Get(':id')
byId() {
return 'by id';
}
}
@Module({ controllers: [FilesController] })
export class TrailingSlashModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(markMiddleware)
.exclude('files/public')
.forRoutes(FilesController);
}
}
@@ -0,0 +1 @@
<h1>{{message}}</h1>{{> footer}}
@@ -0,0 +1 @@
<p>{{message}} {{user}}</p>
@@ -0,0 +1 @@
<footer>{{year}}</footer>
@@ -0,0 +1,53 @@
import { Controller, Get, Module, Render } from '@nestjs/common';
import { readFile } from 'fs/promises';
import { join } from 'path';
import { fileURLToPath } from 'url';
export const VIEWS_DIR = fileURLToPath(new URL('./templates', import.meta.url));
/**
* A minimal Express-style view engine (`__express(file, options, callback)`)
* that, like hbs and ejs, finds its partials ("{{> name}}") through the
* views directory Express passes in `options.settings.views`.
*/
export const partialsEngine = {
__express(
file: string,
options: Record<string, any>,
callback: (err: unknown, html?: string) => void,
) {
const render = async () => {
const views: string = [].concat(options.settings.views)[0];
let html = await readFile(file, 'utf8');
for (const [, name] of html.matchAll(/\{\{> (\w+)\}\}/g)) {
const partial = await readFile(
join(views, 'partials', `${name}.tpl`),
'utf8',
);
html = html.replace(`{{> ${name}}}`, partial);
}
return html.replace(/\{\{(\w+)\}\}/g, (_, key) =>
String(options[key] ?? ''),
);
};
render().then(html => callback(null, html), callback);
},
};
@Controller()
export class ViewsController {
@Get('page')
@Render('index')
page() {
return { message: 'Hello', year: 2026 };
}
@Get('locals')
@Render('locals')
locals() {
return { message: 'Hello' };
}
}
@Module({ controllers: [ViewsController] })
export class ViewsModule {}
@@ -0,0 +1,93 @@
import {
Controller,
Get,
MiddlewareConsumer,
Module,
NestModule,
Param,
} from '@nestjs/common';
import { markMiddleware } from '../adapters.js';
@Controller('admin')
export class AdminController {
@Get()
index() {
return 'admin index';
}
// The legacy form, converted to "{*path}"
@Get('*')
catchAll() {
return 'admin catch-all';
}
}
@Controller('named')
export class NamedWildcardController {
@Get('{*path}')
catchAll(@Param('path') path: string[] | undefined) {
return { path: path ?? null };
}
}
@Controller('optional')
export class OptionalParamController {
@Get('{:id}')
byId(@Param('id') id: string | undefined) {
return { id: id ?? null };
}
}
@Controller()
export class ShapesController {
@Get('docs{/}')
docs() {
return 'docs';
}
@Get('files{.json}')
files() {
return 'files';
}
@Get('users{/:id}/posts')
posts(@Param() params: Record<string, string>) {
return params;
}
@Get('wild/*splat')
splat(@Param('splat') splat: string[]) {
return splat;
}
}
@Module({
controllers: [
AdminController,
NamedWildcardController,
OptionalParamController,
ShapesController,
],
})
export class WildcardsModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(markMiddleware)
.forRoutes(
AdminController,
NamedWildcardController,
OptionalParamController,
);
}
}
@Controller()
export class RootCatchAllController {
@Get('*')
catchAll() {
return 'catch-all';
}
}
@Module({ controllers: [RootCatchAllController] })
export class RootCatchAllModule {}
+38
View File
@@ -0,0 +1,38 @@
{
"compilerOptions": {
"types": ["vitest/globals"],
"module": "commonjs",
"declaration": false,
"noImplicitAny": false,
"removeComments": true,
"noLib": false,
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "ES2023",
"sourceMap": true,
"allowJs": true,
"strictNullChecks": true,
"outDir": "./dist",
"paths": {
"@nestjs/common": ["../../packages/common"],
"@nestjs/common/*": ["../../packages/common/*"],
"@nestjs/core": ["../../packages/core"],
"@nestjs/core/*": ["../../packages/core/*"],
"@nestjs/microservices": ["../../packages/microservices"],
"@nestjs/microservices/*": ["../../packages/microservices/*"],
"@nestjs/websockets": ["../../packages/websockets"],
"@nestjs/websockets/*": ["../../packages/websockets/*"],
"@nestjs/testing": ["../../packages/testing"],
"@nestjs/testing/*": ["../../packages/testing/*"],
"@nestjs/platform-express": ["../../packages/platform-express"],
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-fastify": ["../../packages/platform-fastify"],
"@nestjs/platform-fastify/*": ["../../packages/platform-fastify/*"],
"@nestjs/platform-node": ["../../packages/platform-node"],
"@nestjs/platform-node/*": ["../../packages/platform-node/*"]
}
},
"include": ["src/**/*", "e2e/**/*"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,63 @@
import { NodeAdapter } from '@nestjs/platform-node';
import { Test, TestingModule } from '@nestjs/testing';
import { AppModule } from '../src/app.module.js';
import { randomPort, readTlsOptions } from './utils.js';
describe('Get URL (Node Application)', () => {
let testModule: TestingModule;
let port: number;
beforeEach(async () => {
testModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
});
beforeEach(async () => {
port = await randomPort();
});
it('should be able to get a loopback address', async () => {
const app = testModule.createNestApplication(new NodeAdapter());
await app.listen(port);
expect(await app.getUrl()).toMatch(
new RegExp(`http://(\\[::1\\]|127\\.0\\.0\\.1):${port}`),
);
await app.close();
});
it('should be able to get the IPv4 address', async () => {
const app = testModule.createNestApplication(new NodeAdapter());
await app.listen(port, '127.0.0.1');
expect(await app.getUrl()).toEqual(`http://127.0.0.1:${port}`);
await app.close();
});
it('should return 127.0.0.1 for 0.0.0.0', async () => {
const app = testModule.createNestApplication(new NodeAdapter());
await app.listen(port, '0.0.0.0');
expect(await app.getUrl()).toEqual(`http://127.0.0.1:${port}`);
await app.close();
});
it('should return a loopback address in a callback (default bind)', () => {
const app = testModule.createNestApplication(new NodeAdapter());
return app.listen(port, async () => {
expect(await app.getUrl()).toMatch(
new RegExp(`http://(\\[::1\\]|127\\.0\\.0\\.1):${port}`),
);
await app.close();
});
});
it('should throw an error for calling getUrl before listen', async () => {
const app = testModule.createNestApplication(new NodeAdapter());
await expect(app.getUrl()).rejects.toEqual(
'app.listen() needs to be called before calling app.getUrl()',
);
});
it('should return an https url for "httpsOptions"', async () => {
const app = testModule.createNestApplication(new NodeAdapter(), {
httpsOptions: readTlsOptions(),
});
await app.listen(port, '127.0.0.1');
expect(await app.getUrl()).toEqual(`https://127.0.0.1:${port}`);
await app.close();
});
});
@@ -0,0 +1,39 @@
import { INestApplication } from '@nestjs/common';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test, TestingModule } from '@nestjs/testing';
import { AppModule } from '../src/app.module.js';
describe('Listen (Node Application)', () => {
let testModule: TestingModule;
let app: INestApplication;
beforeEach(async () => {
testModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = testModule.createNestApplication(new NodeAdapter());
});
afterEach(async () => {
await app.close();
});
it('should resolve with httpServer on success', async () => {
const response = await app.listen(3000);
expect(response).toEqual(app.getHttpServer());
});
it('should reject if the port is not available', async () => {
await app.listen(3000);
const secondApp = testModule.createNestApplication(new NodeAdapter());
await expect(secondApp.listen(3000)).rejects.toMatchObject({
code: 'EADDRINUSE',
});
});
it('should reject if there is an invalid host', async () => {
await expect(app.listen(3000, '1')).rejects.toMatchObject({
code: 'EADDRNOTAVAIL',
});
});
});
@@ -0,0 +1,99 @@
import { NodeAdapter } from '@nestjs/platform-node';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AppModule } from '../src/app.module.js';
import { readEvents, readTicks, resetState } from '../src/app.controller.js';
import {
getJson,
requestAndAbort,
sleep,
startRequest,
waitFor,
} from './utils.js';
describe('Observable response lifecycle (Node Application)', () => {
let app: INestApplication;
let port: number;
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication(new NodeAdapter());
await app.listen(0, '127.0.0.1');
port = Number(new URL(await app.getUrl()).port);
resetState();
});
afterEach(async () => {
await app.close();
});
it('stops the producer Observable once the client disconnects', async () => {
await requestAndAbort(port, '/observable', 100);
// The server sees the disconnect only once the socket close reaches it,
// and the producer may tick once more in between
await expect(
waitFor(() => readEvents().includes('producer-stopped')),
).resolves.toBe(true);
const ticksAtStop = readTicks();
expect(ticksAtStop).toBeGreaterThan(0);
await sleep(200);
expect(readTicks()).toBe(ticksAtStop);
});
it('still replies normally when the Observable completes', async () => {
await expect(getJson(port, '/final')).resolves.toEqual({ ok: true });
});
it('stops an intercepted producer Observable once the client disconnects', async () => {
await requestAndAbort(port, '/observable-intercepted', 100);
// The server sees the disconnect only once the socket close reaches it,
// and the producer may tick once more in between
await expect(
waitFor(() => readEvents().includes('producer-stopped')),
).resolves.toBe(true);
const ticksAtStop = readTicks();
expect(ticksAtStop).toBeGreaterThan(0);
await sleep(200);
expect(readTicks()).toBe(ticksAtStop);
});
it('still replies normally when an intercepted Observable completes', async () => {
await expect(getJson(port, '/final-intercepted')).resolves.toEqual({
value: 3,
});
});
it('lets interceptors finish after a disconnect when the handler returns no Observable', async () => {
const abort = startRequest(port, '/slow-intercepted');
await waitFor(() => readEvents().includes('handler-start'));
abort();
await waitFor(
() =>
readEvents().includes('handler-done') &&
readEvents().includes('finalize'),
);
expect(readEvents()).toEqual([
'handler-start',
'handler-done',
'commit',
'finalize',
]);
});
it('routes a late error to the exception filters after a disconnect when the handler returns no Observable', async () => {
const abort = startRequest(port, '/slow-intercepted-error');
await waitFor(() => readEvents().includes('handler-start'));
abort();
await waitFor(() => readEvents().includes('filter:late failure'));
expect(readEvents()).toEqual(['handler-start', 'filter:late failure']);
});
});
@@ -0,0 +1,80 @@
import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { NodeModule } from '../src/node.module.js';
import { listenOnLoopback } from '../../../_support/listen-on-loopback.js';
describe('Raw body (Node Application)', () => {
let app: NestNodeApplication;
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [NodeModule],
}).compile();
app = moduleFixture.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
{
rawBody: true,
},
);
await listenOnLoopback(app);
});
afterEach(async () => {
await app.close();
});
describe('application/json', () => {
const body = '{ "amount":0.0 }';
it('should return exact post body', async () => {
const response = await request(app.getHttpServer())
.post('/')
.set('Content-Type', 'application/json')
.send(body)
.expect(201);
expect(response.body).toEqual({
parsed: {
amount: 0,
},
raw: body,
});
});
it('should work if post body is empty', async () => {
await request(app.getHttpServer())
.post('/')
.set('Content-Type', 'application/json')
.expect(201);
});
});
describe('application/x-www-form-urlencoded', () => {
const body = 'content=this is a post\'s content by "Nest"';
it('should return exact post body', async () => {
const response = await request(app.getHttpServer())
.post('/')
.set('Content-Type', 'application/x-www-form-urlencoded')
.send(body)
.expect(201);
expect(response.body).toEqual({
parsed: {
content: 'this is a post\'s content by "Nest"',
},
raw: body,
});
});
it('should work if post body is empty', async () => {
await request(app.getHttpServer())
.post('/')
.set('Content-Type', 'application/x-www-form-urlencoded')
.expect(201);
});
});
});
@@ -0,0 +1,13 @@
import { Controller, Post, RawBodyRequest, Req } from '@nestjs/common';
import type { NodeRequest } from '@nestjs/platform-node';
@Controller()
export class NodeController {
@Post()
getRawBody(@Req() req: RawBodyRequest<NodeRequest>) {
return {
parsed: req.body,
raw: req.rawBody!.toString(),
};
}
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { NodeController } from './node.controller.js';
@Module({
controllers: [NodeController],
})
export class NodeModule {}
@@ -0,0 +1,60 @@
import { Controller, Get, Logger, UnauthorizedException } from '@nestjs/common';
import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { listenOnLoopback } from '../../../_support/listen-on-loopback.js';
@Controller()
class TestController {
@Get()
async get() {
await new Promise(resolve => setImmediate(resolve));
return 'ok';
}
}
describe('Request hooks (Node Application)', () => {
let app: NestNodeApplication;
let adapter: NodeAdapter;
beforeEach(async () => {
adapter = new NodeAdapter();
const module = await Test.createTestingModule({
controllers: [TestController],
}).compile();
app = module.createNestApplication<NestNodeApplication>(adapter, {
logger: false,
});
await listenOnLoopback(app);
});
afterEach(async () => {
await app.close();
vi.restoreAllMocks();
});
it('should send a request hook rejection to the exception filters', async () => {
adapter.setOnRequestHook(async () => {
throw new UnauthorizedException();
});
await request(app.getHttpServer())
.get('/')
.timeout(2000)
.expect(401, { message: 'Unauthorized', statusCode: 401 });
});
it('should log a request hook rejection after done() and keep the response', async () => {
const error = new Error('Late hook failure');
const logError = vi
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => {});
adapter.setOnRequestHook(async (_req, _res, done) => {
done();
throw error;
});
await request(app.getHttpServer()).get('/').timeout(2000).expect(200, 'ok');
expect(logError).toHaveBeenCalledExactlyOnceWith(error);
});
});
@@ -1,12 +1,14 @@
import { INestApplication } from '@nestjs/common';
import { ExpressAdapter } from '@nestjs/platform-express';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import { LateHeadersModule } from '../src/late-headers.module.js';
const adapters = [
['Express', () => new ExpressAdapter()],
['Fastify', () => new FastifyAdapter()],
['Node', () => new NodeAdapter()],
] as const;
describe.each(adapters)('Sse late headers (%s)', (_name, createAdapter) => {
@@ -0,0 +1,586 @@
import { ValidationPipe } from '@nestjs/common';
import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import { EventSource } from 'eventsource';
import { AppModule } from '../src/app.module.js';
import {
fetchInterceptorDelayedSseStats,
fetchPromiseDelayedSseStats,
fetchSignalDelayedSseStats,
releaseInterceptorDelayedSse,
releasePromiseDelayedSse,
sleep,
waitForInterceptorDelayedSseClose,
waitForInterceptorDelayedSseRequestStart,
waitForPromiseDelayedSseClose,
waitForPromiseDelayedSseRequestStart,
waitForSignalCompletingSseAbort,
waitForSignalDelayedSseRequestStart,
waitForSignalDelayedSseResourceCleanup,
waitForSignalStreamingSseTeardown,
} from './utils.js';
describe('Sse (Node Application)', () => {
let app: NestNodeApplication;
let eventSource: EventSource;
describe('without forceCloseConnections', () => {
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
);
app.useGlobalPipes(new ValidationPipe({ transform: true }));
await app.listen(0);
const url = await app.getUrl();
eventSource = new EventSource(url + '/sse', {
fetch: (input, init) =>
fetch(input, {
...init,
headers: {
...init?.headers,
connection: 'keep-alive',
},
}),
});
});
// The order of actions is very important here. When not using `forceCloseConnections`,
// the SSe eventsource should close the connections in order to signal the server that
// the keep-alive connection can be ended.
afterEach(async () => {
eventSource.close();
await app.close();
});
it('receives events from server', () =>
new Promise<void>(done => {
eventSource.addEventListener('message', event => {
expect(JSON.parse(event.data)).toEqual({
hello: 'world',
});
done();
});
}));
it('returns a validation error status before opening the SSE stream', async () => {
const response = await fetch(
`${await app.getUrl()}/sse/validated?limit=invalid`,
{
headers: {
accept: 'text/event-stream',
},
},
);
expect(response.status).toBe(400);
expect(response.headers.get('content-type')).toContain(
'application/json',
);
});
});
describe('with forceCloseConnections', () => {
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
{
forceCloseConnections: true,
},
);
app.useGlobalPipes(new ValidationPipe({ transform: true }));
await app.listen(0);
const url = await app.getUrl();
eventSource = new EventSource(url + '/sse', {
fetch: (input, init) =>
fetch(input, {
...init,
headers: {
...init?.headers,
connection: 'keep-alive',
},
}),
});
});
afterEach(async () => {
await app.close();
eventSource.close();
});
it('receives events from server', () =>
new Promise<void>(done => {
eventSource.addEventListener('message', event => {
expect(JSON.parse(event.data)).toEqual({
hello: 'world',
});
done();
});
}));
it('returns a validation error status before opening the SSE stream', async () => {
const response = await fetch(
`${await app.getUrl()}/sse/validated?limit=invalid`,
{
headers: {
accept: 'text/event-stream',
},
},
);
expect(response.status).toBe(400);
expect(response.headers.get('content-type')).toContain(
'application/json',
);
});
});
describe('backpressure', () => {
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
{
forceCloseConnections: true,
},
);
await app.listen(0);
});
afterEach(async () => {
await app.close();
});
it('should return SSE headers when the producer completes without events', async () => {
const response = await fetch(`${await app.getUrl()}/sse/empty`);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain(
'text/event-stream',
);
expect(response.headers.get('cache-control')).toContain('no-cache');
expect(await response.text()).toBe('\n');
});
it('should deliver all events when bursting large payloads', async () => {
const url = await app.getUrl();
const n = 50;
const size = 65536;
const response = await fetch(`${url}/sse/burst?n=${n}&size=${size}`);
const body = await response.text();
const dataLines = body
.split('\n')
.filter(line => line.startsWith('data: '));
expect(dataLines).toHaveLength(n);
});
it('should stream events from POST SSE routes with a request body', async () => {
const url = await app.getUrl();
const response = await fetch(`${url}/sse/post`, {
method: 'POST',
headers: {
accept: 'text/event-stream',
'content-type': 'application/json',
},
body: JSON.stringify({ content: 'chunk-0' }),
});
expect(response.status).toBe(201);
expect(response.headers.get('content-type')).toContain(
'text/event-stream',
);
expect(await response.text()).toContain('data: {"content":"chunk-0"}');
});
});
describe('event data that cannot be serialized', () => {
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
{
forceCloseConnections: true,
},
);
await app.listen(0);
});
afterEach(async () => {
await app.close();
});
it('should send number and boolean data as JSON', async () => {
const response = await fetch(`${await app.getUrl()}/sse/primitives`, {
signal: AbortSignal.timeout(2000),
});
expect(await response.text()).toBe(
'\nid: 1\ndata: 1\n\nid: 2\ndata: 0\n\nid: 3\ndata: true\n\n',
);
});
it('should send an error event and end the stream', async () => {
const response = await fetch(`${await app.getUrl()}/sse/unserializable`, {
signal: AbortSignal.timeout(2000),
});
const body = await response.text();
expect(response.status).toBe(200);
expect(body).toContain('data: {"n":1}');
expect(body).toContain(
'event: error\nid: 2\ndata: Internal server error',
);
expect(body).not.toContain('data: {"n":3}');
});
it('should send an error event and end the stream when an event field cannot be serialized', async () => {
const response = await fetch(
`${await app.getUrl()}/sse/unserializable-field`,
{ signal: AbortSignal.timeout(2000) },
);
const body = await response.text();
expect(response.status).toBe(200);
expect(body).toContain('data: {"n":1}');
expect(body).toContain('event: error');
expect(body).not.toContain('data: {"n":3}');
});
it('should return an error status when a field of the first event cannot be serialized', async () => {
const response = await fetch(
`${await app.getUrl()}/sse/unserializable-field-first`,
{ signal: AbortSignal.timeout(2000) },
);
expect(response.status).toBe(500);
expect(response.headers.get('content-type')).not.toContain(
'text/event-stream',
);
});
it('should return an error status when the first event cannot be serialized', async () => {
const response = await fetch(
`${await app.getUrl()}/sse/unserializable-first`,
{ signal: AbortSignal.timeout(2000) },
);
expect(response.status).toBe(500);
expect(response.headers.get('content-type')).not.toContain(
'text/event-stream',
);
});
});
describe('Promise<Observable> disconnect handling', () => {
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
{
forceCloseConnections: true,
},
);
await app.listen(0);
});
afterEach(async () => {
await app.close();
});
it('should not subscribe the producer if the GET SSE client disconnects before the promise resolves', async () => {
const url = await app.getUrl();
const abortController = new AbortController();
const responsePromise = fetch(`${url}/sse/promise-delayed`, {
headers: {
accept: 'text/event-stream',
},
signal: abortController.signal,
});
await waitForPromiseDelayedSseRequestStart(url);
abortController.abort();
await responsePromise.catch(error => {
expect(error.name).toBe('AbortError');
});
await waitForPromiseDelayedSseClose(url);
expect(await releasePromiseDelayedSse(url)).toBe(1);
// Allow the released promise to resolve and the deferred path to run.
await sleep(0);
const stats = await fetchPromiseDelayedSseStats(url);
expect(stats.closeEventsObserved).toBe(1);
expect(stats.requestsStarted).toBe(1);
expect(stats.runningStreams).toBe(0);
expect(stats.subscriptionsStarted).toBe(0);
expect(stats.teardownsObserved).toBe(0);
});
it('should not subscribe the producer if the POST SSE client disconnects before the promise resolves', async () => {
const url = await app.getUrl();
const abortController = new AbortController();
const responsePromise = fetch(`${url}/sse/post/promise-delayed`, {
method: 'POST',
headers: {
accept: 'text/event-stream',
'content-type': 'application/json',
},
body: JSON.stringify({ content: 'chunk-0' }),
signal: abortController.signal,
});
await waitForPromiseDelayedSseRequestStart(url);
abortController.abort();
await responsePromise.catch(error => {
expect(error.name).toBe('AbortError');
});
await waitForPromiseDelayedSseClose(url);
expect(await releasePromiseDelayedSse(url)).toBe(1);
// Allow the released promise to resolve and the deferred path to run.
await sleep(0);
const stats = await fetchPromiseDelayedSseStats(url);
expect(stats.closeEventsObserved).toBe(1);
expect(stats.requestsStarted).toBe(1);
expect(stats.runningStreams).toBe(0);
expect(stats.subscriptionsStarted).toBe(0);
expect(stats.teardownsObserved).toBe(0);
});
});
describe('Promise<Observable> disconnect handling with interceptor', () => {
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
{
forceCloseConnections: true,
},
);
await app.listen(0);
});
afterEach(async () => {
await app.close();
});
it('should not subscribe the producer if the GET SSE client disconnects before the promise resolves', async () => {
const url = await app.getUrl();
const abortController = new AbortController();
const responsePromise = fetch(`${url}/sse/interceptor/promise-delayed`, {
headers: {
accept: 'text/event-stream',
},
signal: abortController.signal,
});
await waitForInterceptorDelayedSseRequestStart(url);
abortController.abort();
await responsePromise.catch(error => {
expect(error.name).toBe('AbortError');
});
await waitForInterceptorDelayedSseClose(url);
expect(await releaseInterceptorDelayedSse(url)).toBe(1);
// Allow the released promise to resolve and the deferred path to run.
await sleep(0);
const stats = await fetchInterceptorDelayedSseStats(url);
expect(stats.closeEventsObserved).toBe(1);
expect(stats.requestsStarted).toBe(1);
expect(stats.runningStreams).toBe(0);
expect(stats.subscriptionsStarted).toBe(0);
expect(stats.teardownsObserved).toBe(0);
});
it('should not subscribe the producer if the POST SSE client disconnects before the promise resolves', async () => {
const url = await app.getUrl();
const abortController = new AbortController();
const responsePromise = fetch(
`${url}/sse/post/interceptor/promise-delayed`,
{
method: 'POST',
headers: {
accept: 'text/event-stream',
'content-type': 'application/json',
},
body: JSON.stringify({ content: 'chunk-0' }),
signal: abortController.signal,
},
);
await waitForInterceptorDelayedSseRequestStart(url);
abortController.abort();
await responsePromise.catch(error => {
expect(error.name).toBe('AbortError');
});
await waitForInterceptorDelayedSseClose(url);
expect(await releaseInterceptorDelayedSse(url)).toBe(1);
// Allow the released promise to resolve and the deferred path to run.
await sleep(0);
const stats = await fetchInterceptorDelayedSseStats(url);
expect(stats.closeEventsObserved).toBe(1);
expect(stats.requestsStarted).toBe(1);
expect(stats.runningStreams).toBe(0);
expect(stats.subscriptionsStarted).toBe(0);
expect(stats.teardownsObserved).toBe(0);
});
it('should clean up setup-phase resources via AbortSignal when the client disconnects mid-await', async () => {
const url = await app.getUrl();
const abortController = new AbortController();
const responsePromise = fetch(`${url}/sse/signal/promise-delayed`, {
headers: {
accept: 'text/event-stream',
},
signal: abortController.signal,
});
await waitForSignalDelayedSseRequestStart(url);
abortController.abort();
await responsePromise.catch(error => {
expect(error.name).toBe('AbortError');
});
// The handler's 80ms setup completes after the disconnect; it should
// observe signal.aborted and clean up the allocated resource itself,
// without the producer Observable ever being subscribed.
await waitForSignalDelayedSseResourceCleanup(url);
const stats = await fetchSignalDelayedSseStats(url);
expect(stats.requestsStarted).toBe(1);
expect(stats.resourcesAllocated).toBe(1);
expect(stats.resourcesCleaned).toBe(1);
expect(stats.subscriptionsStarted).toBe(0);
});
});
describe('SseSignal lifetime', () => {
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
{
forceCloseConnections: true,
},
);
await app.listen(0);
});
afterEach(async () => {
await app.close();
});
it('aborts the signal once a stream that runs to completion has ended', async () => {
const url = await app.getUrl();
const response = await fetch(`${url}/sse/signal/completing`, {
headers: {
accept: 'text/event-stream',
},
});
const body = await response.text();
expect(body).toContain('data: {"chunk":0}');
expect(body).toContain('data: {"chunk":1}');
// The client never disconnected: the signal is a request-lifetime token,
// so it aborts because the stream itself ended.
const stats = await waitForSignalCompletingSseAbort(url);
expect(stats.subscriptionsStarted).toBe(1);
expect(stats.teardownsObserved).toBe(1);
expect(stats.abortsObserved).toBe(1);
});
it('aborts the signal when the client disconnects after the producer is subscribed', async () => {
const url = await app.getUrl();
const abortController = new AbortController();
const response = await fetch(`${url}/sse/signal/streaming`, {
headers: {
accept: 'text/event-stream',
},
signal: abortController.signal,
});
// Read until the first event arrives so the producer is definitely
// subscribed before the client goes away.
if (!response.body) {
throw new Error('Expected the SSE response to expose a readable body.');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let received = '';
while (!received.includes('data:')) {
const { done, value } = await reader.read();
if (done) {
break;
}
received += decoder.decode(value, { stream: true });
}
expect(received).toContain('data:');
abortController.abort();
await reader.cancel().catch(() => undefined);
const stats = await waitForSignalStreamingSseTeardown(url);
expect(stats.subscriptionsStarted).toBe(1);
expect(stats.abortsObserved).toBe(1);
expect(stats.teardownsObserved).toBe(1);
});
});
});
@@ -0,0 +1,94 @@
import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
import { listenOnLoopback } from '../../../_support/listen-on-loopback.js';
describe('Body Parser (Node Application)', () => {
const moduleFixture = Test.createTestingModule({
imports: [AppModule],
});
let app: NestNodeApplication;
afterEach(async () => {
await app.close();
});
describe('application/json', () => {
const stringLimit = '{ "msg": "Hello, World" }';
const stringOverLimit = '{ "msg": "Hello, World!" }';
beforeEach(async () => {
const testFixture = await moduleFixture.compile();
app = testFixture
.createNestApplication<NestNodeApplication>(new NodeAdapter(), {
rawBody: true,
logger: false,
})
.useBodyParser('json', { limit: Buffer.from(stringLimit).byteLength });
await listenOnLoopback(app);
});
it('should allow request with matching body limit', async () => {
const response = await request(app.getHttpServer())
.post('/')
.set('Content-Type', 'application/json')
.send(stringLimit)
.expect(201);
expect(response.body).toEqual({
raw: stringLimit,
});
});
it('should fail if post body is larger than limit', async () => {
await request(app.getHttpServer())
.post('/')
.set('Content-Type', 'application/json')
.send(stringOverLimit)
.expect(413);
});
});
describe('application/x-www-form-urlencoded', () => {
const stringLimit = 'msg=Hello, World';
const stringOverLimit = 'msg=Hello, World!';
beforeEach(async () => {
const testFixture = await moduleFixture.compile();
app = testFixture
.createNestApplication<NestNodeApplication>(new NodeAdapter(), {
rawBody: true,
logger: false,
})
.useBodyParser('urlencoded', {
limit: Buffer.from(stringLimit).byteLength,
extended: true,
});
await listenOnLoopback(app);
});
it('should allow request with matching body limit', async () => {
const response = await request(app.getHttpServer())
.post('/')
.set('Content-Type', 'application/x-www-form-urlencoded')
.send(stringLimit)
.expect(201);
expect(response.body).toEqual({
raw: stringLimit,
});
});
it('should fail if post body is larger than limit', async () => {
await request(app.getHttpServer())
.post('/')
.set('Content-Type', 'application/x-www-form-urlencoded')
.send(stringOverLimit)
.expect(413);
});
});
});
@@ -0,0 +1,122 @@
/**
* E2E tests for the HTTP QUERY method (RFC 10008) — Node adapter.
*
* supertest/superagent don't expose a .query() HTTP-method shorthand, so we
* use Node's native http.request() which accepts any method string.
* Node.js 22 recognises QUERY natively via llhttp 9.2 (RFC 10008).
*/
import * as http from 'http';
import { HttpStatus, INestApplication } from '@nestjs/common';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import { QueryMethodModule } from '../src/query-method.module';
function httpRequest(
url: string,
method: string,
body?: string,
): Promise<{ statusCode: number; json: any }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const options: http.RequestOptions = {
hostname: parsed.hostname,
port: Number(parsed.port),
path: parsed.pathname + parsed.search,
method,
headers: body
? {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
}
: {},
};
const req = http.request(options, res => {
let data = '';
res.on('data', chunk => (data += chunk));
res.on('end', () => {
resolve({
statusCode: res.statusCode!,
json: data ? JSON.parse(data) : null,
});
});
});
req.on('error', reject);
if (body) req.write(body);
req.end();
});
}
describe('QueryMethod (Node)', () => {
let app: INestApplication;
let baseUrl: string;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [QueryMethodModule],
}).compile();
app = module.createNestApplication(new NodeAdapter());
await app.listen(0);
const port = (app.getHttpServer().address() as { port: number }).port;
baseUrl = `http://localhost:${port}`;
});
afterEach(async () => {
await app.close();
});
it('should route QUERY /items to the handler', async () => {
const { statusCode, json } = await httpRequest(
`${baseUrl}/items`,
'QUERY',
JSON.stringify({ name: 'nestjs', page: 1 }),
);
expect(statusCode).toBe(HttpStatus.OK);
expect(json.filters).toEqual({ name: 'nestjs', page: 1 });
});
it('should route QUERY /items/search to the @QueryMethod("search") handler', async () => {
const { statusCode, json } = await httpRequest(
`${baseUrl}/items/search`,
'QUERY',
JSON.stringify({ name: 'test' }),
);
expect(statusCode).toBe(HttpStatus.OK);
expect(json.endpoint).toBe('search');
expect(json.filters).toEqual({ name: 'test' });
});
it('should return 404 for GET /items (wrong method)', async () => {
const { statusCode } = await httpRequest(`${baseUrl}/items`, 'GET');
expect(statusCode).toBe(HttpStatus.NOT_FOUND);
});
it('combines @Param(), @Query() and @Body() on a QUERY route, through a guard/interceptor/pipe', async () => {
const { statusCode, json } = await httpRequest(
`${baseUrl}/items/42?tenant=acme`,
'QUERY',
JSON.stringify({ name: 'nestjs' }),
);
expect(statusCode).toBe(HttpStatus.OK);
expect(json).toEqual({
id: '42',
tenant: 'ACME', // uppercased by the pipe bound to @Query()
filters: { name: 'nestjs' },
intercepted: true, // added by the interceptor
});
});
it('runs exception filters for a QUERY route', async () => {
const { statusCode, json } = await httpRequest(
`${baseUrl}/items/reject`,
'QUERY',
JSON.stringify({}),
);
expect(statusCode).toBe(HttpStatus.BAD_REQUEST);
expect(json).toEqual({ handledBy: 'RejectFilter' });
});
});
+3 -1
View File
@@ -22,7 +22,9 @@
"@nestjs/platform-express": ["../../packages/platform-express"],
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-fastify": ["../../packages/platform-fastify"],
"@nestjs/platform-fastify/*": ["../../packages/platform-fastify/*"]
"@nestjs/platform-fastify/*": ["../../packages/platform-fastify/*"],
"@nestjs/platform-node": ["../../packages/platform-node"],
"@nestjs/platform-node/*": ["../../packages/platform-node/*"]
}
},
"include": ["src/**/*", "e2e/**/*"],
@@ -0,0 +1,141 @@
import { NestApplicationOptions } from '@nestjs/common';
import { RouteConflictException } from '@nestjs/core/errors/exceptions/route-conflict.exception.js';
import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { DuplicateModule } from '../src/duplicate/duplicate.module.js';
import { MultiUserModule } from '../src/multi-user/multi-user.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
interface CapturedLogger {
warnings: string[];
logger: {
log(message: any, ...rest: any[]): void;
warn(message: any, ...rest: any[]): void;
error(message: any, ...rest: any[]): void;
debug(message: any, ...rest: any[]): void;
verbose(message: any, ...rest: any[]): void;
fatal(message: any, ...rest: any[]): void;
};
}
function createCaptureLogger(): CapturedLogger {
const warnings: string[] = [];
return {
warnings,
logger: {
log: () => {},
warn: (message: any) => warnings.push(String(message)),
error: () => {},
debug: () => {},
verbose: () => {},
fatal: () => {},
},
};
}
async function buildNodeApp(
moduleClass: any,
options: NestApplicationOptions,
capture?: CapturedLogger,
): Promise<NestNodeApplication> {
const moduleRef = await Test.createTestingModule({
imports: [moduleClass],
}).compile();
const app = moduleRef.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
options,
);
if (capture) {
app.useLogger(capture.logger);
}
return app;
}
describe('Route conflict policy (Node)', () => {
let app: NestNodeApplication | undefined;
afterEach(async () => {
if (app) {
await app.close();
app = undefined;
}
});
describe('multi-user fixture', () => {
it('boots even with shadow=error because the Node adapter is not order-sensitive', async () => {
app = await buildNodeApp(MultiUserModule, {
routeConflictPolicy: { shadow: 'error' },
});
await expect(listenOnLoopback(app)).resolves.toBeDefined();
});
it('natively routes every endpoint correctly without any strategy', async () => {
app = await buildNodeApp(MultiUserModule, {});
await listenOnLoopback(app);
const cases: Array<{ url: string; body: Record<string, unknown> }> = [
{ url: '/users/me', body: { handler: 'me' } },
{ url: '/users/images', body: { handler: 'images' } },
{
url: '/users/images/42',
body: { handler: 'imageById', imageId: '42' },
},
{ url: '/users/abc', body: { handler: 'byId', userId: 'abc' } },
];
for (const testCase of cases) {
await request(app.getHttpServer())
.get(testCase.url)
.expect(200, testCase.body);
}
});
});
describe('duplicate fixture', () => {
it('aborts on duplicate=error with the aggregated RouteConflictException', async () => {
const builtApp = await buildNodeApp(DuplicateModule, {
routeConflictPolicy: { duplicate: 'error' },
});
await expect(builtApp.init()).rejects.toBeInstanceOf(
RouteConflictException,
);
});
it('boots on duplicate=warn and emits one warning', async () => {
const capture = createCaptureLogger();
app = await buildNodeApp(
DuplicateModule,
{ routeConflictPolicy: { duplicate: 'warn' } },
capture,
);
await expect(listenOnLoopback(app)).resolves.toBeDefined();
expect(capture.warnings).toHaveLength(1);
expect(capture.warnings[0]).toContain('/users/me');
});
it('boots on duplicate=off silently (later duplicate is dropped, first wins)', async () => {
const capture = createCaptureLogger();
app = await buildNodeApp(
DuplicateModule,
{ routeConflictPolicy: { duplicate: 'off' } },
capture,
);
await expect(listenOnLoopback(app)).resolves.toBeDefined();
await request(app.getHttpServer())
.get('/users/me')
.expect(200, { from: 'A' });
expect(capture.warnings).toHaveLength(0);
});
it('boots without a policy, and the first duplicate wins (Fastify throws at registration)', async () => {
app = await buildNodeApp(DuplicateModule, {});
await expect(listenOnLoopback(app)).resolves.toBeDefined();
await request(app.getHttpServer())
.get('/users/me')
.expect(200, { from: 'A' });
});
});
});
@@ -0,0 +1,128 @@
import { NodeAdapter } from '@nestjs/platform-node';
import { INestApplication, NestApplicationOptions } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { MultiUserModule } from '../src/multi-user/multi-user.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
async function buildApp(
options: NestApplicationOptions,
): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
imports: [MultiUserModule],
}).compile();
return moduleRef.createNestApplication(new NodeAdapter(), options);
}
describe('Route resolution strategy: specificity (Node)', () => {
let app: INestApplication | undefined;
afterEach(async () => {
if (app) {
await app.close();
app = undefined;
}
});
describe('with the multi-user fixture', () => {
beforeEach(async () => {
app = await buildApp({ routeResolutionStrategy: 'specificity' });
await listenOnLoopback(app);
});
it('routes `/users/me` to the static handler, not `:userId`', async () => {
const response = await request(app!.getHttpServer()).get('/users/me');
expect(response.status).toBe(200);
expect(response.body).toEqual({ handler: 'me' });
});
it('routes `/users/images` to the images handler, not `:userId`', async () => {
const response = await request(app!.getHttpServer()).get('/users/images');
expect(response.status).toBe(200);
expect(response.body).toEqual({ handler: 'images' });
});
it('routes `/users/images/42` to the imageById handler', async () => {
const response = await request(app!.getHttpServer()).get(
'/users/images/42',
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ handler: 'imageById', imageId: '42' });
});
it('routes `/users/abc` to the byId handler with the userId param', async () => {
const response = await request(app!.getHttpServer()).get('/users/abc');
expect(response.status).toBe(200);
expect(response.body).toEqual({ handler: 'byId', userId: 'abc' });
});
it('routes `/users/123` to the byId handler with the userId param', async () => {
const response = await request(app!.getHttpServer()).get('/users/123');
expect(response.status).toBe(200);
expect(response.body).toEqual({ handler: 'byId', userId: '123' });
});
it('returns 404 for the controller root `/users` (no handler declared there)', async () => {
const response = await request(app!.getHttpServer()).get('/users');
expect(response.status).toBe(404);
});
it('returns 404 for paths deeper than any declared route', async () => {
const response = await request(app!.getHttpServer()).get(
'/users/me/details',
);
expect(response.status).toBe(404);
});
it('returns 404 for an unrelated path with the same prefix shape', async () => {
const response = await request(app!.getHttpServer()).get('/admin/me');
expect(response.status).toBe(404);
});
it('returns 404 for the root path', async () => {
const response = await request(app!.getHttpServer()).get('/');
expect(response.status).toBe(404);
});
it('returns 404 for a known path on the wrong HTTP method', async () => {
const response = await request(app!.getHttpServer()).post('/users/me');
expect(response.status).toBe(404);
});
});
describe('combined with routeConflictPolicy', () => {
it('boots without error when shadow=error and specificity ordering resolves all shadows', async () => {
// The multi-user fixture has GET /users/:userId (declared first) and
// GET /users/me (declared second). In declaration order this is a shadow
// conflict that would abort the app. With specificity sorting, the literal
// /users/me is registered before /:userId, so at runtime every route is
// reachable — the shadow is resolved by the sort and should not trigger
// shadow: 'error'.
await expect(
buildApp({
routeResolutionStrategy: 'specificity',
routeConflictPolicy: { shadow: 'error' },
}).then(a => {
app = a;
return a.init();
}),
).resolves.not.toThrow();
});
it('routes correctly when both options are active together', async () => {
app = await buildApp({
routeResolutionStrategy: 'specificity',
routeConflictPolicy: { shadow: 'warn' },
});
await listenOnLoopback(app);
const meRes = await request(app.getHttpServer()).get('/users/me');
expect(meRes.status).toBe(200);
expect(meRes.body).toEqual({ handler: 'me' });
const idRes = await request(app.getHttpServer()).get('/users/42');
expect(idRes.status).toBe(200);
expect(idRes.body).toEqual({ handler: 'byId', userId: '42' });
});
});
});
@@ -0,0 +1,108 @@
import { NestApplicationOptions, VersioningType } from '@nestjs/common';
import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { VersionedWildcardModule } from '../src/versioned-wildcard/versioned-wildcard.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
async function buildVersionedNodeApp(
options: NestApplicationOptions,
): Promise<NestNodeApplication> {
const moduleRef = await Test.createTestingModule({
imports: [VersionedWildcardModule],
}).compile();
const app = moduleRef.createNestApplication<NestNodeApplication>(
new NodeAdapter(),
options,
);
app.enableVersioning({ type: VersioningType.URI });
return app;
}
// The Node adapter counterpart of the Fastify spec: URI versioning + a
// wildcard sharing a prefix with a literal route, through the deferred
// registration path of `NestApplication.registerRouter`.
//
// The fixture uses Fastify's `@Get('*')`. On the Node adapter, as on Express,
// the legacy converter turns it into the optional named wildcard `{*path}`:
// the tail is exposed as `path` (so `@Param('*')` is undefined). As with
// Express, `/v1/users/` matches the catch-all but the bare `/v1/users` does
// not: the "/" before the optional wildcard is part of the path.
describe('Route conflict policy (Node, URI versioning + wildcard)', () => {
let app: NestNodeApplication | undefined;
afterEach(async () => {
if (app) {
await app.close();
app = undefined;
}
});
it('boots with shadow=error because the shadow policy is filtered out', async () => {
// The detector reports `/v1/users/me` vs `/v1/users/*path` as a
// shadow pair, but the Node adapter matches by specificity (it is not
// order-sensitive), so `filteredPolicy` drops the shadow level.
app = await buildVersionedNodeApp({
routeConflictPolicy: { shadow: 'error' },
});
await expect(listenOnLoopback(app)).resolves.toBeDefined();
});
describe('runtime routing under URI versioning', () => {
beforeEach(async () => {
app = await buildVersionedNodeApp({});
await listenOnLoopback(app);
});
it('routes GET /v1/users/me to the literal handler (UsersMeController)', async () => {
await request(app!.getHttpServer())
.get('/v1/users/me')
.expect(200, { handler: 'me' });
});
it('routes GET /v1/users/anything to the versioned wildcard handler', async () => {
await request(app!.getHttpServer())
.get('/v1/users/anything')
.expect(200, { handler: 'catchAll' });
});
it('routes GET /v1/users/a/b/c to the wildcard (absorbs multi-segment tail)', async () => {
await request(app!.getHttpServer())
.get('/v1/users/a/b/c')
.expect(200, { handler: 'catchAll' });
});
it('routes GET /v1/users/ to the wildcard as well (the converted wildcard is optional)', async () => {
await request(app!.getHttpServer())
.get('/v1/users/')
.expect(200, { handler: 'catchAll' });
});
it('does not route the bare GET /v1/users to the wildcard, as Express does not', async () => {
await request(app!.getHttpServer()).get('/v1/users').expect(404);
});
it('routes the unversioned handler outside the /v1 prefix', async () => {
await request(app!.getHttpServer())
.get('/users/profile')
.expect(200, { handler: 'profile' });
});
});
it('still routes correctly through the deferred-registration path when a conflict policy is set', async () => {
// Setting any policy flips `registerRouter` into the deferred
// install pass (`registerResolvedRoute`). This asserts the version
// filter survives that detour for a wildcard handler.
app = await buildVersionedNodeApp({
routeConflictPolicy: { duplicate: 'error' },
});
await listenOnLoopback(app);
await request(app.getHttpServer())
.get('/v1/users/me')
.expect(200, { handler: 'me' });
await request(app.getHttpServer())
.get('/v1/users/anything')
.expect(200, { handler: 'catchAll' });
});
});
@@ -0,0 +1,79 @@
import { NodeAdapter } from '@nestjs/platform-node';
import { INestApplication, NestApplicationOptions } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { WildcardModule } from '../src/wildcard/wildcard.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
async function buildApp(
options: NestApplicationOptions,
): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
imports: [WildcardModule],
}).compile();
return moduleRef.createNestApplication(new NodeAdapter(), options);
}
describe('Route resolution strategy with wildcards: specificity (Node)', () => {
let app: INestApplication | undefined;
beforeEach(async () => {
app = await buildApp({ routeResolutionStrategy: 'specificity' });
await listenOnLoopback(app);
});
afterEach(async () => {
if (app) {
await app.close();
app = undefined;
}
});
it('routes `/files/readme` to the literal handler (literal beats param and wildcard)', async () => {
const response = await request(app!.getHttpServer()).get('/files/readme');
expect(response.status).toBe(200);
expect(response.body).toEqual({ handler: 'readme' });
});
it('routes `/files/42` to the param handler (param beats wildcard)', async () => {
const response = await request(app!.getHttpServer()).get('/files/42');
expect(response.status).toBe(200);
expect(response.body).toEqual({ handler: 'byId', fileId: '42' });
});
it('routes `/files/some/nested/path` to the wildcard handler (only wildcard matches multi-segment)', async () => {
const response = await request(app!.getHttpServer()).get(
'/files/some/nested/path',
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ handler: 'catchAll' });
});
it('routes unknown single-segment paths to the param handler, not the wildcard', async () => {
const response = await request(app!.getHttpServer()).get(
'/files/anything-here',
);
expect(response.status).toBe(200);
expect(response.body).toEqual({ handler: 'byId', fileId: 'anything-here' });
});
it('returns 404 for the controller root `/files` (wildcard requires at least one extra segment)', async () => {
const response = await request(app!.getHttpServer()).get('/files');
expect(response.status).toBe(404);
});
it('returns 404 for paths outside the controller prefix', async () => {
const response = await request(app!.getHttpServer()).get('/other/path');
expect(response.status).toBe(404);
});
it('returns 404 for the root path', async () => {
const response = await request(app!.getHttpServer()).get('/');
expect(response.status).toBe(404);
});
it('returns 404 for a known path on the wrong HTTP method', async () => {
const response = await request(app!.getHttpServer()).post('/files/readme');
expect(response.status).toBe(404);
});
});
+3 -1
View File
@@ -28,7 +28,9 @@
"@nestjs/platform-express": ["../../packages/platform-express"],
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-fastify": ["../../packages/platform-fastify"],
"@nestjs/platform-fastify/*": ["../../packages/platform-fastify/*"]
"@nestjs/platform-fastify/*": ["../../packages/platform-fastify/*"],
"@nestjs/platform-node": ["../../packages/platform-node"],
"@nestjs/platform-node/*": ["../../packages/platform-node/*"]
}
},
"include": ["src/**/*", "e2e/**/*"],
@@ -9,6 +9,7 @@ import {
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import * as http from 'http';
import request from 'supertest';
@@ -20,6 +21,7 @@ const HOST = 'app.example';
const adapters = [
['Express', () => new ExpressAdapter()],
['Fastify', () => new FastifyAdapter()],
['Node', () => new NodeAdapter()],
] as const;
@Catch(ForbiddenException)
@@ -326,9 +328,9 @@ describe.each(adapters)('CSRF protection (%s)', (_name, createAdapter) => {
handler: 'github/:event',
});
await post('/api/hooks/github').expect(403);
// Express serves it with the "github" route, which is not excluded;
// Fastify does not serve it (strict routing, no empty "event")
await post('/api/hooks/github/').expect(_name === 'Express' ? 403 : 404);
// Express and Node serve it with the "github" route, which is not
// excluded; Fastify does not serve it (strict routing, no empty "event")
await post('/api/hooks/github/').expect(_name === 'Fastify' ? 404 : 403);
});
});
@@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
@@ -10,6 +11,7 @@ import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
const adapters = [
['Express', () => new ExpressAdapter()],
['Fastify', () => new FastifyAdapter()],
['Node', () => new NodeAdapter()],
] as const;
const DEFAULT_CSP =
+2
View File
@@ -29,6 +29,8 @@
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-fastify": ["../../packages/platform-fastify"],
"@nestjs/platform-fastify/*": ["../../packages/platform-fastify/*"],
"@nestjs/platform-node": ["../../packages/platform-node"],
"@nestjs/platform-node/*": ["../../packages/platform-node/*"],
"@nestjs/platform-socket.io": ["../../packages/platform-socket.io"],
"@nestjs/platform-socket.io/*": ["../../packages/platform-socket.io/*"],
"@nestjs/platform-ws": ["../../packages/platform-ws"],
+102
View File
@@ -0,0 +1,102 @@
import { NestNodeApplication, NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import { readFileSync } from 'fs';
import { get } from 'http';
import { join } from 'path';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
import {
getHttpBaseOptions,
sendCanceledHttpRequest,
sendHttpRequest,
} from './utils.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
const readme = readFileSync(join(process.cwd(), 'Readme.md'));
const readmeString = readme.toString();
describe('Node FileSend', () => {
let app: NestNodeApplication;
beforeEach(async () => {
const modRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = modRef.createNestApplication(new NodeAdapter());
await listenOnLoopback(app);
});
it('should return a file from a stream', async () => {
return request(app.getHttpServer())
.get('/file/stream/')
.expect(200)
.expect(res => {
expect(res.body.toString()).toBe(readmeString);
});
});
it('should return a file from a buffer', async () => {
return request(app.getHttpServer())
.get('/file/buffer')
.expect(200)
.expect(res => {
expect(res.body.toString()).toBe(readmeString);
});
});
it('should not stream a non-file', async () => {
return request(app.getHttpServer())
.get('/non-file/pipe-method')
.expect(200)
.expect({ value: 'Hello world' });
});
it('should return a file from an RxJS stream', async () => {
return request(app.getHttpServer())
.get('/file/rxjs/stream/')
.expect(200)
.expect(res => {
expect(res.body.toString()).toBe(readmeString);
});
});
it('should return a file with correct headers', async () => {
return request(app.getHttpServer())
.get('/file/with/headers')
.expect(200)
.expect('Content-Type', 'text/markdown')
.expect('Content-Disposition', 'attachment; filename="Readme.md"')
.expect('Content-Length', readme.byteLength.toString())
.expect(res => {
expect(res.text).toBe(readmeString);
});
});
it('should answer a HEAD request with the headers of a file', async () => {
return request(app.getHttpServer())
.head('/file/with/headers')
.expect(200)
.expect('Content-Type', 'text/markdown')
.expect('Content-Disposition', 'attachment; filename="Readme.md"')
.expect('Content-Length', readme.byteLength.toString());
});
it('should answer a HEAD request with an error if the file does not exist', async () => {
return request(app.getHttpServer()).head('/file/not/exist').expect(400);
});
it('should return an error if the file does not exist', async () => {
return request(app.getHttpServer()).get('/file/not/exist').expect(400);
});
it('should not complete the response when the stream fails after sending data', async () => {
const url = await getHttpBaseOptions(app);
const complete = await new Promise<boolean>(resolve => {
get(new URL('/file/fails/midway', url), res => {
res.resume();
res.on('close', () => resolve(res.complete));
}).on('error', () => resolve(false));
});
expect(complete).toBe(false);
});
// TODO: temporarily turned off (flaky test)
it.skip('should allow for the client to end the response and be able to make another', async () => {
const url = await getHttpBaseOptions(app);
await sendCanceledHttpRequest(new URL('/file/slow', url));
const res = await sendHttpRequest(new URL('/file/stream', url));
expect(res.statusCode).toBe(200);
});
});
+2
View File
@@ -27,6 +27,8 @@
"@nestjs/testing/*": ["../../packages/testing/*"],
"@nestjs/platform-express": ["../../packages/platform-express"],
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-node": ["../../packages/platform-node"],
"@nestjs/platform-node/*": ["../../packages/platform-node/*"],
"@nestjs/platform-socket.io": ["../../packages/platform-socket.io"],
"@nestjs/platform-socket.io/*": ["../../packages/platform-socket.io/*"],
"@nestjs/platform-ws": ["../../packages/platform-ws"],
@@ -0,0 +1,714 @@
import { NodeAdapter } from '@nestjs/platform-node';
import { INestApplication, VersioningType } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { NodeRequest as Request } from '@nestjs/platform-node';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
describe('Custom Versioning', () => {
const extractor = (request: Request): string | string[] => {
const versions = request
.header('Accept')
?.split(',')
.map(header => header.match(/v(\d+\.?\d*)\+json$/))
.filter(match => match && match.length)
.map(matchArray => matchArray![1])
.sort()
.reverse();
return versions!;
};
let app: INestApplication;
// ======================================================================== //
describe('without global default version', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication(new NodeAdapter());
app.enableVersioning({
type: VersioningType.CUSTOM,
extractor,
});
await listenOnLoopback(app);
});
describe('GET /', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Hello World V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Hello World V2!');
});
// There's a known limitation of the express and node adapters, where
// it cannot handle selection of the highest matched version properly.
//
// it('V2, if two versions are requested, select the highest version', () => {
// return request(app.getHttpServer())
// .get('/')
// .set({
// Accept: 'application/foo.v1+json, application/foo.v2+json',
// })
// .expect(200)
// .expect('Hello World V2!');
// });
it('V3', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/foo.v3+json',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /:param', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Parameter V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Parameter V2!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/foo.v3+json',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /multiple', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V2, if an unsupported version is specified, select the lower supported version', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/foo.v2+json, application/foo.v3+json',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/foo.v3+json',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/multiple').expect(404);
});
});
describe('GET /neutral', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Neutral');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Neutral');
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/json',
})
.expect(200)
.expect('Neutral');
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/neutral')
.expect(200)
.expect('Neutral');
});
});
describe('GET /override', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Override Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Override Version 2');
});
// There's a known limitation of the express and node adapters, where
// it cannot handle selection of the highest matched version properly.
//
// it('V2, if two versions are requested, select the highest version', () => {
// return request(app.getHttpServer())
// .get('/override')
// .set({
// Accept: 'application/foo.v1+json, application/foo.v2+json',
// })
// .expect(200)
// .expect('Override Version 2');
// });
it('V3', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/foo.v3+json',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/override').expect(404);
});
});
describe('GET /override-partial', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Override Partial Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Override Partial Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/foo.v3+json',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/override-partial')
.expect(404);
});
});
describe('GET /foo/bar', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Hello FooBar!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Hello FooBar!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/foo.v3+json',
})
.expect(200)
.expect('Hello FooBar!');
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/json',
})
.expect(200)
.expect('Hello FooBar!');
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.expect(200)
.expect('Hello FooBar!');
});
});
afterAll(async () => {
await app.close();
});
});
// ======================================================================== //
describe('with the global default version: "1"', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication(new NodeAdapter());
app.enableVersioning({
type: VersioningType.CUSTOM,
extractor,
defaultVersion: '1',
});
await listenOnLoopback(app);
});
describe('GET /', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Hello World V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Hello World V2!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/foo.v3+json',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /:param', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Parameter V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Parameter V2!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/foo.v3+json',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /multiple', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/foo.v3+json',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/multiple').expect(404);
});
});
describe('GET /neutral', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Neutral');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Neutral');
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/json',
})
.expect(200)
.expect('Neutral');
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/neutral')
.expect(200)
.expect('Neutral');
});
});
describe('GET /override', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Override Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Override Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/foo.v3+json',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/override').expect(404);
});
});
describe('GET /override-partial', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Override Partial Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/foo.v2+json',
})
.expect(200)
.expect('Override Partial Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/foo.v3+json',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/override-partial')
.expect(404);
});
});
describe('GET /foo/bar', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/foo.v1+json',
})
.expect(200)
.expect('Hello FooBar!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/foo.v2+json',
})
.expect(404);
});
it('V3', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/foo.v3+json',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/foo/bar').expect(404);
});
});
afterAll(async () => {
await app.close();
});
});
});
@@ -3,6 +3,7 @@ import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { NodeAdapter } from '@nestjs/platform-node';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
@@ -96,4 +97,44 @@ describe('Default Versioning behavior', () => {
await app.close();
});
});
// ======================================================================== //
describe('Node', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication(new NodeAdapter());
app.enableVersioning();
await listenOnLoopback(app);
});
describe('GET /', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1')
.expect(200)
.expect('Hello World V1!');
});
it('No Version', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /neutral', () => {
it('No Version', () => {
return request(app.getHttpServer())
.get('/neutral')
.expect(200)
.expect('Neutral');
});
});
afterAll(async () => {
await app.close();
});
});
});
@@ -0,0 +1,665 @@
import { NodeAdapter } from '@nestjs/platform-node';
import { INestApplication, VersioningType } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
describe('Header Versioning', () => {
let app: INestApplication;
// ======================================================================== //
describe('without global default version', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication(new NodeAdapter());
app.enableVersioning({
type: VersioningType.HEADER,
header: 'X-API-Version',
});
await listenOnLoopback(app);
});
describe('GET /', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Hello World V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Hello World V2!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/')
.set({
'X-API-Version': '3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/')
.set({
'X-API-Version': '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /:param', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Parameter V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Parameter V2!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
'X-API-Version': '3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
'X-API-Version': '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /multiple', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
'X-API-Version': '3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
'X-API-Version': '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/multiple').expect(404);
});
});
describe('GET /neutral', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Neutral');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Neutral');
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
'X-API-Version': '',
})
.expect(200)
.expect('Neutral');
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/neutral')
.expect(200)
.expect('Neutral');
});
});
describe('GET /override', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Override Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Override Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/override')
.set({
'X-API-Version': '3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override')
.set({
'X-API-Version': '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/override').expect(404);
});
});
describe('GET /override-partial', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Override Partial Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Override Partial Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
'X-API-Version': '3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
'X-API-Version': '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/override-partial')
.expect(404);
});
});
describe('GET /foo/bar', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Hello FooBar!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Hello FooBar!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
'X-API-Version': '3',
})
.expect(200)
.expect('Hello FooBar!');
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
'X-API-Version': '',
})
.expect(200)
.expect('Hello FooBar!');
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.expect(200)
.expect('Hello FooBar!');
});
});
afterAll(async () => {
await app.close();
});
});
// ======================================================================== //
describe('with the global default version: "1"', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication(new NodeAdapter());
app.enableVersioning({
type: VersioningType.HEADER,
header: 'X-API-Version',
defaultVersion: '1',
});
await listenOnLoopback(app);
});
describe('GET /', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Hello World V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Hello World V2!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/')
.set({
'X-API-Version': '3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/')
.set({
'X-API-Version': '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /:param', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Parameter V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Parameter V2!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
'X-API-Version': '3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
'X-API-Version': '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /multiple', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
'X-API-Version': '3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
'X-API-Version': '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/multiple').expect(404);
});
});
describe('GET /neutral', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Neutral');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Neutral');
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
'X-API-Version': '',
})
.expect(200)
.expect('Neutral');
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/neutral')
.expect(200)
.expect('Neutral');
});
});
describe('GET /override', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Override Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Override Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/override')
.set({
'X-API-Version': '3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override')
.set({
'X-API-Version': '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/override').expect(404);
});
});
describe('GET /override-partial', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Override Partial Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
'X-API-Version': '2',
})
.expect(200)
.expect('Override Partial Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
'X-API-Version': '3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
'X-API-Version': '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/override-partial')
.expect(404);
});
});
describe('GET /foo/bar', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
'X-API-Version': '1',
})
.expect(200)
.expect('Hello FooBar!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
'X-API-Version': '2',
})
.expect(404);
});
it('V3', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
'X-API-Version': '3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
'X-API-Version': '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/foo/bar').expect(404);
});
});
afterAll(async () => {
await app.close();
});
});
});
@@ -0,0 +1,786 @@
import { NodeAdapter } from '@nestjs/platform-node';
import { INestApplication, VersioningType } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
describe('Media Type Versioning', () => {
let app: INestApplication;
// ======================================================================== //
describe('without global default version', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication(new NodeAdapter());
app.enableVersioning({
type: VersioningType.MEDIA_TYPE,
key: 'v=',
});
await listenOnLoopback(app);
});
describe('GET /', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Hello World V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Hello World V2!');
});
it.each([
'application/json;v="2"',
'application/json;note="a;v=1";v=2',
'text/html;note="a,application/json;v=1", application/json;v=2',
'application/json;note="a\\";v=1";v=2',
'application/json;v="\\2"',
'application/json;v=2;note="unterminated',
])('V2 with quoted parameters: %s', async acceptHeader => {
await request(app.getHttpServer())
.get('/')
.set({ Accept: acceptHeader })
.expect(200)
.expect('Hello World V2!');
});
it.each([
'application/json;note="a;v=2',
'text/html;note="a,application/json;v=2',
])(
'ignores a version after an unterminated quote: %s',
async acceptHeader => {
await request(app.getHttpServer())
.get('/')
.set({ Accept: acceptHeader })
.expect(404);
},
);
it('V2 after other parameters', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/json;q=0.9;v=2',
})
.expect(200)
.expect('Hello World V2!');
});
it('V2 in a list of media types', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'text/html, application/json;v=2, */*;q=0.1',
})
.expect(200)
.expect('Hello World V2!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/json;v=3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /:param', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Parameter V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Parameter V2!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/json;v=3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /multiple', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/json;v=3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/multiple').expect(404);
});
});
describe('GET /neutral', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Neutral');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Neutral');
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/json',
})
.expect(200)
.expect('Neutral');
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/neutral')
.expect(200)
.expect('Neutral');
});
});
describe('GET /override', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Override Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Override Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/json;v=3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/override').expect(404);
});
});
describe('GET /override-partial', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Override Partial Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Override Partial Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/json;v=3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/override-partial')
.expect(404);
});
});
describe('GET /foo/bar', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Hello FooBar!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Hello FooBar!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/json;v=3',
})
.expect(200)
.expect('Hello FooBar!');
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/json',
})
.expect(200)
.expect('Hello FooBar!');
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.expect(200)
.expect('Hello FooBar!');
});
});
describe('GET /multiple-neutral', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/multiple-neutral')
.set({
Accept: 'application/json;v=1',
})
.expect(404);
});
it('V2', () => {
return request(app.getHttpServer())
.get('/multiple-neutral')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Multiple Versions Neutral or 2');
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/multiple-neutral')
.set({
Accept: 'application/json',
})
.expect(200)
.expect('Multiple Versions Neutral or 2');
});
it.each([
'application/json;note="a;v=1"',
'text/html;note="a,application/json;v=1"',
])('No Version with a quoted parameter: %s', async acceptHeader => {
await request(app.getHttpServer())
.get('/multiple-neutral')
.set({ Accept: acceptHeader })
.expect(200)
.expect('Multiple Versions Neutral or 2');
});
it.each([
'application/json;note="a;v=2',
'text/html;note="a,application/json;v=2',
])(
'treats a version after an unterminated quote as unversioned: %s',
async acceptHeader => {
await request(app.getHttpServer())
.get('/multiple-neutral')
.set({ Accept: acceptHeader })
.expect(200)
.expect('Multiple Versions Neutral or 2');
},
);
it('No Version with other parameters', () => {
return request(app.getHttpServer())
.get('/multiple-neutral')
.set({
Accept: 'application/json;charset=utf-8',
})
.expect(200)
.expect('Multiple Versions Neutral or 2');
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/multiple-neutral')
.expect(200)
.expect('Multiple Versions Neutral or 2');
});
});
afterAll(async () => {
await app.close();
});
});
// ======================================================================== //
describe('with the global default version: "1"', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication(new NodeAdapter());
app.enableVersioning({
type: VersioningType.MEDIA_TYPE,
key: 'v=',
defaultVersion: '1',
});
await listenOnLoopback(app);
});
describe('GET /', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Hello World V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Hello World V2!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/json;v=3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /:param', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Parameter V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Parameter V2!');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: 'application/json;v=3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/param/hello')
.set({
Accept: '',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /multiple', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/json;v=3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/multiple')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/multiple').expect(404);
});
});
describe('GET /neutral', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Neutral');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Neutral');
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/neutral')
.set({
Accept: 'application/json',
})
.expect(200)
.expect('Neutral');
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/neutral')
.expect(200)
.expect('Neutral');
});
});
describe('GET /override', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Override Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Override Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/json;v=3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/override').expect(404);
});
});
describe('GET /override-partial', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Override Partial Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/json;v=2',
})
.expect(200)
.expect('Override Partial Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/json;v=3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override-partial')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer())
.get('/override-partial')
.expect(404);
});
});
describe('GET /foo/bar', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/json;v=1',
})
.expect(200)
.expect('Hello FooBar!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/json;v=2',
})
.expect(404);
});
it('V3', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/json;v=3',
})
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.set({
Accept: 'application/json',
})
.expect(404);
});
it('No Header', () => {
return request(app.getHttpServer()).get('/foo/bar').expect(404);
});
});
afterAll(async () => {
await app.close();
});
});
});
@@ -0,0 +1,486 @@
import { NodeAdapter } from '@nestjs/platform-node';
import { INestApplication, VersioningType } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
describe('URI Versioning', () => {
let app: INestApplication;
// ======================================================================== //
describe('without global default version', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication(new NodeAdapter());
app.enableVersioning({
type: VersioningType.URI,
});
await listenOnLoopback(app);
});
describe('GET /', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1/')
.expect(200)
.expect('Hello World V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/v2/')
.expect(200)
.expect('Hello World V2!');
});
it('V3', () => {
return request(app.getHttpServer()).get('/v3/').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /:param', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1/param/hello')
.expect(200)
.expect('Parameter V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/v2/param/hello')
.expect(200)
.expect('Parameter V2!');
});
it('V3', () => {
return request(app.getHttpServer()).get('/v3/param/hello').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer()).get('/param/hello').expect(404);
});
});
describe('GET /multiple', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1/multiple')
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/v2/multiple')
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V3', () => {
return request(app.getHttpServer()).get('/v3/multiple').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer()).get('/multiple').expect(404);
});
});
describe('GET /neutral', () => {
it('No Version', () => {
return request(app.getHttpServer())
.get('/neutral')
.expect(200)
.expect('Neutral');
});
});
describe('GET /override', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1/override')
.expect(200)
.expect('Override Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/v2/override')
.expect(200)
.expect('Override Version 2');
});
it('V3', () => {
return request(app.getHttpServer()).get('/v3/override').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer()).get('/override').expect(404);
});
});
describe('GET /override-partial', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1/override-partial')
.expect(200)
.expect('Override Partial Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/v2/override-partial')
.expect(200)
.expect('Override Partial Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/v3/override-partial')
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override-partial')
.expect(404);
});
});
describe('GET /foo/bar', () => {
it('V1', () => {
return request(app.getHttpServer()).get('/v1/foo/bar').expect(404);
});
it('V2', () => {
return request(app.getHttpServer()).get('/v2/foo/bar').expect(404);
});
it('V3', () => {
return request(app.getHttpServer()).get('/v3/foo/bar').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/foo/bar')
.expect(200)
.expect('Hello FooBar!');
});
});
afterAll(async () => {
await app.close();
});
});
// ======================================================================== //
describe('with the global default version: "1"', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication(new NodeAdapter());
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
await listenOnLoopback(app);
});
describe('GET /', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1')
.expect(200)
.expect('Hello World V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/v2')
.expect(200)
.expect('Hello World V2!');
});
it('V3', () => {
return request(app.getHttpServer()).get('/v3').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer()).get('/').expect(404);
});
});
describe('GET /:param', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1/param/hello')
.expect(200)
.expect('Parameter V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/v2/param/hello')
.expect(200)
.expect('Parameter V2!');
});
it('V3', () => {
return request(app.getHttpServer()).get('/v3/param/hello').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer()).get('/param/hello').expect(404);
});
});
describe('GET /multiple', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1/multiple')
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/v2/multiple')
.expect(200)
.expect('Multiple Versions 1 or 2');
});
it('V3', () => {
return request(app.getHttpServer()).get('/v3/multiple').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer()).get('/multiple').expect(404);
});
});
describe('GET /neutral', () => {
it('No Version', () => {
return request(app.getHttpServer())
.get('/neutral')
.expect(200)
.expect('Neutral');
});
});
describe('GET /override', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1/override')
.expect(200)
.expect('Override Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/v2/override')
.expect(200)
.expect('Override Version 2');
});
it('V3', () => {
return request(app.getHttpServer()).get('/v3/override').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer()).get('/override').expect(404);
});
});
describe('GET /override-partial', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1/override-partial')
.expect(200)
.expect('Override Partial Version 1');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/v2/override-partial')
.expect(200)
.expect('Override Partial Version 2');
});
it('V3', () => {
return request(app.getHttpServer())
.get('/v3/override-partial')
.expect(404);
});
it('No Version', () => {
return request(app.getHttpServer())
.get('/override-partial')
.expect(404);
});
});
describe('GET /foo/bar', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1/foo/bar')
.expect(200)
.expect('Hello FooBar!');
});
it('V2', () => {
return request(app.getHttpServer()).get('/v2/foo/bar').expect(404);
});
it('V3', () => {
return request(app.getHttpServer()).get('/v3/foo/bar').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer()).get('/foo/bar').expect(404);
});
});
afterAll(async () => {
await app.close();
});
});
// ======================================================================== //
describe('with the global prefix enabled and an excluded route', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication(new NodeAdapter());
app.setGlobalPrefix('api', { exclude: ['/foo/bar'] });
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
await listenOnLoopback(app);
});
describe('GET /', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/api/v1')
.expect(200)
.expect('Hello World V1!');
});
it('V2', () => {
return request(app.getHttpServer())
.get('/api/v2')
.expect(200)
.expect('Hello World V2!');
});
it('V3', () => {
return request(app.getHttpServer()).get('/api/v3').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer()).get('/api').expect(404);
});
});
describe('GET /foo/bar (excluded from the API prefix)', () => {
it('V1', () => {
return request(app.getHttpServer())
.get('/v1/foo/bar')
.expect(200)
.expect('Hello FooBar!');
});
it('V2', () => {
return request(app.getHttpServer()).get('/v2/foo/bar').expect(404);
});
it('V3', () => {
return request(app.getHttpServer()).get('/v3/foo/bar').expect(404);
});
it('No Version', () => {
return request(app.getHttpServer()).get('/foo/bar').expect(404);
});
});
afterAll(async () => {
await app.close();
});
});
// ======================================================================== //
describe('with middleware applied', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication(new NodeAdapter());
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
await listenOnLoopback(app);
});
describe('GET /middleware', () => {
it('should return "Hello from middleware function!"', () => {
return request(app.getHttpServer())
.get('/v1/middleware')
.expect(200)
.expect('Hello from middleware function!');
});
});
describe('GET /middleware/override', () => {
it('should return "Hello from middleware function!"', () => {
return request(app.getHttpServer())
.get('/v2/middleware/override')
.expect(200)
.expect('Hello from middleware function!');
});
});
describe('GET /middleware/multiple', () => {
it('should return "Hello from middleware function!" (v1)', () => {
return request(app.getHttpServer())
.get('/v1/middleware/multiple')
.expect(200)
.expect('Hello from middleware function!');
});
it('should return "Hello from middleware function!" (v2)', () => {
return request(app.getHttpServer())
.get('/v2/middleware/multiple')
.expect(200)
.expect('Hello from middleware function!');
});
});
describe('GET /middleware/neutral', () => {
it('should return "Hello from middleware function!"', () => {
return request(app.getHttpServer())
.get('/middleware/neutral')
.expect(200)
.expect('Hello from middleware function!');
});
});
afterAll(async () => {
await app.close();
});
});
});
+2
View File
@@ -27,6 +27,8 @@
"@nestjs/testing/*": ["../../packages/testing/*"],
"@nestjs/platform-express": ["../../packages/platform-express"],
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-node": ["../../packages/platform-node"],
"@nestjs/platform-node/*": ["../../packages/platform-node/*"],
"@nestjs/platform-socket.io": ["../../packages/platform-socket.io"],
"@nestjs/platform-socket.io/*": ["../../packages/platform-socket.io/*"],
"@nestjs/platform-ws": ["../../packages/platform-ws"],
+131
View File
@@ -0,0 +1,131 @@
<p align="center">
<a href="https://nestjs.com/" target="_blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="https://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://github.com/nestjs/nest/blob/master/LICENSE" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
Nest is a framework for building efficient, scalable <a href="https://nodejs.org" target="_blank">Node.js</a> server-side applications. It uses modern JavaScript, is built with <a href="https://www.typescriptlang.org" target="_blank">TypeScript</a> (preserves compatibility with pure JavaScript) and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Reactive Programming).
<p>Under the hood, Nest makes use of <a href="https://expressjs.com/" target="_blank">Express</a>, but also provides compatibility with a wide range of other libraries, like <a href="https://github.com/fastify/fastify" target="_blank">Fastify</a>, allowing for easy use of the myriad of third-party plugins which are available.</p>
## Philosophy
<p>In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front-end and back-end applications, giving rise to awesome projects like <a href="https://angular.dev/" target="_blank">Angular</a>, <a href="https://react.dev/" target="_blank">React</a>, and <a href="https://vuejs.org/" target="_blank">Vue</a>, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.</p>
<p>Nest aims to provide an application architecture out of the box which allows for effortless creation of highly testable, scalable, and loosely coupled and easily maintainable applications. The architecture is heavily inspired by Angular.</p>
## Getting started
- To check out the [guide](https://docs.nestjs.com), visit [docs.nestjs.com](https://docs.nestjs.com). :books:
- 要查看中文 [指南](readme_zh.md), 请访问 [docs.nestjs.cn](https://docs.nestjs.cn). :books:
- [가이드](readme_kr.md) 문서는 [docs.nestjs.com](https://docs.nestjs.com)에서 확인하실 수 있습니다. :books:
- [ガイド](readme_jp.md)は [docs.nestjs.com](https://docs.nestjs.com)でご確認ください。 :books:
## Questions
For questions and support please use the official [Discord channel](https://discord.gg/G7Qnnhy). The issue list of this repo is **exclusively** for bug reports and feature requests.
## Issues
Please make sure to read the [Issue Reporting Checklist](https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md#-submitting-an-issue) before opening an issue. Issues not conforming to the guidelines may be closed immediately.
## Observability
[NestJS Observe](https://observe.nestjs.com) is the official observability platform for Nest applications. Install the `@nestjs/observe` SDK, pass an API key, and requests, background jobs, errors, logs, and distributed traces start streaming to a dashboard - no manual span wiring and no collector to run. Because the SDK hooks into Nest's own request lifecycle, a trace reads like a call graph of your controllers and providers instead of a bare HTTP route. Free for up to 300,000 events a month, and there is a [live demo](https://www.observe-demo.nestjs.com/dashboard) with no signup.
## Consulting
With official support, you can get expert help straight from the Nest core team. We provide dedicated technical support, migration strategies, advice on best practices (and design decisions), PR reviews, and team augmentation. Read more about [support here](https://enterprise.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support from the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
#### Principal Sponsors
<table style="text-align:center;">
<tr>
<td><a href="https://trilon.io" target="_blank"><img src="https://nestjs.com/img/github/trilon-logo.svg" width="200" valign="middle" /></a></td>
<td><a href="https://microsoft.com/" target="_blank"><img src="https://nestjs.com/img/github/microsoft-logo.png" width="180" valign="middle" /></a></td>
<td><a href="https://mojam.co" target="_blank"><img src="https://images.opencollective.com/mojamco/981e9de/logo/256.png?height=256" width="80" valign="middle" /></a></td>
<td><a href="https://valor-software.com/" target="_blank"><img src="https://docs.nestjs.com/assets/sponsors/valor-software.png" width="170" valign="middle" /></a></td>
<td><a href="https://serpapi.com/" target="_blank"><img src="https://nestjs.com/img/github/serpapi-logo.png" width="150" valign="middle" /></a></td>
</tr>
</table>
#### Gold Sponsors
<table style="text-align:center;">
<tr>
<td><a href="https://www.redhat.com" target="_blank"><img src="https://nestjs.com/img/github/red-hat-logo.svg" width="150" valign="middle" /></a></td>
<td><a href="https://github.com/Sanofi-IADC" target="_blank"><img src="https://docs.nestjs.com/assets/sponsors/sanofi.png" width="120" valign="middle" /></a></td>
<td><a href="https://nx.dev" target="_blank"><img src="https://nestjs.com/img/github/nx-logo.png" height="45" valign="middle" /></a></td>
<td><a href="https://intrinsic.ventures/" target="_blank"><img src="https://nestjs.com/img/github/intrinisic-logo.png" width="150" valign="middle" /></a></td>
<td><a href="https://jetbrains.com/" target="_blank"><img src="https://nestjs.com/img/github/jetbrains-logo.svg" width="70" valign="middle" /></a></td>
</tr>
<tr>
<td><a href="https://snyk.co/nestjs" target="_blank"><img src="https://nestjs.com/img/github/snyk-logo-black.png" width="125" valign="middle" /></a></td>
<td align="center"><a href="https://www.movavi.com/imovie-for-windows.html" target="_blank"><img src="https://images.opencollective.com/movavi/1fb15e5/avatar/256.png?height=256" width="60" valign="middle" /></a></td>
</tr>
</table>
#### Silver Sponsors
<table style="text-align:center;">
<tr>
<td><a href="https://www.mercedes-benz.com/" target="_blank"><img src="https://nestjs.com/img/github/mercedes-logo.png" width="100" valign="middle" /></a></td>
<td><a href="https://handsontable.com/docs/react-data-grid/?utm_source=NestJS_GH&utm_medium=sponsorship&utm_campaign=library_sponsorship_2024" target="_blank"><img src="https://nestjs.com/img/github/handsontable-dark-logo.svg#2" width="150" valign="middle" /></a></td>
<td align="center" valign="middle"><a href="https://www.itflashcards.com/" target="_blank"><img src="https://nestjs.com/img/github/it_flashcards-logo.png" width="170" valign="middle" /></a></td>
<td align="center" valign="middle"><a href="https://arcjet.com/?ref=nestjs" target="_blank"><img src="https://nestjs.com/img/github/arcjet-logo.svg" width="170" valign="middle" /></a></td>
<td align="center" valign="middle"><a href="https://pandektes.com" target="_blank"><img src="https://nestjs.com/img/github/pandektes-logo.png" width="65" valign="middle" /></a></td>
</tr><tr>
<td align="center" valign="middle"><a href="https://www.fintechcrafts.com/" target="_blank"><img src="https://opencollective-production.s3.us-west-1.amazonaws.com/account-avatar/935813df-48a9-4b41-b923-7d70b5621c6b/2026-05-06%2010%2021%2033.png" width="65" valign="middle" /></a></td>
<td align="center" valign="middle"><a href="https://talordata.com/?campaignid=Ya5Xpw2gmhyee6Tg&utm_source=nestjs&utm_term=nestjs" target="_blank"><img src="https://images.opencollective.com/elowen/9ccac7f/avatar/512.png" width="65" valign="middle" /></a></td>
<td align="center" valign="middle"><a href="https://memory2.co/" target="_blank"><img src="https://images.opencollective.com/memory-squared/bbe37f5/avatar/256.png?height=50" width="65" valign="middle" /></a></td>
</tr>
</table>
#### Sponsors
<table>
<tr>
<td align="center" valign="middle"><a href="https://mantro.net/" target="_blank"><img src="https://nestjs.com/img/github/mantro-logo.svg" width="95" valign="middle" /></a></td>
<td align="center" valign="middle"><a href="https://triplebyte.com/" target="_blank"><img src="https://nestjs.com/img/github/triplebyte-logo.png" width="107" valign="middle" /></a></td>
<td align="center" valign="middle"><a href="https://nearpod.com/" target="_blank"><img src="https://nestjs.com/img/github/nearpod-logo.svg" width="100" valign="middle" /></a></td>
<td align="center" valign="middle"><a href="https://www.naologic.com/" target="_blank"><img src="https://nestjs.com/img/github/naologic-logo.svg" width="125" valign="middle" /></a></td>
<td align="center" valign="middle"><a href="https://triplecore.io" target="_blank"><img src="https://nestjs.com/img/github/triplecore-logo.svg" width="50" valign="middle" /></a>
<td align="center" valign="middle"><a href="https://www.hingehealth.com/" target="_blank"><img src="https://nestjs.com/img/github/hinge-health-logo.svg" width="100" valign="middle" /></a></td>
</tr>
</table>
## Backers
<a href="https://opencollective.com/nest" target="_blank"><img src="https://opencollective.com/nest/backers.svg?width=1000"></a>
## Stay in touch
- Author - [Kamil Myśliwiec](https://x.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- X - [@nestframework](https://x.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).
@@ -0,0 +1,135 @@
import proxyAddr from '@fastify/proxy-addr';
import type {
NodeAdapterOptions,
NodeTrustProxy,
} from '../interfaces/node-adapter-options.interface.js';
import { NodeRequest } from './node-request.js';
import { getHostname } from './utils/hostname.util.js';
type TrustFunction = (address: string, hop: number) => boolean;
/**
* Compiles the `trustProxy` option the way Fastify compiles its own (and
* Express its "trust proxy" setting, but for a hop count).
*/
function compileTrust(
trustProxy: Exclude<NodeTrustProxy, false>,
): TrustFunction {
if (typeof trustProxy === 'function') {
return trustProxy;
}
if (trustProxy === true) {
return () => true;
}
if (typeof (trustProxy as unknown) === 'number') {
// As with Fastify: a hop count cannot tell the socket's peer from a client
// that sends its own X-Forwarded-For, so it trusts no proxy
return () => false;
}
return proxyAddr.compile(
typeof trustProxy === 'string'
? trustProxy.split(',').map(entry => entry.trim())
: trustProxy,
);
}
// Proxies may join several values with ","; Express reads the first one
function firstValue(header: string): string {
const index = header.indexOf(',');
return (index === -1 ? header : header.slice(0, index)).trim();
}
/**
* The class of the requests of an adapter whose options change how requests
* read the query string or the headers of proxies: a subclass of
* `NodeRequest` overriding those getters. Without such options, it is
* `NodeRequest` itself, so that requests run the same code as without this
* function.
*
* @internal
*/
export function createRequestClass(
options: Pick<NodeAdapterOptions, 'trustProxy' | 'querystringParser'>,
): typeof NodeRequest {
const { trustProxy, querystringParser } = options;
let RequestClass = NodeRequest;
if (querystringParser !== undefined) {
if (typeof querystringParser !== 'function') {
throw new TypeError(
`Invalid querystringParser: ${String(querystringParser)} (expected a function)`,
);
}
const QUERY = Symbol('query');
RequestClass = class extends RequestClass {
declare [QUERY]: Record<string, any> | undefined;
get query(): Record<string, any> {
let query = this[QUERY];
if (query === undefined) {
const url = this.url!;
const index = url.indexOf('?');
// As Fastify calls it: with "" for a URL without a query string
query = this[QUERY] = querystringParser(
index === -1 ? '' : url.slice(index + 1),
);
}
return query;
}
set query(value: Record<string, any>) {
this[QUERY] = value;
}
};
}
if (trustProxy !== undefined && trustProxy !== false) {
const trust = compileTrust(trustProxy);
// Whether the socket's peer is a proxy whose headers count
const isFromTrustedProxy = (req: NodeRequest) =>
trust(req.socket?.remoteAddress as string, 0);
const app = Object.freeze({
get(setting: string): unknown {
if (setting === 'trust proxy') {
return trustProxy;
}
return setting === 'trust proxy fn' ? trust : undefined;
},
});
RequestClass = class extends RequestClass {
get ip(): string | undefined {
return proxyAddr(this, trust);
}
get ips(): string[] {
// From the client to the closest proxy, without the socket's peer
const addresses = proxyAddr.all(this, trust);
addresses.reverse().pop();
return addresses;
}
get protocol(): string {
// Node joins repeated headers it does not know with ", "
const header = this.headers['x-forwarded-proto'] as string | undefined;
return header && isFromTrustedProxy(this)
? firstValue(header)
: super.protocol;
}
get hostname(): string | undefined {
const header = this.headers['x-forwarded-host'] as string | undefined;
return getHostname(
header && isFromTrustedProxy(this)
? firstValue(header)
: this.headers.host,
);
}
get app(): { get(setting: string): unknown } {
return app;
}
};
}
return RequestClass;
}
+2
View File
@@ -0,0 +1,2 @@
export * from './node-adapter.js';
export * from './node-request.js';
@@ -0,0 +1,949 @@
import {
BadRequestException,
NotFoundException,
HttpException,
HttpStatus,
InternalServerErrorException,
Logger,
type NestApplicationOptions,
RequestMethod,
StreamableFile,
VERSION_NEUTRAL,
type VersioningOptions,
VersioningType,
} from '@nestjs/common';
import {
type CorsOptions,
type CorsOptionsDelegate,
type RouteInfo,
type SecurityRequestHook,
type VersionValue,
addLeadingSlash,
isNil,
isObject,
isString,
isUndefined,
stripEndSlash,
} from '@nestjs/common/internal';
import { AbstractHttpAdapter } from '@nestjs/core';
import { LegacyRouteConverter } from '@nestjs/core/internal';
import cors from 'cors';
import encodeUrl from 'encodeurl';
import * as http from 'http';
import * as https from 'https';
import { pathToRegexp } from 'path-to-regexp';
import serveStatic from 'serve-static';
import { Duplex, finished, Writable } from 'stream';
import type {
NestNodeBodyParserOptionsFor,
NestNodeBodyParserType,
} from '../interfaces/nest-node-body-parser.interface.js';
import type { NodeAdapterOptions } from '../interfaces/node-adapter-options.interface.js';
import type { ServeStaticOptions } from '../interfaces/serve-static-options.interface.js';
import * as bodyParsers from '../body-parser/body-parser.js';
import {
createUriTooLongError,
NodeRouter,
PARAM_TOO_LONG_KEY,
} from '../router/node-router.js';
import { getRoutingPath } from '../router/utils.js';
import { createRequestClass } from './configured-request.js';
import { type NodeViewOptions, ViewRenderer } from '../views/view-renderer.js';
import {
endWithoutBody,
NodeRequest,
NodeResponse,
ROUTE_ERROR_HANDLER,
toBytes,
} from './node-request.js';
import { getMediaTypeVersion } from './utils/get-media-type-version.util.js';
type StreamableHandlerResponse = Parameters<StreamableFile['errorHandler']>[1];
type VersionedRoute = <
TRequest extends Record<string, any> = any,
TResponse = any,
>(
req: TRequest,
res: TResponse,
next: () => void,
) => any;
const METHODS_BY_REQUEST_METHOD: Record<RequestMethod, string | null> = {
[RequestMethod.GET]: 'GET',
[RequestMethod.POST]: 'POST',
[RequestMethod.PUT]: 'PUT',
[RequestMethod.DELETE]: 'DELETE',
[RequestMethod.PATCH]: 'PATCH',
[RequestMethod.ALL]: null,
[RequestMethod.OPTIONS]: 'OPTIONS',
[RequestMethod.HEAD]: 'HEAD',
[RequestMethod.SEARCH]: 'SEARCH',
[RequestMethod.PROPFIND]: 'PROPFIND',
[RequestMethod.PROPPATCH]: 'PROPPATCH',
[RequestMethod.MKCOL]: 'MKCOL',
[RequestMethod.COPY]: 'COPY',
[RequestMethod.MOVE]: 'MOVE',
[RequestMethod.LOCK]: 'LOCK',
[RequestMethod.UNLOCK]: 'UNLOCK',
[RequestMethod.QUERY]: 'QUERY',
};
// Covers "application/json" and the "+json" structured syntax suffix (RFC 6839),
// e.g. "application/problem+json", with or without parameters.
function isJsonContentType(contentType: string): boolean {
const mediaType = contentType.split(';')[0].trim().toLowerCase();
return (
mediaType.startsWith('application/json') || mediaType.endsWith('+json')
);
}
function rawBodyVerifier(req: NodeRequest, _res: unknown, buffer: Buffer) {
if (Buffer.isBuffer(buffer)) {
req.rawBody = buffer;
}
return true;
}
function getBodyParserOptions<T extends object>(rawBody: boolean, options?: T) {
return rawBody ? { ...options, verify: rawBodyVerifier } : options || {};
}
function setFinalHeaders(res: NodeResponse) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Security-Policy', "default-src 'none'");
res.setHeader('X-Content-Type-Options', 'nosniff');
}
/**
* HTTP adapter built directly on the Node.js `http`/`https` modules, with no
* third-party framework in between.
*
* Handlers receive the native `IncomingMessage`/`ServerResponse` objects
* (see {@link NodeRequest} and {@link NodeResponse}), routes are matched
* through a radix tree, and route paths use the same syntax as with the
* Express adapter. Connect-style `(req, res, next)` middleware works as long
* as it relies on the Node.js request and response APIs, or on the small
* Express-compatible subset `NodeRequest` and `NodeResponse` provide.
*
* @publicApi
*/
export class NodeAdapter extends AbstractHttpAdapter<
http.Server | https.Server,
NodeRequest,
NodeResponse
> {
declare protected instance: NodeRouter;
private readonly logger = new Logger(NodeAdapter.name);
private readonly openConnections = new Set<Duplex>();
private readonly registeredPrefixes = new Set<string>();
private readonly views = new ViewRenderer();
private isShuttingDown = false;
private onRequestHook?: (
req: NodeRequest,
res: NodeResponse,
done: (err?: Error) => void,
) => Promise<void> | void;
private onResponseHook?: (
req: NodeRequest,
res: NodeResponse,
) => Promise<void> | void;
// The class of the requests of the server (see createRequestClass())
private readonly requestClass: typeof NodeRequest;
/**
* @param routerOrOptions The adapter's options, or the `NodeRouter` to use
* (one is created by default).
*/
constructor(routerOrOptions?: NodeRouter | NodeAdapterOptions) {
super();
const options =
routerOrOptions instanceof NodeRouter ? {} : (routerOrOptions ?? {});
this.requestClass = createRequestClass(options);
const router =
routerOrOptions instanceof NodeRouter
? routerOrOptions
: new NodeRouter((err, req, res) => this.finalHandler(err, req, res), {
maxParamLength: options.maxParamLength,
});
if (this.requestClass !== NodeRequest) {
// For the requests of another server the router handles
router.requestPrototype = this.requestClass.prototype;
}
this.setInstance(router);
}
/**
* Registers a request hook. Call `done()` to continue processing the request,
* or `done(err)` to hand an error to the error handlers; resolving the
* returned promise does not continue it. Only the first completion counts.
* Errors after `done()` are logged without advancing the request again.
*/
public setOnRequestHook(
onRequestHook: (
req: NodeRequest,
res: NodeResponse,
done: (err?: Error) => void,
) => Promise<void> | void,
) {
this.onRequestHook = onRequestHook;
}
/**
* Registers a hook to run after the response has been sent.
* Thrown errors and rejected promises are logged.
*/
public setOnResponseHook(
onResponseHook: (
req: NodeRequest,
res: NodeResponse,
) => Promise<void> | void,
) {
this.onResponseHook = onResponseHook;
}
/**
* Runs the hook as the first middleware (see
* `AbstractHttpAdapter.registerSecurityHook()`), and for the requests the
* router rejects before any middleware runs (a malformed URL, answered
* with a 400), for its headers, as the FastifyAdapter does for the errors
* its router answers itself.
*/
public registerSecurityHook(hook: SecurityRequestHook<NodeRequest>) {
this.instance.onRejectedTarget = (req: NodeRequest, res: NodeResponse) => {
try {
hook(req, res);
} catch {
// The request is rejected either way, and a throw here would escape
// the router
}
};
return super.registerSecurityHook(hook);
}
public reply(response: NodeResponse, body: any, statusCode?: number) {
if (!isNil(statusCode)) {
response.statusCode = statusCode;
}
if (isNil(body)) {
if (endWithoutBody(response)) {
return;
}
return response.end();
}
if (body instanceof StreamableFile) {
// A stream that failed before the reply (e.g. while an interceptor
// awaited) has already emitted its 'error' event
const { errored } = body.getStream();
if (errored) {
return body.errorHandler(
errored,
this.toStreamableHandlerResponse(response),
);
}
this.applyStreamHeaders(response, body);
const stream = body.getStream();
stream.once('error', err => {
body.errorHandler(err, this.toStreamableHandlerResponse(response));
});
// pipe() leaves the source open when the client disconnects early
finished(response, () => {
if (!stream.readableEnded) {
stream.destroy();
}
});
// Node discards the body of a HEAD response without ever applying
// backpressure, so piping would read the whole source. The response
// ends once the source is readable or has ended instead, which still
// leaves an error opening it (e.g. a missing file) to the error handler.
if (response.req?.method === 'HEAD') {
const end = () => response.end();
if (stream.readableEnded) {
end();
} else {
stream.once('readable', end).once('end', end);
}
return response.on('error', (err: Error) => body.errorLogger(err));
}
return stream
.pipe<Writable>(response)
.on('error', (err: Error) => body.errorLogger(err));
}
// JSON.stringify() sends the enumerable properties of an Error, e.g. the
// request config of an HTTP client error
if (
body instanceof Error &&
response.statusCode >= HttpStatus.BAD_REQUEST
) {
body = {
statusCode: response.statusCode,
message: http.STATUS_CODES[response.statusCode] ?? 'Error',
};
}
const responseContentType = response.getHeader('Content-Type');
if (
typeof responseContentType === 'string' &&
!isJsonContentType(responseContentType) &&
(body?.statusCode >= HttpStatus.BAD_REQUEST ||
// A custom error body (e.g. "new HttpException({ ... }, 404)") need
// not have a "statusCode" of its own
(isObject(body) &&
!isNil(statusCode) &&
statusCode >= HttpStatus.BAD_REQUEST))
) {
this.logger.warn(
"Content-Type doesn't match Reply body, you might need a custom ExceptionFilter for non-JSON responses",
);
// With the charset Express' res.json() ends up sending
response.setHeader('Content-Type', 'application/json; charset=utf-8');
}
// Buffers, typed arrays and DataViews go out as binary, as with Fastify
if (ArrayBuffer.isView(body)) {
return this.writeBody(
response,
toBytes(body),
responseContentType,
'application/octet-stream',
);
}
if (isObject(body)) {
return this.writeBody(
response,
JSON.stringify(body) ?? '',
responseContentType,
'application/json; charset=utf-8',
);
}
return this.writeBody(
response,
String(body),
responseContentType,
'text/plain; charset=utf-8',
);
}
public status(response: NodeResponse, statusCode: number) {
response.statusCode = statusCode;
return response;
}
public end(response: NodeResponse, message?: string) {
return response.end(message);
}
public async render(response: NodeResponse, view: string, options: any) {
if (!this.views.isConfigured()) {
throw new InternalServerErrorException(
'No view engine is configured; call app.setViewEngine() to use @Render().',
);
}
// As with Express' res.render(): the data the handler returns wins over
// res.locals, which wins over the engine's defaultContext
const html = await this.views.render(view, {
...response.locals,
...options,
});
return this.writeBody(
response,
html,
response.getHeader('Content-Type'),
'text/html; charset=utf-8',
);
}
public redirect(response: NodeResponse, statusCode: number, url: string) {
// With the short body and "Vary: Accept" Express sends (@Redirect())
response.redirect(statusCode, url);
}
public setErrorHandler(handler: Function, prefix?: string) {
return this.use(handler);
}
public setNotFoundHandler(
handler: Function,
prefix?: string,
excludedRoutes?: RouteInfo[],
) {
// A request no route matched only because of a parameter's length gets
// a 414 rather than a 404, as with Fastify (see NodeAdapterOptions)
const notFound = (req: NodeRequest, res: NodeResponse, next: Function) =>
(req as any)[PARAM_TOO_LONG_KEY]
? next(createUriTooLongError())
: handler(req, res, next);
const normalizedPrefix = this.normalizePrefix(prefix);
if (normalizedPrefix) {
this.registeredPrefixes.add(normalizedPrefix);
this.use(normalizedPrefix, notFound);
// Excluded routes live at the root, out of the prefix mount's reach.
for (const route of excludedRoutes ?? []) {
this.instance.useExact(
undefined,
addLeadingSlash(route.path),
notFound,
);
}
return;
}
return this.use((req: NodeRequest, res: NodeResponse, next: Function) => {
// When multiple apps share this adapter, skip requests that belong to
// another app's prefix (see the ExpressAdapter).
const path = getRoutingPath(req.url!);
for (const registeredPrefix of this.registeredPrefixes) {
if (
path === registeredPrefix ||
path.startsWith(`${registeredPrefix}/`)
) {
return next();
}
}
return notFound(req, res, next);
});
}
public isHeadersSent(response: NodeResponse): boolean {
return response.headersSent;
}
public getHeader(response: NodeResponse, name: string) {
return response.getHeader(name);
}
public setHeader(response: NodeResponse, name: string, value: string) {
return response.setHeader(name, value);
}
public appendHeader(response: NodeResponse, name: string, value: string) {
return response.appendHeader(name, value);
}
public normalizePath(path: string): string {
try {
const convertedPath = LegacyRouteConverter.tryConvert(path);
// Call "pathToRegexp" to trigger a TypeError if the path is invalid
pathToRegexp(convertedPath);
return convertedPath;
} catch (e) {
if (e instanceof TypeError) {
LegacyRouteConverter.printError(path);
}
throw e;
}
}
public listen(port: string | number, callback?: () => void): http.Server;
public listen(
port: string | number,
hostname: string,
callback?: () => void,
): http.Server;
public listen(port: any, ...args: any[]): http.Server {
return this.httpServer.listen(port, ...args);
}
public beforeClose() {
this.isShuttingDown = true;
}
public close() {
this.isShuttingDown = true;
this.closeOpenConnections();
if (!this.httpServer) {
return undefined;
}
return new Promise(resolve => this.httpServer.close(resolve));
}
public useStaticAssets(path: string, options?: ServeStaticOptions) {
if (options?.prefix) {
return this.use(options.prefix, serveStatic(path, options));
}
return this.use(serveStatic(path, options));
}
public setViewEngine(options: NodeViewOptions | string) {
this.views.setEngine(options);
return this;
}
public setBaseViewsDir(path: string | string[]) {
this.views.setBaseViewsDir(path);
return this;
}
public getRequestHostname(request: NodeRequest): string | undefined {
return request.hostname;
}
public getRequestMethod(request: NodeRequest): string {
return request.method!;
}
public getRequestUrl(request: NodeRequest): string {
return request.originalUrl ?? request.url!;
}
public enableCors(options?: CorsOptions | CorsOptionsDelegate<any>) {
return this.use(cors(options as any));
}
public createMiddlewareFactory(
requestMethod: RequestMethod,
): (path: string, callback: Function) => any {
// Routes passed to "forRoutes()" as plain strings come with no method
// (-1). As with the ExpressAdapter (which falls back to "use()"), the
// middleware is then mounted on the path, and runs for every sub-path.
const isMount = !(requestMethod in METHODS_BY_REQUEST_METHOD);
const method = METHODS_BY_REQUEST_METHOD[requestMethod] ?? undefined;
return (path: string, callback: Function) => {
try {
// The core marks an exact-match path with a trailing "$" (e.g. "/api$")
const isExactPath = path.endsWith('$');
const convertedPath = LegacyRouteConverter.tryConvert(
isExactPath ? path.slice(0, -1) : path,
);
// "/api" also matches "/api/", which the wildcard path registered next
// to it may match too ("/api/{*path}" does, "/api/*path" does not).
// The core runs the middleware for the first of its paths that
// matches a request only.
return isMount && !isExactPath
? this.instance.use(convertedPath, callback)
: this.instance.useExact(method, convertedPath, callback);
} catch (e) {
if (e instanceof TypeError) {
LegacyRouteConverter.printError(path);
}
throw e;
}
};
}
public initHttpServer(options: NestApplicationOptions) {
const serverOptions = {
IncomingMessage: this.requestClass,
ServerResponse: NodeResponse,
} as http.ServerOptions<typeof NodeRequest, typeof NodeResponse>;
const listener = (req: NodeRequest, res: NodeResponse) =>
this.handleRequest(req, res);
this.httpServer = options?.httpsOptions
? https.createServer(
{ ...options.httpsOptions, ...serverOptions } as https.ServerOptions,
listener as http.RequestListener,
)
: http.createServer(serverOptions, listener);
// Where res.sendFile() hands its errors (see ROUTE_ERROR_HANDLER); a
// directory answers like an unmatched route, as with Express
(this.httpServer as any)[ROUTE_ERROR_HANDLER] = (
err: any,
req: NodeRequest,
res: NodeResponse,
) =>
this.instance.handleError(
err?.code === 'EISDIR'
? new NotFoundException(
`Cannot ${req.method} ${this.getRequestUrl(req)}`,
)
: err,
req,
res,
);
// The first layer of the router: NestApplication calls initHttpServer()
// before any middleware is registered, so requests are turned away while
// the shutdown hooks run (beforeClose()) before Nest middleware runs
if (options?.return503OnClosing) {
this.use((req: NodeRequest, res: NodeResponse, next: Function) => {
if (this.isShuttingDown) {
res.setHeader('Connection', 'close');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.statusCode = 503;
res.end('Service Unavailable');
} else {
next();
}
});
}
if (options?.forceCloseConnections) {
this.trackOpenConnections();
}
}
public registerParserMiddleware(prefix?: string, rawBody?: boolean) {
if (!this.instance.hasMiddleware('jsonParser')) {
this.use(bodyParsers.json(getBodyParserOptions(!!rawBody)));
}
if (!this.instance.hasMiddleware('urlencodedParser')) {
this.use(
bodyParsers.urlencoded(
getBodyParserOptions(!!rawBody, { extended: true }),
),
);
}
}
public useBodyParser<ParserType extends NestNodeBodyParserType>(
type: ParserType,
rawBody: boolean,
options?: NestNodeBodyParserOptionsFor<ParserType>,
): this {
const parser = (bodyParsers[type] as Function)(
getBodyParserOptions(rawBody, options),
);
this.use(parser);
return this;
}
public getType(): string {
return 'node';
}
public isRouteOrderSensitive(): boolean {
// Routes are matched by specificity (static > parametric > wildcard), as
// with Fastify, regardless of the registration order.
return false;
}
// The return type is spelled out (rather than "RouteMatchingOptions") so
// that the declarations still compile against an older "@nestjs/common"
public getRouteMatchingOptions(): {
caseSensitive: boolean;
ignoreTrailingSlash: boolean;
} {
// Like the router (see RouteTable): "/Users" is not "/users", and
// "/users/" is served by the "/users" route
return { caseSensitive: true, ignoreTrailingSlash: true };
}
public applyVersionFilter(
handler: Function,
version: VersionValue,
versioningOptions: VersioningOptions,
): VersionedRoute {
const callNextHandler: VersionedRoute = (req, res, next) => {
if (!next) {
throw new InternalServerErrorException(
'HTTP adapter does not support filtering on version',
);
}
return next();
};
if (
version === VERSION_NEUTRAL ||
// URL Versioning is done via the path, so the filter continues forward
versioningOptions.type === VersioningType.URI
) {
return (req, res, next) => handler(req, res, next);
}
if (versioningOptions.type === VersioningType.CUSTOM) {
return (req, res, next) => {
const extractedVersion = versioningOptions.extractor(req);
if (Array.isArray(version)) {
if (
Array.isArray(extractedVersion) &&
version.filter(v => extractedVersion.includes(v as string)).length
) {
return handler(req, res, next);
}
if (
isString(extractedVersion) &&
version.includes(extractedVersion)
) {
return handler(req, res, next);
}
} else if (isString(version)) {
if (
Array.isArray(extractedVersion) &&
extractedVersion.includes(version)
) {
return handler(req, res, next);
}
if (isString(extractedVersion) && version === extractedVersion) {
return handler(req, res, next);
}
}
return callNextHandler(req, res, next);
};
}
if (versioningOptions.type === VersioningType.MEDIA_TYPE) {
return (req, res, next) => {
const acceptHeaderValue: string | string[] | undefined =
req.headers?.['accept'];
const headerVersion = getMediaTypeVersion(
acceptHeaderValue,
versioningOptions.key,
);
if (isUndefined(headerVersion)) {
if (Array.isArray(version) && version.includes(VERSION_NEUTRAL)) {
return handler(req, res, next);
}
} else if (Array.isArray(version)) {
if (version.includes(headerVersion)) {
return handler(req, res, next);
}
} else if (isString(version) && version === headerVersion) {
return handler(req, res, next);
}
return callNextHandler(req, res, next);
};
}
if (versioningOptions.type === VersioningType.HEADER) {
return (req, res, next) => {
const customHeaderVersionParameter: string | undefined =
req.headers?.[versioningOptions.header.toLowerCase()];
if (isUndefined(customHeaderVersionParameter)) {
if (Array.isArray(version) && version.includes(VERSION_NEUTRAL)) {
return handler(req, res, next);
}
} else if (Array.isArray(version)) {
if (version.includes(customHeaderVersionParameter)) {
return handler(req, res, next);
}
} else if (
isString(version) &&
version === customHeaderVersionParameter
) {
return handler(req, res, next);
}
return callNextHandler(req, res, next);
};
}
throw new Error('Unsupported versioning options');
}
public mapException(error: unknown): unknown {
if (error instanceof HttpException) {
return error;
}
// SyntaxError is thrown by the JSON body parser when given invalid JSON,
// URIError
// when a path parameter has an invalid percent-encoding (e.g. "%FF")
if (error instanceof SyntaxError || error instanceof URIError) {
return new BadRequestException(error.message);
}
// As with Express and Fastify: http-errors-style client errors (those of
// the body parsers, such as a body that is too large, and of many Express
// middleware) reach exception filters as an HttpException with their
// status, and the original error as its cause. A 5xx one is a server
// fault: it stays unmapped, so the built-in exception filter logs it.
if (
isHttpError(error) &&
error.statusCode < HttpStatus.INTERNAL_SERVER_ERROR
) {
return new HttpException(
// An error that must not reach the client only gets a status text,
// like the built-in exception filter answers it
error.expose === false
? (http.STATUS_CODES[error.statusCode] ?? 'Internal server error')
: error.message,
error.statusCode,
{ cause: error },
);
}
return error;
}
private handleRequest(req: NodeRequest, res: NodeResponse) {
if (this.onResponseHook) {
res.on('finish', () => {
// The response is gone: an error can only be logged, and must not
// become an unhandled rejection (as with the ExpressAdapter)
try {
const result = this.onResponseHook!.call(this, req, res);
if (result && typeof result.then === 'function') {
result.then(undefined, err => this.logger.error(err));
}
} catch (err) {
this.logger.error(err);
}
});
}
if (this.onRequestHook) {
return this.runRequestHook(req, res);
}
this.instance.handle(req, res);
}
// As with the ExpressAdapter: only the first completion routes the request
// (or hands its error to the error handlers), later errors are logged
private runRequestHook(req: NodeRequest, res: NodeResponse) {
let completed = false;
const doneOnce = (err?: unknown) => {
if (completed) {
if (err) {
this.logger.error(err);
}
return;
}
completed = true;
if (err) {
return this.instance.handleError(err, req, res);
}
this.instance.handle(req, res);
};
try {
const result = this.onRequestHook!.call(this, req, res, doneOnce);
if (result && typeof result.then === 'function') {
result.then(undefined, err =>
doneOnce(err || new Error('Request hook failed')),
);
}
} catch (err) {
doneOnce(err || new Error('Request hook failed'));
}
}
private finalHandler(err: any, req: NodeRequest, res: NodeResponse) {
if (res.headersSent) {
req.socket.destroy();
return;
}
if (err !== undefined) {
const statusCode = err?.statusCode ?? err?.status;
res.statusCode =
statusCode >= 400 && statusCode < 600
? statusCode
: HttpStatus.INTERNAL_SERVER_ERROR;
if (res.statusCode >= 500) {
this.logger.error(err?.stack ?? err);
}
setFinalHeaders(res);
res.end(http.STATUS_CODES[res.statusCode]);
return;
}
// Requests outside the global prefix. As with Express' final handler, the
// path is echoed back as received (percent-encoded, never decoded) and
// the browser told not to sniff or run anything in the response
const url = req.originalUrl ?? req.url!;
const queryIndex = url.indexOf('?');
const path = queryIndex === -1 ? url : url.slice(0, queryIndex);
res.statusCode = HttpStatus.NOT_FOUND;
setFinalHeaders(res);
res.end(`Cannot ${req.method} ${encodeUrl(path)}`);
}
private normalizePrefix(prefix?: string): string {
return stripEndSlash(addLeadingSlash(prefix));
}
private trackOpenConnections() {
this.httpServer.on('connection', (socket: Duplex) => {
this.openConnections.add(socket);
socket.on('close', () => this.openConnections.delete(socket));
});
}
private closeOpenConnections() {
for (const socket of this.openConnections) {
socket.destroy();
this.openConnections.delete(socket);
}
}
// Writes the headers in a single "writeHead()" call, which is cheaper than
// one "setHeader()" per header, with an explicit "Content-Length" (also
// sent in answer to HEAD requests, which carry no body).
private writeBody(
response: NodeResponse,
payload: string | Uint8Array,
contentType: unknown,
defaultContentType: string,
) {
if (endWithoutBody(response)) {
return;
}
const contentLength =
typeof payload === 'string'
? Buffer.byteLength(payload)
: payload.byteLength;
response.writeHead(
response.statusCode,
contentType === undefined
? {
'Content-Type': defaultContentType,
'Content-Length': contentLength,
}
: { 'Content-Length': contentLength },
);
return response.end(payload);
}
// `StreamableFile` error handlers reply through a Fastify/Express-like `send()`
private toStreamableHandlerResponse(
response: NodeResponse,
): StreamableHandlerResponse {
return {
get destroyed() {
return response.destroyed;
},
get headersSent() {
return response.headersSent;
},
get statusCode() {
return response.statusCode;
},
set statusCode(statusCode: number) {
response.statusCode = statusCode;
},
send: (body: string) => {
// Replaces the headers describing the file (its length, notably)
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
response.setHeader('Content-Length', Buffer.byteLength(body));
response.end(body);
},
end: () => response.end(),
// Ending would make a file that failed midway look complete
destroy: (error?: Error) => response.destroy(error),
};
}
private setHeaderIfNotExists(
response: NodeResponse,
name: string,
value?: string | string[] | number,
) {
if (value !== undefined && response.getHeader(name) === undefined) {
const headerValue = Array.isArray(value) ? value.join(',') : value;
response.setHeader(name, headerValue);
}
}
private applyStreamHeaders(
response: NodeResponse,
streamable: StreamableFile,
) {
const headers = streamable.getHeaders();
this.setHeaderIfNotExists(response, 'Content-Type', headers.type);
this.setHeaderIfNotExists(
response,
'Content-Disposition',
headers.disposition,
);
this.setHeaderIfNotExists(response, 'Content-Length', headers.length);
}
}
/**
* Returns true when `err` is an "http-errors"-shaped error (the body parsers,
* csrf, etc.), as the ExpressAdapter checks it.
*/
function isHttpError(
err: unknown,
): err is { statusCode: number; message: string; expose: boolean } {
return (
isObject(err) &&
err instanceof Error &&
typeof (err as any).expose === 'boolean' &&
typeof (err as any).statusCode === 'number' &&
(err as any).status === (err as any).statusCode
);
}
@@ -0,0 +1,736 @@
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';
import { getHostname } from './utils/hostname.util.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 behavior.
*/
const NODE_APP = Object.freeze({
/**
* `'trust proxy'` is `false` (`req.ip` is the socket address) unless the
* adapter's `trustProxy` option is set; 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<string, string | number | string[]>;
/** 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<string, any>;
/**
* 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<string, any> | undefined;
/**
* Query string parameters, parsed on first access.
*/
get query(): Record<string, any> {
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<string, any>) {
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 (from
* `X-Forwarded-Host` for a trusted proxy, see the `trustProxy` option).
*/
get hostname(): string | undefined {
return getHostname(this.headers.host);
}
/**
* Remote address of the underlying socket (the client's address from
* `X-Forwarded-For` for a trusted proxy, see the `trustProxy` option).
*/
get ip(): string | undefined {
return this.socket?.remoteAddress;
}
/**
* Addresses from `X-Forwarded-For`, from the client to the closest trusted
* proxy, as Express lists them; empty unless the `trustProxy` option is set.
*/
get ips(): string[] {
return [];
}
/**
* `'https'` over TLS, `'http'` otherwise (from `X-Forwarded-Proto` for a
* trusted proxy, see the `trustProxy` option).
*/
get protocol(): string {
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` (or the `trustProxy`
* option), 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<Request> {
declare [LOCALS]: Record<string, any> | undefined;
/**
* Values scoped to this response, like Express' `res.locals`, for
* middleware to pass data along. Created on first access.
*/
get locals(): Record<string, any> {
return (this[LOCALS] ??= Object.create(null));
}
set locals(value: Record<string, any>) {
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<string, any>, 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<string, any>, 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<string, any> = { ...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');
// As Express 5.3 writes it
body =
`<!DOCTYPE html><head><title>${message}</title></head>` +
`<body><p>${message}. Redirecting to ${escapeHtml(location)}</p></body>`;
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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[
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;
}
@@ -0,0 +1,63 @@
/**
* Splits a header value at separators outside quoted strings in linear time.
* Backslash-escaped characters inside quoted strings are treated as literals.
*/
const splitOutsideQuotes = (value: string, separator: ',' | ';'): string[] => {
const parts: string[] = [];
let start = 0;
let isQuoted = false;
const len = value.length;
for (let index = 0; index < len; index++) {
const character = value[index];
if (isQuoted && character === '\\') {
// A quoted-pair consumes the next character, including an escaped quote.
index++;
} else if (character === '"') {
isQuoted = !isQuoted;
} else if (character === separator && !isQuoted) {
parts.push(value.slice(start, index));
start = index + 1;
}
}
parts.push(value.slice(start));
return parts;
};
/**
* Returns the version carried by an `Accept` header for media type versioning,
* e.g. `2` for `application/json;v=2` with the `v=` key.
*
* The version parameter may follow other parameters and may belong to any
* media range of a list (`text/html, application/json;v=2`); the first match
* wins. Returns `undefined` when no parameter starts with the key.
*
* Quoted parameter values are unescaped, and separators inside them are ignored.
* An unterminated quoted string runs to the end of the header value, so a
* version that follows its opening quote is not read.
*
* Duplicated in each platform package so that a platform package never
* depends on a newer `@nestjs/core` than the one installed.
*/
export function getMediaTypeVersion(
acceptHeader: string | string[] | undefined,
key: string,
): string | undefined {
const mediaRanges = ([] as string[])
.concat(acceptHeader ?? [])
.flatMap(value => splitOutsideQuotes(value, ','));
for (const mediaRange of mediaRanges) {
const [, ...parameters] = splitOutsideQuotes(mediaRange, ';');
for (const parameter of parameters) {
const trimmedParameter = parameter.trim();
if (trimmedParameter.startsWith(key)) {
const version = trimmedParameter.slice(key.length);
if (version.startsWith('"') && version.endsWith('"')) {
return version.slice(1, -1).replace(/\\([\s\S])/g, '$1');
}
return version;
}
}
}
return undefined;
}
@@ -0,0 +1,13 @@
/**
* Host name of a `Host` (or `X-Forwarded-Host`) header value, without the
* port. An IPv6 literal keeps its brackets ("[::1]:3000" gives "[::1]").
*/
export function getHostname(host: string | undefined): string | undefined {
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);
}
@@ -0,0 +1,809 @@
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 (`&#9786;`), 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<string, number> = {
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 {
// An empty header is an empty list of codings, as body-parser reads it
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();
}
// An empty charset parameter gets the default too, as with body-parser
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 rejectProtoKeys = poisoningActions.protoAction === 'error';
const decoder = (
value: string,
defaultDecoder: (value: string, ...args: any[]) => string,
charset: string,
type: 'key' | 'value',
) => {
// The decoder of "qs" replaces "+" and calls decodeURIComponent() for
// every key and value, which leaves one without "%" or "+" unchanged
const decoded =
value.indexOf('%') === -1 && value.indexOf('+') === -1
? value
: defaultDecoder(value, defaultDecoder, charset);
if (
rejectProtoKeys &&
type === 'key' &&
(extended ? PROTO_KEY.test(decoded) : decoded === '__proto__')
) {
throw new SyntaxError('Object contains forbidden prototype property');
}
return decoded;
};
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',
);
}
+12
View File
@@ -0,0 +1,12 @@
/*
* Nest @platform-node
* Copyright(c) 2017 - 2025 Kamil Mysliwiec
* https://nestjs.com
* MIT Licensed
*/
export * from './adapters/index.js';
export * from './interfaces/index.js';
export * from './multer/index.js';
export { NodeRouter } from './router/node-router.js';
export type { NodeViewOptions } from './views/view-renderer.js';
@@ -0,0 +1,4 @@
export * from './nest-node-application.interface.js';
export * from './nest-node-body-parser.interface.js';
export * from './node-adapter-options.interface.js';
export * from './serve-static-options.interface.js';
@@ -0,0 +1,53 @@
import type { HttpServer, INestApplication } from '@nestjs/common';
import type { CorsOptions, CorsOptionsDelegate } from '@nestjs/common/internal';
import type { Server as CoreHttpServer } from 'http';
import type { Server as CoreHttpsServer } from 'https';
import type { NodeRequest, NodeResponse } from '../adapters/node-request.js';
import type { NodeRouter } from '../router/node-router.js';
import type {
NestNodeBodyParserOptionsFor,
NestNodeBodyParserType,
} from './nest-node-body-parser.interface.js';
import type { ServeStaticOptions } from './serve-static-options.interface.js';
import type { NodeViewOptions } from '../views/view-renderer.js';
/**
* Interface describing methods on NestNodeApplication.
*
* @see [Platform](https://docs.nestjs.com/first-steps#platform)
*
* @publicApi
*/
export interface NestNodeApplication<
TServer extends CoreHttpServer | CoreHttpsServer = CoreHttpServer,
> extends INestApplication<TServer> {
getHttpAdapter(): HttpServer<NodeRequest, NodeResponse, NodeRouter>;
listen(port: number | string, callback?: () => void): Promise<TServer>;
listen(
port: number | string,
hostname: string,
callback?: () => void,
): Promise<TServer>;
useStaticAssets(path: string, options?: ServeStaticOptions): this;
enableCors(options?: CorsOptions | CorsOptionsDelegate<any>): void;
/**
* Configures the template engine used by `@Render()`, either with
* `@fastify/view`-style options (`{ engine: { ejs: require('ejs') },
* templates: 'views' }`) or with an engine name, as with Express.
*/
setViewEngine(options: NodeViewOptions | string): this;
/**
* Sets the directory (or directories) views are resolved against.
*/
setBaseViewsDir(path: string | string[]): this;
useBodyParser<ParserType extends NestNodeBodyParserType>(
parser: ParserType,
options?: NestNodeBodyParserOptionsFor<ParserType>,
): this;
}
@@ -0,0 +1,37 @@
import type {
NodeBodyParserOptions,
NodeJsonParserOptions,
NodeTextParserOptions,
NodeUrlencodedParserOptions,
} from '../body-parser/body-parser.js';
/**
* Options of each body parser that `useBodyParser()` accepts.
*
* @publicApi
*/
export interface NestNodeBodyParserOptionsMap {
json: NodeJsonParserOptions;
urlencoded: NodeUrlencodedParserOptions;
text: NodeTextParserOptions;
raw: NodeBodyParserOptions;
}
/**
* @publicApi
*/
export type NestNodeBodyParserType = keyof NestNodeBodyParserOptionsMap;
/**
* @publicApi
*/
export type NestNodeBodyParserOptionsFor<
ParserType extends NestNodeBodyParserType,
> = Omit<NestNodeBodyParserOptionsMap[ParserType], 'verify'>;
export type {
NodeBodyParserOptions,
NodeJsonParserOptions,
NodeTextParserOptions,
NodeUrlencodedParserOptions,
};
@@ -0,0 +1,45 @@
/**
* Proxies whose `X-Forwarded-*` headers the `NodeAdapter` trusts (see
* {@link NodeAdapterOptions.trustProxy}): every proxy (`true`), addresses or
* subnets (`'loopback'`, `'10.0.0.0/8'`, or several, comma-separated or in an
* array), or a function deciding for each address and its hop count from the
* socket (`0`). As with Fastify, there is no hop count: it cannot tell a
* proxy from a client that sends its own `X-Forwarded-For`.
*
* @publicApi
*/
export type NodeTrustProxy =
boolean | string | string[] | ((address: string, hop: number) => boolean);
/**
* Options of the `NodeAdapter` (`new NodeAdapter(options)`).
*
* @publicApi
*/
export interface NodeAdapterOptions {
/**
* Longest (decoded) route parameter a route matches; `100` by default, as
* with Fastify's `maxParamLength`. A request whose parameter is longer falls
* through to another matching route (a sibling wildcard, for instance), if
* any, or gets a 414 (URI Too Long). Wildcards are not limited.
*/
maxParamLength?: number;
/**
* Proxies to trust, as with Fastify's `trustProxy` option (and Express'
* "trust proxy" setting, but for a hop count, which Fastify does not trust
* either); `false` by default. For a request that comes from a
* trusted proxy, `req.ip` and `req.ips` are read from `X-Forwarded-For`,
* `req.protocol` (and `req.secure`) from `X-Forwarded-Proto`, and
* `req.hostname` from `X-Forwarded-Host`, the way Express reads them.
* `req.app.get('trust proxy')` returns the setting.
*/
trustProxy?: NodeTrustProxy;
/**
* Parses the query string (without the leading "?", and `''` when the URL
* has none) into `req.query`, as with Fastify's option of the same name.
* By default, `fast-querystring` parses it into flat keys
* (`?a[b]=1` gives `{ 'a[b]': '1' }`); `(str) => qs.parse(str)` gives
* nested objects and arrays.
*/
querystringParser?: (str: string) => Record<string, any>;
}
@@ -0,0 +1,23 @@
/**
* Options of `useStaticAssets()`, passed through to `serve-static`.
*
* @see https://www.npmjs.com/package/serve-static
*
* @publicApi
*/
export interface ServeStaticOptions {
dotfiles?: 'allow' | 'deny' | 'ignore';
etag?: boolean;
extensions?: string[] | false;
fallthrough?: boolean;
immutable?: boolean;
index?: boolean | string | string[];
lastModified?: boolean;
maxAge?: number | string;
redirect?: boolean;
setHeaders?: (res: any, path: string, stat: any) => any;
/**
* URL path the files are served under.
*/
prefix?: string;
}
@@ -0,0 +1 @@
export const MULTER_MODULE_OPTIONS = 'MULTER_MODULE_OPTIONS';
+3
View File
@@ -0,0 +1,3 @@
export * from './interceptors/index.js';
export * from './interfaces/index.js';
export * from './multer.module.js';
@@ -0,0 +1,73 @@
import {
type CallHandler,
type ExecutionContext,
Inject,
mixin,
type NestInterceptor,
Optional,
type Type,
} from '@nestjs/common';
import multer from 'multer';
import { Observable } from 'rxjs';
import { MULTER_MODULE_OPTIONS } from '../files.constants.js';
import { MulterModuleOptions } from '../interfaces/index.js';
import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
removeFilesOnPipeRejection,
transformException,
} from '../multer/multer.utils.js';
type MulterInstance = any;
/**
* @param localOptions
*
* @publicApi
*/
export function AnyFilesInterceptor(
localOptions?: MulterOptions,
): Type<NestInterceptor> {
class MixinInterceptor implements NestInterceptor {
protected multer: MulterInstance;
constructor(
@Optional()
@Inject(MULTER_MODULE_OPTIONS)
options: MulterModuleOptions = {},
) {
this.multer = (multer as any)(
applyDefaultLimits(mergeMulterOptions(options, localOptions)),
);
}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const ctx = context.switchToHttp();
await new Promise<void>((resolve, reject) =>
this.multer.any()(ctx.getRequest(), ctx.getResponse(), (err: any) => {
if (err) {
const error = transformException(err);
return reject(error);
}
resolve();
}),
);
return next
.handle()
.pipe(
removeFilesOnPipeRejection(
this.multer.storage,
ctx.getRequest(),
ctx.getResponse(),
),
);
}
}
const Interceptor = mixin(MixinInterceptor);
return Interceptor;
}
@@ -0,0 +1,81 @@
import {
type CallHandler,
type ExecutionContext,
Inject,
mixin,
type NestInterceptor,
Optional,
type Type,
} from '@nestjs/common';
import multer from 'multer';
import { Observable } from 'rxjs';
import { MULTER_MODULE_OPTIONS } from '../files.constants.js';
import { MulterModuleOptions } from '../interfaces/index.js';
import {
MulterField,
MulterOptions,
} from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
removeFilesOnPipeRejection,
transformException,
} from '../multer/multer.utils.js';
type MulterInstance = any;
/**
* @param uploadFields
* @param localOptions
* @publicApi
*/
export function FileFieldsInterceptor(
uploadFields: MulterField[],
localOptions?: MulterOptions,
): Type<NestInterceptor> {
class MixinInterceptor implements NestInterceptor {
protected multer: MulterInstance;
constructor(
@Optional()
@Inject(MULTER_MODULE_OPTIONS)
options: MulterModuleOptions = {},
) {
this.multer = (multer as any)(
applyDefaultLimits(mergeMulterOptions(options, localOptions)),
);
}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const ctx = context.switchToHttp();
await new Promise<void>((resolve, reject) =>
this.multer.fields(uploadFields)(
ctx.getRequest(),
ctx.getResponse(),
(err: any) => {
if (err) {
const error = transformException(err);
return reject(error);
}
resolve();
},
),
);
return next
.handle()
.pipe(
removeFilesOnPipeRejection(
this.multer.storage,
ctx.getRequest(),
ctx.getResponse(),
),
);
}
}
const Interceptor = mixin(MixinInterceptor);
return Interceptor;
}
@@ -0,0 +1,79 @@
import {
type CallHandler,
type ExecutionContext,
Inject,
mixin,
type NestInterceptor,
Optional,
type Type,
} from '@nestjs/common';
import multer from 'multer';
import { Observable } from 'rxjs';
import { MULTER_MODULE_OPTIONS } from '../files.constants.js';
import { MulterModuleOptions } from '../interfaces/index.js';
import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
removeFilesOnPipeRejection,
transformException,
} from '../multer/multer.utils.js';
type MulterInstance = any;
/**
* @param fieldName
* @param localOptions
*
* @publicApi
*/
export function FileInterceptor(
fieldName: string,
localOptions?: MulterOptions,
): Type<NestInterceptor> {
class MixinInterceptor implements NestInterceptor {
protected multer: MulterInstance;
constructor(
@Optional()
@Inject(MULTER_MODULE_OPTIONS)
options: MulterModuleOptions = {},
) {
this.multer = (multer as any)(
applyDefaultLimits(mergeMulterOptions(options, localOptions)),
);
}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const ctx = context.switchToHttp();
await new Promise<void>((resolve, reject) =>
this.multer.single(fieldName)(
ctx.getRequest(),
ctx.getResponse(),
(err: any) => {
if (err) {
const error = transformException(err);
return reject(error);
}
resolve();
},
),
);
return next
.handle()
.pipe(
removeFilesOnPipeRejection(
this.multer.storage,
ctx.getRequest(),
ctx.getResponse(),
),
);
}
}
const Interceptor = mixin(MixinInterceptor);
return Interceptor;
}
@@ -0,0 +1,82 @@
import {
type CallHandler,
type ExecutionContext,
Inject,
mixin,
type NestInterceptor,
Optional,
type Type,
} from '@nestjs/common';
import multer from 'multer';
import { Observable } from 'rxjs';
import { MULTER_MODULE_OPTIONS } from '../files.constants.js';
import { MulterModuleOptions } from '../interfaces/index.js';
import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
removeFilesOnPipeRejection,
transformException,
} from '../multer/multer.utils.js';
type MulterInstance = any;
/**
*
* @param fieldName
* @param maxCount
* @param localOptions
*
* @publicApi
*/
export function FilesInterceptor(
fieldName: string,
maxCount?: number,
localOptions?: MulterOptions,
): Type<NestInterceptor> {
class MixinInterceptor implements NestInterceptor {
protected multer: MulterInstance;
constructor(
@Optional()
@Inject(MULTER_MODULE_OPTIONS)
options: MulterModuleOptions = {},
) {
this.multer = (multer as any)(
applyDefaultLimits(mergeMulterOptions(options, localOptions)),
);
}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const ctx = context.switchToHttp();
await new Promise<void>((resolve, reject) =>
this.multer.array(fieldName, maxCount)(
ctx.getRequest(),
ctx.getResponse(),
(err: any) => {
if (err) {
const error = transformException(err);
return reject(error);
}
resolve();
},
),
);
return next
.handle()
.pipe(
removeFilesOnPipeRejection(
this.multer.storage,
ctx.getRequest(),
ctx.getResponse(),
),
);
}
}
const Interceptor = mixin(MixinInterceptor);
return Interceptor;
}
@@ -0,0 +1,5 @@
export * from './any-files.interceptor.js';
export * from './file-fields.interceptor.js';
export * from './file.interceptor.js';
export * from './files.interceptor.js';
export * from './no-files.interceptor.js';
@@ -0,0 +1,64 @@
import {
type CallHandler,
type ExecutionContext,
Inject,
mixin,
type NestInterceptor,
Optional,
type Type,
} from '@nestjs/common';
import multer from 'multer';
import { Observable } from 'rxjs';
import { MULTER_MODULE_OPTIONS } from '../files.constants.js';
import { MulterModuleOptions } from '../interfaces/index.js';
import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
transformException,
} from '../multer/multer.utils.js';
type MulterInstance = any;
/**
*
* @param localOptions
* @publicApi
*/
export function NoFilesInterceptor(
localOptions?: MulterOptions,
): Type<NestInterceptor> {
class MixinInterceptor implements NestInterceptor {
protected multer: MulterInstance;
constructor(
@Optional()
@Inject(MULTER_MODULE_OPTIONS)
options: MulterModuleOptions = {},
) {
this.multer = (multer as any)(
applyDefaultLimits(mergeMulterOptions(options, localOptions)),
);
}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const ctx = context.switchToHttp();
await new Promise<void>((resolve, reject) =>
this.multer.none()(ctx.getRequest(), ctx.getResponse(), (err: any) => {
if (err) {
const error = transformException(err);
return reject(error);
}
resolve();
}),
);
return next.handle();
}
}
const Interceptor = mixin(MixinInterceptor);
return Interceptor;
}
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more