Running in Production
The entry point
Section titled “The entry point”import { createHttpApplication } from "@blixis-io/http";import { AppModule } from "./app.module.js";
const port = Number(process.env["PORT"] ?? 3000);
const app = await createHttpApplication(AppModule);await app.listen(port);
console.log(`Listening on http://localhost:${port}`);listen(port, hostname?) binds a real node:http server and resolves once it’s actually accepting connections, with { port } reflecting the bound port (useful when you pass 0 to let the OS pick one, e.g. in tests). hostname defaults to "0.0.0.0" — all interfaces; pass "127.0.0.1" to bind localhost only.
Under the hood, every incoming IncomingMessage is converted to a Web-standard Request (including a real AbortSignal wired to the client connection — see below) and every returned Response is streamed back onto the socket. None of this is something you write; it’s what listen() sets up.
Graceful shutdown
Section titled “Graceful shutdown”process.on("SIGTERM", () => { void app.close("SIGTERM").then(() => process.exit(0));});app.close(signal?) closes the listening socket and runs every OnApplicationShutdown hook in the application (see Lifecycle Hooks) — the string you pass through is handed to each hook as-is, so a database connection’s shutdown hook can log or branch on which signal triggered it. close() is idempotent: calling it more than once (a signal handler and a test’s cleanup both running it, say) is safe. Every call returns the same promise, so a second caller waits for the same drain-then-teardown instead of closing the application under requests that are still running, and the hooks run once. The signal of the first call is the one the hooks see. If a hook fails, every caller’s promise rejects with that same error. A hook that never settles keeps close() pending: shutdownTimeout only bounds the wait for in-flight requests, not the hooks, so give hooks that talk to the network their own timeout.
listen() can only be called once per application: a second call while it is listening, or after close(), rejects with an error instead of leaving the first server running without a handle.
Telling the load balancer first
Section titled “Telling the load balancer first”close() stops accepting connections at once. A load balancer that has not noticed yet will still send traffic to an instance that now refuses it. So on a shutdown signal, first mark the application as draining, wait for the balancer’s health check to notice, and only then close:
process.on("SIGTERM", () => { app.startDraining(); // app.draining is true; a readiness check can now answer "not ready" setTimeout(() => { void app.close("SIGTERM").then(() => process.exit(0)); }, 10_000); // a little longer than the balancer's check interval});app.draining is also true from the moment close() is called, for the requests still being served. startDraining() changes nothing else: the application keeps serving. Read it from a readiness endpoint, so it answers “not ready” during the drain: @blixis-io/health does.
Malformed requests
Section titled “Malformed requests”On listen(), Node’s HTTP parser rejects broken framing before a controller ever runs, and the server keeps serving other connections:
- request headers over Node’s
maxHeaderSize(16 KiB by default) →431 Request Header Fields Too Large - a non-numeric, negative or duplicated
Content-Length, orContent-Lengthtogether withTransfer-Encoding→400 Bad Request - bytes past the declared
Content-Lengthare parsed as the next request on the connection; if they aren’t valid HTTP that is a400and the connection is closed
A body that never reaches its declared Content-Length keeps the request waiting until the client hangs up (answered with 400, see Body parsing rules) or until requestTimeout (504). A chunked body without a Content-Length is cut off with 413 as soon as it crosses bodyLimit, even while the client is still sending. A client that disconnects mid-body is not logged as a server error.
Client disconnects propagate as a real AbortSignal
Section titled “Client disconnects propagate as a real AbortSignal”close() stops accepting new connections, drops idle keep-alive sockets, and lets in-flight requests finish. Anything still running after shutdownTimeout (default 10 seconds) has its socket destroyed, which aborts its request.signal. Pass shutdownTimeout: Infinity to wait indefinitely, or a smaller value to fit your orchestrator’s kill grace period.
const app = await createHttpApplication(AppModule, { shutdownTimeout: 5_000 });If a client closes the connection before a handler finishes, the Request’s signal fires "abort" — useful for cancelling expensive work early:
@Get("report")async generateReport(@Req() req: Request) { const result = await computeReport({ signal: req.signal }); return result;}In-process, without a socket at all
Section titled “In-process, without a socket at all”app.handle(request) runs the exact same request-handling logic — routing, validation, guards, error mapping — against an in-memory Request, with no server, no port, no network stack. This is what @blixis-io/testing is built on (see Testing), and it’s also a reasonable way to invoke the same application logic from a non-HTTP entry point (a CLI command, a queue worker) without spinning up a socket you don’t need.
The origin of request.url
Section titled “The origin of request.url”request.url is a full URL, and its origin (scheme://host[:port]) comes from the address the server listens on, with the port it actually bound (so listen(0) reports the real port, not 0). Client headers don’t influence it by default, because a client can send any Host and any X-Forwarded-*. Two options let you opt in:
// Clients reach the server directly, by its public name:const app = await createHttpApplication(AppModule, { trustHostHeader: true });
// The server sits behind a proxy or load balancer you control:const app = await createHttpApplication(AppModule, { trustProxy: true });| Option | request.url origin comes from |
|---|---|
| neither | the listen address |
trustHostHeader |
the Host header, over http |
trustProxy |
X-Forwarded-Proto and X-Forwarded-Host (first value of each), else Host; implies trustHostHeader |
Only a bare host or host:port is accepted (letters, digits, -, _, dots, or a bracketed IPv6 literal), and only http or https for the scheme. Anything else, such as evil.com/path, user@evil.com or a javascript: scheme, is ignored and the listen address is used instead, so a hostile header can’t smuggle a path or credentials into the URL. Turn on trustProxy only when the proxy overwrites those headers; otherwise any client can claim any origin. Even with an option on, build absolute links in emails and redirects from a configured public URL, not from request.url. A server bound to an IPv6 address (listen(3000, "::1")) is written with brackets (http://[::1]:3000), which new URL requires.
Under createFetchHandler the platform supplies the Request and its URL; these options don’t apply.
Who is connecting
Section titled “Who is connecting”currentRemoteAddress() returns the address of the peer that opened the connection for the request being served, from anywhere inside it: a middleware, a guard, a controller, a service. It is undefined outside a request, and for a request that did not arrive on a socket (app.handle() in-process, createFetchHandler, where the platform owns the socket). An IPv4 peer is written plainly (203.0.113.7, not ::ffff:203.0.113.7).
import { currentRemoteAddress } from "@blixis-io/http";
const peer = currentRemoteAddress(); // "203.0.113.7"Behind a reverse proxy this is the proxy, not the user. Nothing here reads X-Forwarded-For or Forwarded, because a client can send any value in them, and the trustProxy option above only concerns the origin of request.url. To get the user’s address, trust the forwarded header only for the proxies you control: getClientIp() in @blixis-io/security does that and takes the number of proxies in front of the server. Don’t key a rate limit or an allow list on the raw header.
What isn’t handled for you yet
Section titled “What isn’t handled for you yet”There’s no built-in request logging, rate limiting, CORS, security headers or compression in the HTTP layer itself (the optional @blixis-io/security package provides the middleware for the middle three): the framework’s HTTP layer is deliberately just routing + validation + guards + error mapping (see Introduction). What there is, is a place to put them: the middleware option wraps every request, including the ones the router refuses and the routes you mount(), and works the same under listen() and createFetchHandler.
import { withResponseHeaders, type Middleware } from "@blixis-io/http";
const accessLog: Middleware = async (request, next) => { const started = performance.now(); const response = await next(); console.log(`${request.method} ${new URL(request.url).pathname} ${response.status} ${Math.round(performance.now() - started)}ms`); return response;};
const securityHeaders: Middleware = async (_request, next) => withResponseHeaders(await next(), { "x-content-type-options": "nosniff" });
const app = await createHttpApplication(AppModule, { middleware: [accessLog, securityHeaders] });See Middleware for the order, error handling and what a middleware can and can’t see, and Logging requests and errors for the request-id and access-log middleware that ship with @blixis-io/http.
On a platform that calls fetch (Vercel, Netlify, Cloudflare Workers)
Section titled “On a platform that calls fetch (Vercel, Netlify, Cloudflare Workers)”listen() is for a long-lived Node process. On a serverless or edge platform the platform owns the socket and calls your code once per request, so export a fetch handler instead:
import { createFetchHandler } from "@blixis-io/http";import { AppModule } from "../dist/app.module.js";
export default createFetchHandler(AppModule);The object has fetch(request) (what Vercel and Workers look for) and close(). The application boots on the first request, once; requests that arrive while it is booting share that boot, and later requests reuse it. If boot fails, that request gets a generic 500 (the real error is logged, never sent to the client) and the next request tries again instead of caching the failure.
listen(), shutdownTimeout and the SIGTERM handler don’t apply here. Options such as requestTimeout, bodyLimit and responseValidation do: createFetchHandler(AppModule, { requestTimeout: 8000 }).
Run it from compiled JavaScript (tsc or Rolldown output). The providers’ own TypeScript bundlers use esbuild, which drops the decorator metadata the DI container needs. See Compatibility for what has been run where.