UNPKG

@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
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 };