Production all 2 written
TypeScript with no build step, and the configuration, logging and shutdown habits that separate a script from a service.
47 TypeScript in Node, without a build step
TypeScript is JavaScript with type annotations: you write down what kind of
value each variable and parameter holds, and a checker tells you when the code disagrees. Most large
Node projects use it. Node can now run a .ts file directly, with nothing installed, by
deleting the types and running the JavaScript that is left. That is convenient, and it has one
consequence you must understand before relying on it: Node never checks the types.
A .ts file, run directly
greet.ts
interface User {
name: string;
age: number;
}
function greet(user: User): string {
return `Hello, ${user.name}. Next year you will be ${user.age + 1}.`;
}
const ada: User = { name: "Ada", age: 36 };
console.log(greet(ada));
// Data from outside -- a form, a request body -- with a promise about its shape.
const fromForm = JSON.parse('{ "name": "Alan", "age": "41" }') as User;
console.log(greet(fromForm));
console.log("TypeScript support in this node:", process.features.typescript);
check("the annotated code ran", greet(ada), "Hello, Ada. Next year you will be 37.");
check("and nothing checked that age was really a number", greet(fromForm).endsWith("be 411."), true);
What node printed, when the build ran it
node greet.ts
Hello, Ada. Next year you will be 37. Hello, Alan. Next year you will be 411. TypeScript support in this node: strip ok the annotated code ran ok and nothing checked that age was really a number
The command is just node greet.ts. The interface, the annotations and the
as User were removed, and the JavaScript underneath ran. The second greeting shows what
that means. as User is a promise from the programmer, not a check: the form sent the age
as the string "41", and "41" + 1 is "411" (lesson 7). Node did
not notice, because it does not look at types at all — and the TypeScript checker would not have
noticed either, because JSON.parse returns any, which may be asserted to be
anything. Types describe the data you trust. Data from outside still has to be checked at run time,
as lesson 37's checkTodo did.
What stripping does
example.mjs
import { stripTypeScriptTypes } from "node:module";
const source = `function total(items: Item[], tax: number = 0.2): number {
return items.reduce((sum, item) => sum + item.price, 0) * (1 + tax);
}`;
const stripped = stripTypeScriptTypes(source);
console.log(source);
console.log("--- becomes ---");
console.log(stripped.replaceAll(" ", "·"));
check("every line keeps its length, so positions do not move",
stripped.split("\n").map((line) => line.length), source.split("\n").map((line) => line.length));
What node printed, when the build ran it
node example.mjs
function total(items: Item[], tax: number = 0.2): number {
return items.reduce((sum, item) => sum + item.price, 0) * (1 + tax);
}
--- becomes ---
function·total(items········,·tax·········=·0.2)·········{
··return·items.reduce((sum,·item)·=>·sum·+·item.price,·0)·*·(1·+·tax);
}
ok every line keeps its length, so positions do not move
(node:<pid>) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Node replaces each piece of type syntax with the same number of spaces (shown here as dots) and
runs the result. Nothing moves, so there is no need for source maps: when a .ts file
throws, the line and column in the stack trace are the line and column in your file. (The warning is
about the stripTypeScriptTypes function, which is newer; running a .ts file,
as the first panel did, prints none.)
distance.ts
type Point = {
x: number;
y: number;
};
function distance(a: Point, b: Point): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
const points: Point[] = JSON.parse('[{ "x": 0, "y": 0 }]');
console.log(distance(points[0], points[1]));
What node printed as it died, when the build ran it
node distance.ts
file:///app/distance.ts:7
return Math.hypot(a.x - b.x, a.y - b.y);
^
TypeError: Cannot read properties of undefined (reading 'x')
at distance (file:///app/distance.ts:7:29)
at Object.<anonymous> (file:///app/distance.ts:11:13)
at Module._compile (node:internal/modules/cjs/loader:1934:14)
at Object..js (node:internal/modules/cjs/loader:2074:10)
at Module.load (node:internal/modules/cjs/loader:1656:32)
at Module._load (node:internal/modules/cjs/loader:1448:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47
Node.js v26.7.0
Line 7, column 29 is exactly where b.x is in the file as written. (The bug is the
same as the last one: the types said there were two points, and the data had one.)
Imports: the extension, and import type
types.ts
export interface Order {
id: number;
total: number;
}
export const currency = "GBP";
no-extension.ts
import type { Order } from "./types";
import { currency } from "./types";
const order: Order = { id: 1, total: 1250 };
console.log(order, currency);
type-as-value.ts
import { Order, currency } from "./types.ts";
const order: Order = { id: 1, total: 1250 };
console.log(order, currency);
right.ts
import type { Order } from "./types.ts";
import { currency } from "./types.ts";
const order: Order = { id: 1, total: 1250 };
console.log(order, currency);
The terminal, as the build ran it
node no-extension.ts # exits 1 node:internal/modules/esm/resolve:272 throw new ERR_MODULE_NOT_FOUND( ^ Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/types' imported from /app/no-extension.ts at finalizeResolution (node:internal/modules/esm/resolve:272:11) at moduleResolve (node:internal/modules/esm/resolve:879:10) at defaultResolve (node:internal/modules/esm/resolve:1006:11) at #cachedDefaultResolve (node:internal/modules/esm/loader:708:20) at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:728:38) at ModuleLoader.resolveSync (node:internal/modules/esm/loader:766:56) at #resolve (node:internal/modules/esm/loader:690:17) at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:610:35) at ModuleJob.syncLink (node:internal/modules/esm/module_job:277:33) at ModuleJob.link (node:internal/modules/esm/module_job:389:17) { code: 'ERR_MODULE_NOT_FOUND', url: 'file:///app/types' } Node.js v26.7.0 node type-as-value.ts # exits 1 file:///app/type-as-value.ts:1 import { Order, currency } from "./types.ts"; ^^^^^ SyntaxError: The requested module './types.ts' does not provide an export named 'Order' at #asyncInstantiate (node:internal/modules/esm/module_job:463:21) at async ModuleJob.run (node:internal/modules/esm/module_job:561:5) at async node:internal/modules/esm/loader:650:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) Node.js v26.7.0 node right.ts { id: 1, total: 1250 } GBP
Two rules, both because Node reads each file on its own and does no guessing. First, write the
extension: "./types.ts", exactly as ES modules need "./maths.mjs" (lesson
20). Second, import a type with import type. An interface is deleted from
types.ts when it runs, so there is no Order left to import, and a plain
import { Order } fails when the module is linked. import type is deleted
along with the other types, so it never asks.
TypeScript that is not just types
account.ts
class Account {
constructor(private balance: number) {}
get total() {
return this.balance;
}
}
console.log(new Account(500).total);
What node printed as it died, when the build ran it
node account.ts
file:///app/account.ts:2
class Account {
constructor(private balance: number) {}
^^^^^^^^^^^^^^^
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode
at parseTypeScript (node:internal/modules/typescript:57:40)
at processTypeScriptCode (node:internal/modules/typescript:129:37)
at stripTypeScriptModuleTypes (node:internal/modules/typescript:172:22)
at Module._compile (node:internal/modules/cjs/loader:1888:15)
at Object..js (node:internal/modules/cjs/loader:2074:10)
at Module.load (node:internal/modules/cjs/loader:1656:32)
at Module._load (node:internal/modules/cjs/loader:1448:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47 {
code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'
}
Node.js v26.7.0
A few TypeScript features are not annotations on JavaScript but generate JavaScript of their own.
constructor(private balance: number) means "and assign this.balance =
balance", which is code, and there is nothing to strip it down to. enum is the other
one people meet (it is in the error catalogue), along with
namespace. Node refuses all of them rather than guess. Each has a plain replacement:
status.ts
// Instead of: enum Status { Active = "active", Suspended = "suspended" }
const Status = { Active: "active", Suspended: "suspended" } as const;
type Status = (typeof Status)[keyof typeof Status];
// Instead of a parameter property: a field and an assignment.
class Account {
#balance: number;
constructor(balance: number) {
this.#balance = balance;
}
get total(): number {
return this.#balance;
}
}
function describe(status: Status): string {
return status === Status.Active ? "can log in" : "cannot log in";
}
console.log(describe(Status.Active), "/", describe(Status.Suspended));
console.log("balance:", new Account(500).total);
check("the replacements run, with no generated code", [describe(Status.Suspended), new Account(500).total], ["cannot log in", 500]);
What node printed, when the build ran it
node status.ts
can log in / cannot log in balance: 500 ok the replacements run, with no generated code
Checking the types is a separate step
// tsconfig.json -- settings that match what Node does
{
"compilerOptions": {
"module": "nodenext",
"target": "esnext",
"strict": true,
"noEmit": true, // Node runs the .ts files; tsc only checks them
"allowImportingTsExtensions": true, // import "./types.ts"
"verbatimModuleSyntax": true, // insists on import type
"erasableSyntaxOnly": true // refuses enum and parameter properties, as Node does
}
}
// and then, in package.json:
// "scripts": { "check": "tsc", "test": "node --test" }
// run with: npm install -D typescript && npm run check
The type checker, tsc, is the typescript package from the npm registry, and this site installs nothing from it. Node itself only strips types, which every other panel on this page shows running.
So a TypeScript project on Node has two steps that used to be one. node runs the code,
straight from the .ts files, fast. tsc, the TypeScript compiler, checks the
types and writes nothing, and runs where you would run a linter: in your editor as you type, and in
CI before anything is merged. The three settings at the bottom make tsc reject exactly
the things Node would, so a program that passes the check also runs.
node_modules/money/package.json
{ "name": "money", "type": "module", "exports": "./index.ts" }
node_modules/money/index.ts
export const pounds = (pence: number): string => (pence / 100).toFixed(2);
app.ts
import { pounds } from "money";
console.log(pounds(1250));
The terminal, as the build ran it
node app.ts # exits 1
node:internal/modules/typescript:155
throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename);
^
Error [ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING]: Stripping types is currently unsupported for files under node_modules, for "file:///app/node_modules/money/index.ts"
at stripTypeScriptModuleTypes (node:internal/modules/typescript:155:11)
at ModuleLoader.<anonymous> (node:internal/modules/esm/translators:681:29)
at #translate (node:internal/modules/esm/loader:441:20)
at afterLoad (node:internal/modules/esm/loader:509:29)
at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:514:12)
at #getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:567:36)
at afterResolve (node:internal/modules/esm/loader:614:52)
at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:620:12)
at ModuleJob.syncLink (node:internal/modules/esm/module_job:277:33)
at ModuleJob.link (node:internal/modules/esm/module_job:389:17) {
code: 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'
}
Node.js v26.7.0
One limit: Node strips types only in your own files, never in node_modules. A package
has to be published as JavaScript, with its types in separate .d.ts files, so that it
works for everyone whatever they run it with. Lesson 49 builds a package.
node file.tsruns TypeScript by deleting the types. It never checks them.- Types are replaced with spaces, so line and column numbers stay correct.
- Import with the
.tsextension, and import types withimport type. enum,namespaceand parameter properties are refused. Use an objectas constand ordinary fields.- Check types separately with
tsc --noEmit, and validate outside data at run time anyway.
48 Configuration, logging and graceful shutdown
A program that works is not yet a service. A service is started by something else — a container platform, a process manager — with settings it did not choose, runs for weeks, writes logs that someone reads when it goes wrong at three in the morning, and is stopped and replaced every time you deploy. Three habits make that go smoothly, and each of them is a few lines of plain Node.
Configuration: check it all at start-up
config.mjs
export class ConfigError extends Error {}
// Read, check and convert every setting once, at start-up.
export function loadConfig(env) {
const problems = [];
const port = Number(env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) problems.push(`PORT must be a port number, got "${env.PORT}"`);
const logLevel = env.LOG_LEVEL ?? "info";
if (!["debug", "info", "warn", "error"].includes(logLevel)) problems.push(`LOG_LEVEL must be debug, info, warn or error, got "${logLevel}"`);
const apiToken = env.API_TOKEN;
if (!apiToken) problems.push("API_TOKEN is not set");
if (problems.length > 0) throw new ConfigError(problems.join("\n"));
return Object.freeze({ port, logLevel, apiToken });
}
main.mjs
import { loadConfig, ConfigError } from "./config.mjs";
let config;
try {
config = loadConfig(process.env);
} catch (err) {
if (!(err instanceof ConfigError)) throw err;
console.error("cannot start, the configuration is wrong:");
console.error(err.message.replace(/^/gm, " - "));
process.exit(1);
}
console.log("starting with", { ...config, apiToken: `[set, ${config.apiToken.length} characters]` });
// ... and the server would start here.
The terminal, as the build ran it
node main.mjs # exits 1 cannot start, the configuration is wrong: - API_TOKEN is not set PORT=80a LOG_LEVEL=verbose node main.mjs # exits 1 cannot start, the configuration is wrong: - PORT must be a port number, got "80a" - LOG_LEVEL must be debug, info, warn or error, got "verbose" - API_TOKEN is not set PORT=8080 API_TOKEN=pretend-token-123 node main.mjs starting with { port: 8080, logLevel: 'info', apiToken: '[set, 17 characters]' }
Settings that change between your laptop and production — ports, addresses, tokens — come from
environment variables (lesson 31), and loadConfig is the one place that reads them. It
converts each one to the right type, checks it, and collects every problem before refusing to
start, so a broken deployment fails immediately, with a message listing everything to fix. The
alternative, reading process.env.API_TOKEN wherever it is needed, fails an hour later on
the first request that needs the token, with an error that mentions undefined.
The result is frozen, so nothing can change it by accident, and it is passed to the rest of the program rather than read again. And the start-up line shows that the token was set and how long it is, never the token itself: anything printed ends up in a log, and logs are read by far more people, and kept far longer, than secrets should be.
Logs a machine can read
logger.mjs
import { AsyncLocalStorage } from "node:async_hooks";
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
const SECRET = /password|token|secret|authorization/i;
// Whatever is stored here is visible to all code running on behalf of one request.
export const requestContext = new AsyncLocalStorage();
export function createLogger(level = "info") {
const minimum = LEVELS[level];
const write = (lvl, msg, fields = {}) => {
if (LEVELS[lvl] < minimum) return;
const entry = { level: lvl, msg, ...requestContext.getStore(), ...fields };
console.log(JSON.stringify(entry, (key, value) => (SECRET.test(key) ? "[redacted]" : value)));
};
return {
debug: (msg, fields) => write("debug", msg, fields),
info: (msg, fields) => write("info", msg, fields),
warn: (msg, fields) => write("warn", msg, fields),
error: (msg, fields) => write("error", msg, fields),
};
}
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
import { setTimeout as sleep } from "node:timers/promises";
import { createLogger, requestContext } from "./logger.mjs";
const log = createLogger("info"); // debug lines are dropped at this level
const prices = { tea: 350, cake: 275, soup: 590 };
const slowness = { tea: 30, cake: 60, soup: 90 }; // ms the pretend database takes
// Deep inside the program, with no idea which request it is serving.
async function lookUpPrice(product) {
log.debug("looking up a price", { product });
await sleep(slowness[product]); // pretend to ask a database
log.info("price found", { product, pence: prices[product] });
return prices[product];
}
let nextId = 1;
const server = createServer((req, res) => {
const requestId = `req-${nextId++}`;
requestContext.run({ requestId }, async () => {
log.info("request started", { path: req.url, authorization: req.headers.authorization });
const product = req.url.split("/").pop();
const pence = await lookUpPrice(product);
res.end(String(pence));
log.info("request finished", { status: 200 });
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;
// Three requests at once, so their log lines interleave. (The lines are also
// collected here, so the checks below can look at them.)
const lines = [];
const realLog = console.log;
console.log = (line) => { lines.push(JSON.parse(line)); realLog(line); };
await Promise.all(["soup", "tea", "cake"].map((product) =>
fetch(`${base}/prices/${product}`, { headers: { authorization: "Bearer pretend-token-123" } })));
server.close();
console.log = realLog;
const idFor = Object.fromEntries(lines.filter((l) => l.path).map((l) => [l.path.split("/").pop(), l.requestId]));
const found = lines.filter((l) => l.msg === "price found");
check("the lines of the three requests interleaved", found.map((l) => l.product), ["tea", "cake", "soup"]);
check("yet each line from lookUpPrice carries the id of its own request", found.every((l) => l.requestId === idFor[l.product]), true);
check("debug lines were dropped, and the token never printed",
[lines.some((l) => l.level === "debug"), lines.filter((l) => l.path).every((l) => l.authorization === "[redacted]")], [false, true]);
What node printed, when the build ran it
node example.mjs
{"level":"info","msg":"request started","requestId":"req-1","path":"/prices/soup","authorization":"[redacted]"}
{"level":"info","msg":"request started","requestId":"req-2","path":"/prices/tea","authorization":"[redacted]"}
{"level":"info","msg":"request started","requestId":"req-3","path":"/prices/cake","authorization":"[redacted]"}
{"level":"info","msg":"price found","requestId":"req-2","product":"tea","pence":350}
{"level":"info","msg":"request finished","requestId":"req-2","status":200}
{"level":"info","msg":"price found","requestId":"req-3","product":"cake","pence":275}
{"level":"info","msg":"request finished","requestId":"req-3","status":200}
{"level":"info","msg":"price found","requestId":"req-1","product":"soup","pence":590}
{"level":"info","msg":"request finished","requestId":"req-1","status":200}
ok the lines of the three requests interleaved
ok yet each line from lookUpPrice carries the id of its own request
ok debug lines were dropped, and the token never printed
Each line is one JSON object: easy for a person to read, and trivial for a log system to search —
"every error line for req-3" is a query rather than a squint. Levels let a
production server log at info and hide the debug detail you only want while
investigating. The replacer passed to JSON.stringify redacts any field whose name looks
secret, so the authorization header, logged carelessly in "request started", came out as
[redacted].
The request id is the interesting part. Three requests were in flight at once, so their lines
interleaved, and lookUpPrice was never told which request it was serving — yet every one
of its lines carries the right id. AsyncLocalStorage keeps a value for everything that
runs because of one run() call, across every await and callback that follows
from it, the way a thread-local variable would in a language with a thread per request. Put the id in
at the edge of the program, and every log line and error from anywhere below includes it.
Log to standard output, one line per event, and let whatever runs the process collect it. A service
that writes and rotates its own log files is doing the platform's job, badly. In real projects a
package such as pino does all of this, faster; it produces the same kind of line.
Graceful shutdown
Deploying a new version means stopping the old one, and a platform stops a process by sending it
SIGTERM (lesson 31), then, if it has not gone after some seconds, SIGKILL,
which cannot be caught. The panel runs one server three ways. Each time, a slow request is in flight
when the signal arrives, and a second request is sent just after it.
server.mjs
import { createServer } from "node:http";
import { setTimeout as sleep } from "node:timers/promises";
const mode = process.env.SHUTDOWN; // "none", "close" or "graceful"
let shuttingDown = false;
const server = createServer(async (req, res) => {
await sleep(300); // a slow request
if (shuttingDown && mode === "graceful") {
res.setHeader("connection", "close"); // do not keep this connection
}
res.end(`finished ${req.url}`);
});
if (mode !== "none") {
process.on("SIGTERM", () => {
shuttingDown = true;
server.close(() => process.exit(0)); // stop accepting; exit once idle
if (mode === "graceful") server.closeIdleConnections(); // and drop idle keep-alives now
setTimeout(() => process.exit(1), 10_000).unref(); // but never wait forever
});
}
server.listen(0, "127.0.0.1", () => console.log(`http://127.0.0.1:${server.address().port}`));
example.mjs
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { once } from "node:events";
import { setTimeout as sleep } from "node:timers/promises";
async function tryShutdown(mode) {
const server = spawn(process.execPath, ["server.mjs"], {
env: { ...process.env, SHUTDOWN: mode },
stdio: ["ignore", "pipe", "inherit"],
});
const exited = once(server, "exit");
const [base] = await once(createInterface({ input: server.stdout }), "line");
const inFlight = fetch(`${base}/order`).then(
async (res) => `${res.status} ${await res.text()}`,
(err) => `failed (${err.cause?.code})`);
await sleep(100); // the request is now in progress
const signalled = performance.now();
server.kill("SIGTERM");
await sleep(20);
const late = await fetch(`${base}/late`).then((res) => String(res.status), (err) => `failed (${err.cause?.code})`);
const [code, signal] = await exited;
return {
mode,
"in flight": await inFlight,
"sent after": late,
"ended by": signal ?? `exit ${code}`,
"ms to exit": Math.round(performance.now() - signalled),
};
}
const results = [];
for (const mode of ["none", "close", "graceful"]) results.push(await tryShutdown(mode));
console.table(results);
const [none, close, graceful] = results;
check("with no handler, the request in flight was cut off", [none["in flight"].startsWith("failed"), none["ended by"]], [true, "SIGTERM"]);
check("close() let it finish, but the process lingered for seconds", [close["in flight"], close["ms to exit"] > 1000], ["200 finished /order", true]);
check("the graceful version finished it and left promptly", [graceful["in flight"], graceful["ended by"], graceful["ms to exit"] < 1000], ["200 finished /order", "exit 0", true]);
check("no version accepted a request after the signal", results.every((r) => r["sent after"] === "failed (ECONNREFUSED)"), true);
What node printed, when the build ran it
node example.mjs
┌─────────┬────────────┬───────────────────────────┬─────────────────────────┬───────────┬────────────┐ │ (index) │ mode │ in flight │ sent after │ ended by │ ms to exit │ ├─────────┼────────────┼───────────────────────────┼─────────────────────────┼───────────┼────────────┤ │ 0 │ 'none' │ 'failed (UND_ERR_SOCKET)' │ 'failed (ECONNREFUSED)' │ 'SIGTERM' │ 24 │ │ 1 │ 'close' │ '200 finished /order' │ 'failed (ECONNREFUSED)' │ 'exit 0' │ 3222 │ │ 2 │ 'graceful' │ '200 finished /order' │ 'failed (ECONNREFUSED)' │ 'exit 0' │ 215 │ └─────────┴────────────┴───────────────────────────┴─────────────────────────┴───────────┴────────────┘ ok with no handler, the request in flight was cut off ok close() let it finish, but the process lingered for seconds ok the graceful version finished it and left promptly ok no version accepted a request after the signal
With no handler, SIGTERM ended the process on the spot, and the customer whose order
was in flight got a broken connection. server.close() is half the fix: it stops accepting
new connections at once (the late request was refused) and calls back when the existing ones are done,
so the order finished. But the process then lingered for seconds, because the client kept its
connection open for reuse — HTTP keep-alive — and an open connection counts as not done. The graceful
version also closes idle connections immediately and tells clients with requests still running not to
reuse theirs (connection: close), so it exits the moment the last request is answered. The
unreferenced ten-second timer is the safety net: if something never finishes, exit anyway rather than
wait for SIGKILL.
A real shutdown handler then closes whatever else is open, in reverse order of opening: stop accepting work, finish what is in flight, close the database (lesson 39), flush the logs, exit 0.
In a container, handle SIGTERM yourself. Run as CMD ["node",
"server.mjs"], Node is process 1 in the container, and Linux gives process 1 no default signal
behaviour: without a handler, SIGTERM does nothing at all, and the container sits there
until it is killed ten seconds later. A handler like the one above fixes that, as does running with
docker run --init.
// When something is truly broken, log it and exit -- and let the platform restart you.
process.on("uncaughtException", (err) => {
log.error("uncaught exception, exiting", { error: err.stack });
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
log.error("unhandled rejection, exiting", { error: String(reason) });
process.exit(1);
});
A last-resort handler only matters in a process that has already gone wrong; lesson 28 ran one for real. This is its shape in a service, next to the logger above.
After an uncaught exception the program is in a state nobody planned for, so do not try to carry on: log it, exit non-zero, and let the process manager — systemd, a container restart policy, Kubernetes — start a fresh copy. That is only safe because the rest of this lesson is in place: configuration is checked on every start, and a restart drops nothing the graceful path would have kept.
- Read configuration from the environment once, validate all of it, and refuse to start if it is wrong.
- Never print secrets. Redact them in logs by field name.
- Log one JSON object per line to standard output, with a level. Use
AsyncLocalStorageto attach a request id everywhere. - On
SIGTERM: stop accepting, finish in-flight requests, close idle connections, clean up, exit — with a timeout. - On an uncaught error: log, exit 1, and let the platform restart you.