Last active
October 19, 2023 21:49
-
-
Save kevinswiber/d59ba9df9f0d2e301014c1065e5fa2bb to your computer and use it in GitHub Desktop.
Simple Node.js HTTP server with router and logging.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Purpose: A simple HTTP server with routing and logging. | |
// Author: Kevin Swiber <[email protected]> | |
// Exports: | |
// serve({ routes, host, port, protocol, secure, serverOptions }) | |
// routes: A Map of routes to handlers. Example: | |
// const routes = new Map(); | |
// routes.set("/greeting", { | |
// get: ({ response }) => { | |
// response.setHeader("Content-Type", "application/json"); | |
// response.end(JSON.stringify({ hello: "world" })); | |
// } | |
// }); | |
// host: The hostname to listen on. Defaults to localhost. | |
// port: The port to listen on. Defaults to 0 (random). | |
// protocol: The protocol to use. Defaults to http1.1, supports http2. | |
// secure: Whether to use TLS. Defaults to false. | |
// serverOptions: Options to pass to the server constructor. | |
// logger: A logger object with the following methods: | |
// trace(...args): Log a trace message. | |
// debug(...args): Log a debug message. | |
// info(...args): Log an info message. | |
// warn(...args): Log a warning message. | |
// error(...args): Log an error message. | |
// fatal(...args): Log a fatal message. | |
// object: An object with the same methods as the logger, but which accepts | |
// an object as the first argument. The object will be formatted as JSON. | |
// | |
// Logging: | |
// The logger will only log messages at or above the LOG_LEVEL environment | |
// variable. The LOG_STYLE environment variable controls the format of the | |
// log messages. It can be set to "json" or "pretty". The default is "json". | |
// The NO_COLOR environment variable can be set to disable colorized output. | |
// The default is to enable colorized output if the terminal supports it. | |
// | |
// TLS: | |
// The TLS_CA_CERT, TLS_SERVER_CERT, and TLS_SERVER_KEY environment variables | |
// can be used to specify the paths to the CA certificate, server certificate, | |
// and server key, respectively. The default values are: | |
// TLS_CA_CERT: $HOME/.local/share/certs/localhost+2.pem | |
// TLS_SERVER_CERT: $HOME/.local/share/certs/localhost+2.pem | |
// TLS_SERVER_KEY: $HOME/.local/share/certs/localhost+2-key.pem | |
// | |
// Routing: | |
// The router supports the following syntax path syntax for the Map keys: | |
// /path/to/resource | |
// Matches the exact string "/path/to/resource". | |
// /path/to/{resource} | |
// Matches the exact string "/path/to/" followed by any string. The matched | |
// string will be available in the matches array as matches[0].groups.resource. | |
// /path/to/{resource:regex} | |
// Matches the exact string "/path/to/" followed by any string that matches the | |
// given regular expression. The matched string will be available in the | |
// matches array as matches[0].groups.resource. | |
// /path/to/{resource*} | |
// Matches the exact string "/path/to/" followed by any string. The matched | |
// string will be available in the matches array as matches[0].groups.resource. | |
// The router will continue to match segments until the end of the path. | |
// /path/to/{resource*:regex} | |
// Matches the exact string "/path/to/" followed by any string that matches the | |
// given regular expression. The matched string will be available in the | |
// matches array as matches[0].groups.resource. | |
// The router will continue to match segments until the end of the path. | |
// /regex/ | |
// Matches any string that matches the given regular expression. The matched | |
// string will be available in the matches array as matches[0]. | |
// | |
// Route handlers support the following syntax: | |
// { | |
// get: ({ request, response, url, matches, logger }) => { | |
// // request: The incoming request object. | |
// // response: The outgoing response object. | |
// // url: A URL object representing the request URL. | |
// // matches: An array of matches from the router. | |
// // logger: A logger object. | |
// }, | |
// post: (..), | |
// put: (..), | |
// patch: (..), | |
// delete: (..), | |
// "*": (..) | |
// [method: string]: (..) | |
// } | |
// The router will attempt to match the request method to a handler. | |
// - If no handler is found for the HTTP method, it will fallback to | |
// the "*" handler. | |
// - If no handler is found on a known path, it will return a | |
// 406 Method Not Allowed response. | |
// - If no handler is found, it will return a 404 Not Found response. | |
// | |
// MIT License | |
// | |
// Copyright (c) 2023 Kevin Swiber | |
// | |
// Permission is hereby granted, free of charge, to any person obtaining a copy | |
// of this software and associated documentation files (the "Software"), to deal | |
// in the Software without restriction, including without limitation the rights | |
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
// copies of the Software, and to permit persons to whom the Software is | |
// furnished to do so, subject to the following conditions: | |
// | |
// The above copyright notice and this permission notice shall be included in all | |
// copies or substantial portions of the Software. | |
// | |
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
// SOFTWARE. | |
import { createServer as createHttpServer } from "node:http"; | |
import { createServer as createHttpsServer } from "node:https"; | |
import { | |
createServer as createHttp2Server, | |
createSecureServer as createSecureHttp2Server | |
} from "node:http2"; | |
import { readFileSync } from "node:fs"; | |
import { homedir } from "node:os"; | |
import { resolve } from "node:path"; | |
import { env, hrtime, stdout } from "node:process"; | |
import { isatty } from "node:tty"; | |
import { formatWithOptions } from "node:util"; | |
const kHandlers = Symbol.for("handlers"); | |
const kRouteOptions = Symbol.for("routeOptions"); | |
const kRouteKey = Symbol.for("routeKey"); | |
const isCompatibleTerminal = isatty(stdout.fd) && env.TERM | |
&& (env.TERM !== "dumb"); | |
const defaults = { | |
HOST: "localhost", | |
PORT: 0, | |
LOG_LEVEL: "info", | |
LOG_STYLE: isCompatibleTerminal ? "pretty" : "json", | |
TLS_SERVER_KEY: resolve(homedir(), ".local/share/certs/localhost+2-key.pem"), | |
TLS_SERVER_CERT: resolve(homedir(), ".local/share/certs/localhost+2.pem") | |
}; | |
const { | |
HOST, | |
PORT, | |
LOG_LEVEL, | |
LOG_STYLE, | |
TLS_CA_CERT, | |
TLS_SERVER_CERT, | |
TLS_SERVER_KEY, | |
NO_COLOR, | |
} = Object.assign(defaults, env); | |
const colors = (LOG_STYLE === "pretty") && !NO_COLOR | |
const levels = { | |
trace: 10, | |
debug: 20, | |
info: 30, | |
warn: 40, | |
error: 50, | |
fatal: 60, | |
}; | |
const formatters = { | |
json: (entry) => JSON.stringify(entry), | |
pretty: (entry) => { | |
return formatWithOptions({ colors }, "[%s] %s%s: %s", | |
maybeColorizeDate(entry.date), maybeColorizeLevel(entry.level), | |
entry.event ? ` (${entry.event})` : "", | |
entry.message); | |
} | |
}; | |
const formatter = formatters[LOG_STYLE]; | |
const logger = { | |
supports(level) { | |
return levels[LOG_LEVEL] <= levels[level] | |
}, | |
write(level, ...args) { | |
const entry = { | |
level, | |
date: new Date().toISOString(), | |
message: formatWithOptions({ colors }, ...args), | |
}; | |
stdout.write(`${formatter(entry)}\n`); | |
}, | |
writeObject(level, obj) { | |
const entry = { | |
level, | |
date: new Date().toISOString(), | |
}; | |
if (obj.message) { | |
obj.message = formatWithOptions({ colors }, obj.message); | |
} | |
stdout.write(`${formatter(Object.assign(entry, obj))}\n`); | |
}, | |
}; | |
logger.object = { logger }; | |
for (const level of Object.keys(levels)) { | |
Object.defineProperty(logger, level, { | |
value: function(...args) { | |
if (this.supports(level)) { | |
this.write(level, ...args); | |
} | |
} | |
}); | |
Object.defineProperty(logger.object, level, { | |
value: function(obj) { | |
if (this.logger.supports(level)) { | |
this.logger.writeObject(level, obj); | |
} | |
} | |
}); | |
} | |
function maybeColorizeLevel(level) { | |
if (!colors) { | |
return level; | |
} | |
switch (level) { | |
case "info": | |
return `\x1b[32m${level}\x1b[39m`; | |
case "warn": | |
return `\x1b[33m${level}\x1b[39m`; | |
case "debug": | |
return `\x1b[34m${level}\x1b[39m`; | |
case "trace": | |
return `\x1b[35m${level}\x1b[39m`; | |
case "error": | |
case "fatal": | |
return `\x1b[31m${level}\x1b[39m`; | |
default: | |
return level; | |
} | |
} | |
function maybeColorizeDate(date) { | |
if (!colors) { | |
return date; | |
} | |
return `\x1b[90m${date}\x1b[39m`; | |
} | |
function maybeColorizeStatusCode(statusCode) { | |
if (!colors) { | |
return statusCode; | |
} | |
if (statusCode >= 500) { | |
return `\x1b[31m${statusCode}\x1b[39m`; | |
} else if (statusCode >= 400) { | |
return `\x1b[33m${statusCode}\x1b[39m`; | |
} else { | |
return `\x1b[32m${statusCode}\x1b[39m`; | |
} | |
} | |
function maybeColorizeServiceTime(serviceTime) { | |
if (!colors) { | |
return serviceTime; | |
} | |
const duration = Number(serviceTime?.slice(0, -2) || 0); | |
if (duration >= 1e3) { | |
return `\x1b[31m${serviceTime}\x1b[39m`; | |
} else if (duration >= 500) { | |
return `\x1b[33m${serviceTime}\x1b[39m`; | |
} else { | |
return `\x1b[32m${serviceTime}\x1b[39m`; | |
} | |
} | |
function serve({ | |
routes, | |
host = HOST, | |
port = PORT, | |
protocol = "http1.1", | |
secure = false, | |
serverOptions = {} | |
}) { | |
const activeSessions = new Set(); | |
const routeTreeMap = createRouteTreeMap(routes) | |
let createServer = secure ? createHttpsServer : createHttpServer; | |
if (protocol === "http2") { | |
createServer = secure ? createSecureHttp2Server : createHttp2Server; | |
} | |
if (secure) { | |
serverOptions.key ||= readFileSync(TLS_SERVER_KEY); | |
serverOptions.cert ||= readFileSync(TLS_SERVER_CERT); | |
serverOptions.ca ||= TLS_CA_CERT | |
? readFileSync(TLS_CA_CERT) | |
: undefined; | |
} | |
const server = createServer(serverOptions); | |
server.on("error", function onError(err) { | |
logger.object.fatal({ | |
event: "http-server-error", | |
message: err | |
}); | |
attemptGracefulShutdown(1); | |
}); | |
server.on("listening", function onListen() { | |
const { address, port, family } = server.address(); | |
const host = family === "IPv6" ? `[${address}]` : address; | |
const scheme = secure ? "https" : "http"; | |
logger.object.info({ | |
event: "http-listen", | |
host, | |
port, | |
family, | |
message: `Server listening on ${scheme}://${host}:${port}` | |
}); | |
}); | |
server.on("request", function onRequest(request, response) { | |
let loggingRouteKey = undefined; | |
try { | |
const { routeKey, handler } = router({ | |
routes: routeTreeMap, | |
request, | |
protocol, | |
activeSessions | |
}); | |
loggingRouteKey = routeKey; | |
handler?.(request, response); | |
} catch (err) { | |
logger.object.error({ | |
event: "http-routing-error", | |
"http.route": loggingRouteKey, | |
message: err | |
}); | |
if (!response.headersSent) { | |
response.statusCode = 500; | |
response.setHeader("Content-Type", "text/plain"); | |
response.end("Internal server error."); | |
} | |
} | |
}); | |
function attemptGracefulShutdown(exitCode = 1) { | |
logger.object.info({ | |
event: "http-close", | |
message: "Attempting graceful shutdown..." | |
}); | |
if (typeof server.closeIdleConnections === "function") { | |
server.closeIdleConnections(); | |
} | |
for (const session of activeSessions) { | |
if (typeof session.close === "function") { | |
session.close(); | |
} | |
} | |
function closeIdleConnections() { | |
for (const session of activeSessions) { | |
if (session._httpMessage && !session._httpMessage.finished) { | |
continue; | |
} | |
if (typeof session.close === "function") { | |
session.close(); | |
} else { | |
session.destroy(); | |
} | |
} | |
setImmediate(() => { | |
if (activeSessions.size === 0) { | |
clearInterval(killWatcher); | |
return; | |
} | |
}); | |
} | |
const killWatcher = setInterval(closeIdleConnections, | |
server.keepAliveTimeout || 5000); | |
closeIdleConnections(); | |
server.close(function onClose() { | |
logger.object.info({ | |
event: "http-graceful-shutdown", | |
message: "Graceful shutdown complete." | |
}); | |
process.exit(exitCode); | |
}); | |
} | |
["SIGINT", "SIGTERM"].forEach((signal) => { | |
process.on(signal, function onSignal() { | |
attemptGracefulShutdown(0); | |
}); | |
}); | |
server.listen(port, host); | |
} | |
function createRouteTreeMap(routeMap, routeKeyPrefix = "") { | |
const routes = new Map(); | |
const stringPaths = new Map(); | |
for (const [path, value] of routeMap.entries()) { | |
if (path instanceof RegExp) { | |
if (path.test("")) { | |
routes.set(kHandlers, value); | |
continue; | |
} | |
if (!routes.has(path)) { | |
routes.set(path, new Map()); | |
} | |
let route = routes.get(path); | |
route.set(kRouteOptions, { matchToEnd: true, matchString: false }); | |
route.set(kRouteKey, routeKeyPrefix + path); | |
if (value instanceof Map) { | |
for (const [k, v] of createRouteTreeMap(value, path).entries()) { | |
route.set(k, v); | |
} | |
} else { | |
route.set(kHandlers, value); | |
} | |
continue; | |
} | |
stringPaths.set(path, value); | |
} | |
const state = { routes }; | |
for (const [path, value] of stringPaths.entries()) { | |
if (path === "") { | |
state.routes = routes; | |
} | |
const segments = Array.from(path).reduce((acc, char) => { | |
const last = acc[acc.length - 1]; | |
const isEscaped = last?.endsWith("\\"); | |
if (char === "/" && !isEscaped) { | |
acc.push("/"); | |
} else { | |
if (last === "/") { | |
acc.push(char); | |
} else { | |
acc[acc.length - 1] += char; | |
} | |
} | |
return acc; | |
}, []); | |
for (const segment of segments) { | |
if (segment.startsWith("{") && segment.endsWith("}")) { | |
const adjustedSegment = segment.substr(1, segment.length - 2); | |
const splitSegment = adjustedSegment.split(":", 2); | |
const hasSplat = splitSegment[0]?.endsWith("*"); | |
const variableName = splitSegment[0]?.replace("*", "") || "segment"; | |
const variableExpression = `(?<${variableName}>${splitSegment[1] || ".+"})`; | |
try { | |
new RegExp(`^${variableExpression}$`); | |
} catch (err) { | |
if (err instanceof SyntaxError) { | |
logger.object.warn({ | |
event: "http-router", | |
message: formatWithOptions({ colors }, "Invalid path syntax: `%s`. %O", path, err) | |
}); | |
} | |
continue; | |
} | |
const re = new RegExp(`^${variableExpression}$`); | |
if (!state.routes.has(re)) { | |
state.routes.set(re, new Map()); | |
} | |
state.routes = state.routes.get(re); | |
state.routes.set(kRouteOptions, { matchToEnd: !!hasSplat, matchString: false }); | |
} else { | |
if (!state.routes.has(segment)) { | |
state.routes.set(segment, new Map()); | |
} | |
state.routes = state.routes.get(segment); | |
state.routes.set(kRouteOptions, { matchToEnd: false, matchString: true }); | |
} | |
} | |
const routeKey = `${routeKeyPrefix}${path}`; | |
state.routes.set(kRouteKey, routeKey); | |
if (value instanceof Map) { | |
for (const [k, v] of createRouteTreeMap(value, routeKey).entries()) { | |
state.routes.set(k, v); | |
} | |
} else { | |
state.routes.set(kHandlers, value); | |
} | |
state.routes = routes; | |
} | |
return routes; | |
}; | |
function router({ | |
routes, | |
request: { method, headers, url }, | |
protocol, | |
activeSessions | |
}) { | |
const supportsTrace = logger.supports("trace"); | |
const state = { | |
routes, | |
handlers: null, | |
routeKey: "", | |
matches: [], | |
fullMatch: false, | |
startTime: hrtime.bigint() | |
}; | |
const parsedURL = new URL(url, `http://${headers.host}`); | |
const { pathname } = parsedURL; | |
const segments = Array.from(pathname).reduce((acc, char) => { | |
if (char === "/") { | |
acc.push("/"); | |
} else { | |
const last = acc[acc.length - 1]; | |
if (last === "/") { | |
acc.push(char); | |
} else { | |
acc[acc.length - 1] += char; | |
} | |
} | |
return acc; | |
}, []).map((segment) => decodeURIComponent(segment)); | |
for (const [segmentIndex, segment] of segments.entries()) { | |
for (const [path, value] of state.routes.entries()) { | |
if ([kHandlers, kRouteOptions, kRouteKey].indexOf(path) !== -1) { | |
continue; | |
} | |
const options = value.get(kRouteOptions); | |
const matchPath = options.matchToEnd ? | |
segments.slice(segmentIndex).join("") : | |
segment; | |
if (options.matchString) { | |
if (matchPath === path) { | |
const matches = [path]; | |
matches.index = 0; | |
matches.input = matchPath; | |
matches.groups = undefined; | |
if (options.matchToEnd) { | |
state.fullMatch = true; | |
} | |
state.matches.push(matches); | |
state.routes = value; | |
state.routeKey = value.get(kRouteKey); | |
state.handlers = value.get(kHandlers); | |
break; | |
} | |
} else { | |
const matches = path.exec(matchPath); | |
if (matches) { | |
if (options.matchToEnd) { | |
state.fullMatch = true; | |
} | |
state.matches.push(matches); | |
state.routes = value; | |
state.routeKey = value.get(kRouteKey); | |
state.handlers = value.get(kHandlers); | |
break; | |
} | |
} | |
} | |
if (!state.fullMatch && state.matches.length < segmentIndex + 1) { | |
state.matches = []; | |
state.routes = routes; | |
state.routeKey = ""; | |
state.handlers = null; | |
break; | |
} | |
} | |
const inner = state.handlers?.[method.toLowerCase()] || | |
state.handlers?.["*"] || fallback(state.handlers); | |
function handlerWrap(request, response) { | |
if (request.httpVersion === "2.0") { | |
const session = request.stream.session; | |
if (!activeSessions.has(session)) { | |
session.on("close", () => { | |
activeSessions.delete(session); | |
}); | |
activeSessions.add(session); | |
} | |
} else if (protocol === "http2") { | |
// this is an http/1.1 connection on an http/2 server | |
if ((request.headers.connection || "").toLowerCase() === "keep-alive") { | |
const socket = request.socket; | |
if (!activeSessions.has(socket)) { | |
socket.on("close", () => { | |
activeSessions.delete(socket); | |
}); | |
activeSessions.add(socket); | |
} | |
} | |
} | |
request.on("error", (err) => { | |
logger.object.error({ | |
event: "http-request-error", | |
message: err | |
}); | |
}); | |
response.on("error", (err) => { | |
logger.object.error({ | |
event: "http-response-error", | |
message: err | |
}); | |
}); | |
if (logger.supports("debug")) { | |
const level = supportsTrace ? "trace" : "debug"; | |
const socket = request.httpVersion === "2.0" | |
? request.stream.session.socket | |
: request.socket; | |
response.on("finish", () => { | |
const { statusCode } = response; | |
const { method, url } = request; | |
const { remoteAddress, remotePort } = socket; | |
const path = supportsTrace ? url : (state.routeKey || "-"); | |
const loggableStatusCode = maybeColorizeStatusCode(statusCode); | |
const duration = (hrtime.bigint() - state.startTime) / BigInt(1e6); | |
const loggableDuration = supportsTrace ? ` (${maybeColorizeServiceTime(`${duration}ms`)})` : ""; | |
const entry = { | |
event: "http-request", | |
statusCode, | |
remoteAddress, | |
"http.method": method, | |
"http.route": state.routeKey, | |
"http.path": supportsTrace ? url : undefined, | |
"http.duration": Number(duration), | |
message: | |
`${remoteAddress} ${remotePort} ${method} ${path} ` + | |
`${loggableStatusCode}${loggableDuration}` | |
} | |
logger.object[level](entry); | |
}); | |
} | |
return inner({ | |
request, | |
response, | |
url: parsedURL, | |
matches: state.matches, | |
logger | |
}); | |
} | |
return { routeKey: state.routeKey, handler: handlerWrap }; | |
} | |
function fallback(handlers) { | |
return function({ response }) { | |
if (!handlers) { | |
response.statusCode = 404; | |
response.end("Not found."); | |
return; | |
} | |
response.statusCode = 406; | |
response.end("Method not allowed."); | |
}; | |
} | |
export { serve, logger }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { serve } from "./http-server.js"; | |
import { routes } from "./routes.js"; | |
serve({ | |
routes, | |
protocol: "http2", | |
secure: true, | |
serverOptions: { allowHTTP1: true } | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const routes = new Map(); | |
const data = [ | |
{ | |
id: "mercury", | |
region: "us-east-1", | |
}, | |
{ | |
id: "venus", | |
region: "us-west-1" | |
}, | |
{ | |
id: "mars", | |
region: "us-west-2" | |
} | |
]; | |
function json(response, body) { | |
response.setHeader("Content-Type", "application/json"); | |
response.end(JSON.stringify(body)); | |
} | |
routes.set("/machines", { | |
get: ({ response }) => { | |
json(response, data); | |
} | |
}); | |
routes.set("/machines/{id}", { | |
get: ({ response, matches }) => { | |
const { id } = matches.pop().groups; | |
const machine = data.find((m) => m.id === decodeURIComponent(id)); | |
if (!machine) { | |
response.statusCode = 404; | |
response.end(); | |
return; | |
} | |
json(response, machine); | |
} | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment