@thi.ng/server
Version:
Minimal HTTP server with declarative routing, static file serving and freely extensible via pre/post interceptors
361 lines (360 loc) • 10.2 kB
JavaScript
import { identity } from "@thi.ng/api";
import { isFunction } from "@thi.ng/checks";
import { readText } from "@thi.ng/file-io";
import { ConsoleLogger } from "@thi.ng/logger";
import { preferredTypeForPath } from "@thi.ng/mime";
import { Router } from "@thi.ng/router";
import { upper } from "@thi.ng/strings";
import { createReadStream } from "node:fs";
import * as http from "node:http";
import * as https from "node:https";
import { isIPv6 } from "node:net";
import { pipeline, Transform } from "node:stream";
import { createBrotliCompress, createDeflate, createGzip } from "node:zlib";
import { parseCoookies } from "./utils/cookies.js";
import { parseSearchParams } from "./utils/formdata.js";
import { isMatchingHost, normalizeIPv6Address } from "./utils/host.js";
const MISSING = "__missing";
class Server {
constructor(opts = {}) {
this.opts = opts;
this.logger = opts.logger ?? new ConsoleLogger("server");
this.host = opts.host ?? "localhost";
this.methodAdapter = opts.method ?? ((method, { route }) => method === "head" && !route.handlers.head && route.handlers.get ? (console.log("adapted head"), "get") : method);
if (isIPv6(this.host)) this.host = normalizeIPv6Address(this.host);
this.augmentCtx = opts.context ?? identity;
const routes = [
{
id: MISSING,
match: ["__404__"],
handlers: {
get: async ({ res }) => res.missing()
}
},
...this.opts.routes ?? []
];
this.router = new Router({
default: MISSING,
prefix: opts.prefix ?? "/",
trim: opts.trim ?? true,
routes: routes.map(this.compileRoute.bind(this))
});
}
logger;
router;
server;
host;
augmentCtx;
methodAdapter;
async start() {
const {
ssl,
host = "localhost",
port = ssl ? 443 : 8080,
uniqueHeaders,
blockList,
requestTimeout
} = this.opts;
try {
this.server = ssl ? https.createServer(
{
key: readText(ssl.key, this.logger),
cert: readText(ssl.cert, this.logger),
ServerResponse,
uniqueHeaders,
blockList,
requestTimeout
},
this.listener.bind(this)
) : http.createServer(
{ ServerResponse },
this.listener.bind(this)
);
this.server.listen(port, host, void 0, () => {
this.logger.info(
`starting server: http${ssl ? "s" : ""}://${host}:${port}`
);
});
return true;
} catch (e) {
this.logger.severe(e);
return false;
}
}
async stop() {
if (this.server) {
this.logger.info(`stopping server...`);
this.server.close();
}
return true;
}
async listener(req, res) {
try {
if (req.url.includes("#")) return res.badRequest();
const url = new URL(req.url, `http://${req.headers.host}`);
if (this.opts.host && !isMatchingHost(url.hostname, this.host)) {
this.logger.debug(
"ignoring request, host mismatch:",
url.hostname,
this.host
);
return res.noResponse();
}
const path = decodeURIComponent(url.pathname);
const match = this.router.route(path);
if (match.id === MISSING) return res.missing();
const route = this.router.routeForID(match.id).spec;
const rawCookies = req.headers["cookie"] || req.headers["set-cookie"]?.join(";");
const cookies = rawCookies ? parseCoookies(rawCookies) : {};
const query = parseSearchParams(url.searchParams);
const origMethod = req.method.toLowerCase();
const method = this.methodAdapter(origMethod, {
req,
route,
match,
query,
cookies
});
if (method === "options" && !route.handlers.options) {
return res.noContent({
allow: Object.keys(route.handlers).map(upper).join(", ")
});
}
const ctx = this.augmentCtx({
// @ts-ignore
server: this,
logger: this.logger,
url,
req,
res,
path,
query,
cookies,
route,
match,
method,
origMethod
});
const handler = route.handlers[method];
if (handler) {
this.runHandler(handler, ctx);
} else {
res.notAllowed();
}
} catch (e) {
this.logger.warn(`error:`, req.url, e.message);
res.writeHead(500).end();
}
}
async runHandler({ fn, pre, post }, ctx) {
try {
let failed;
if (pre) {
for (let i = 0, n = pre.length; i < n; i++) {
const fn2 = pre[i];
if (fn2 && !await fn2(ctx)) {
ctx.res.end();
failed = i;
break;
}
}
}
if (failed === void 0) await fn(ctx);
if (post) {
for (let i = failed ?? post.length; --i >= 0; ) {
const fn2 = post[i];
if (fn2) await fn2(ctx);
}
}
ctx.res.end();
} catch (e) {
this.logger.warn(`handler error:`, e);
if (!ctx.res.headersSent) {
ctx.res.writeHead(500).end();
}
}
}
compileRoute(route) {
const compilePhase = (handler, phase) => {
let isPhaseUsed = false;
const $bind = (iceps) => (iceps ?? []).map((x) => {
if (x[phase]) {
isPhaseUsed = true;
return x[phase].bind(x);
}
});
const fns = [...$bind(this.opts.intercept)];
if (!isFunction(handler)) {
fns.push(...$bind(handler.intercept));
}
return isPhaseUsed ? fns : void 0;
};
const result = { ...route, handlers: {} };
for (let method in route.handlers) {
const handler = route.handlers[method];
result.handlers[method] = {
fn: isFunction(handler) ? handler : handler.fn,
pre: compilePhase(handler, "pre"),
post: compilePhase(handler, "post")
};
}
return result;
}
addRoutes(routes) {
for (let r of routes) {
this.logger.debug("registering route:", r.id, r.match);
}
this.router.addRoutes(routes.map(this.compileRoute.bind(this)));
this.logger.debug(this.router.index);
}
sendFile({ req, res }, path, headers, compress = false) {
const mime = headers?.["content-type"] ?? preferredTypeForPath(path);
const accept = req.headers["accept-encoding"];
const encoding = compress && accept ? [
{ mode: "br", tx: createBrotliCompress },
{ mode: "gzip", tx: createGzip },
{ mode: "deflate", tx: createDeflate }
].find((x) => accept.includes(x.mode)) : void 0;
return new Promise((resolve) => {
try {
this.logger.debug("sending file:", path, "mime:", mime, accept);
const src = createReadStream(path);
const mergedHeaders = { "content-type": mime, ...headers };
if (encoding) {
mergedHeaders["content-encoding"] = encoding.mode;
}
res.writeHead(200, mergedHeaders);
const finalize = (err) => {
if (err) res.end();
resolve();
};
encoding ? pipeline(src, encoding.tx(), res, finalize) : pipeline(src, res, finalize);
} catch (e) {
this.logger.warn(e.message);
res.missing();
resolve();
}
});
}
redirectToRoute(res, route) {
res.redirectTo(this.router.format(route));
}
}
const server = (opts) => new Server(opts);
class ServerResponse extends http.ServerResponse {
/**
* Writes a HTTP 204 header (plus given `headers`) and ends the response.
*
* @param headers
*/
noContent(headers) {
this.writeHead(204, headers).end();
}
/**
* Writes a HTTP 302 header to redirect to given URL, add given additional
* `headers` and ends the response.
*
* @remarks
* Also see {@link ServerResponse.seeOther}.
*
* @param headers
*/
redirectTo(location, headers) {
this.writeHead(302, { ...headers, location }).end();
}
/**
* Writes a HTTP 303 header to redirect to given URL, add given additional
* `headers` and ends the response.
*
* @remarks
* Also see {@link ServerResponse.redirectTo}.
*
* @param headers
*/
seeOther(location, headers) {
this.writeHead(303, { ...headers, location }).end();
}
/**
* Writes a HTTP 304 header (plus given `headers`) and ends the response.
*
* @param headers
*/
unmodified(headers) {
this.writeHead(304, headers).end();
}
/**
* Writes a HTTP 400 header (plus given `headers`) and ends the response
* (with optional `body`).
*
* @param headers
*/
badRequest(headers, body) {
this.writeHead(400, headers).end(body);
}
/**
* Writes a HTTP 401 header (plus given `headers`) and ends the response
* (with optional `body`).
*
* @param headers
*/
unauthorized(headers, body) {
this.writeHead(401, headers).end(body);
}
/**
* Writes a HTTP 403 header (plus given `headers`) and ends the response
* (with optional `body`).
*
* @param headers
*/
forbidden(headers, body) {
this.writeHead(403, headers).end(body);
}
/**
* Writes a HTTP 404 header (plus given `headers`) and ends the response
* (with optional `body`).
*
* @param headers
*/
missing(headers, body) {
this.writeHead(404, headers).end(body);
}
/**
* Writes a HTTP 405 header (plus given `headers`) and ends the response
* (with optional `body`).
*
* @param headers
*/
notAllowed(headers, body) {
this.writeHead(405, headers).end(body);
}
/**
* Writes a HTTP 406 header (plus given `headers`) and ends the response
* (with optional `body`).
*
* @param headers
*/
notAcceptable(headers, body) {
this.writeHead(406, headers).end(body);
}
/**
* Writes a HTTP 429 header (plus given `headers`) and ends the response
* (with optional `body`).
*
* @param headers
*/
rateLimit(headers, body) {
this.writeHead(429, headers).end(body);
}
/**
* HTTP 444. Indicates the server has returned no information to the client and closed
* the connection (useful as a deterrent for malware)
*/
noResponse() {
this.writeHead(444).end();
}
}
export {
Server,
ServerResponse,
server
};