UNPKG

@trifrost/core

Version:

Blazingly fast, runtime-agnostic server framework for modern edge and node environments

842 lines (841 loc) 27.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Context = exports.IP_HEADER_CANDIDATES = void 0; const object_1 = require("@valkyriestudios/utils/object"); const string_1 = require("@valkyriestudios/utils/string"); const Cookies_1 = require("./modules/Cookies"); const nonce_1 = require("./modules/JSX/ctx/nonce"); const render_1 = require("./modules/JSX/render"); const CacheControl_1 = require("./middleware/CacheControl"); const constants_1 = require("./types/constants"); const Http_1 = require("./utils/Http"); const Generic_1 = require("./utils/Generic"); const RGX_IP = /^(?:\d{1,3}\.){3}\d{1,3}$|^(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}$/; const RGX_URL = /^(https?:\/\/)[^\s/$.?#].[^\s]*$/i; /** * Used to get ip from headers under a trusted proxy, take note that this array will * be re-ordered automatically. */ exports.IP_HEADER_CANDIDATES = [ 'x-client-ip', 'x-forwarded-for', 'cf-connecting-ip', 'fastly-client-ip', 'true-client-ip', 'x-real-ip', 'x-cluster-client-ip', 'x-forwarded', 'forwarded-for', 'forwarded', 'x-appengine-user-ip', ]; // eslint-disable-next-line prettier/prettier class Context { /** * MARK: Private */ /* Computed IP Address, see ip getter */ #ip = undefined; /* TriFrost State */ #state; /* TriFrost Name */ #name = 'unknown'; /* TriFrost Host */ #host = null; /* TriFrost Domain */ #domain = undefined; /* TriFrost Nonce */ #nonce = null; /* Kind of Context */ #kind = 'std'; /* Cache (see cache getter) */ #cache = null; /* TriFrost Route Query. We compute this on an as-needed basis */ #query = null; /* TriFrost logger instance */ #logger; /* Timeout */ #timeout = null; /* Timeout Id */ #timeout_id = null; /* Hooks to be executed after the context has finished */ #after = []; /** * MARK: Protected */ /* TriFrost Context Config */ ctx_config; /* TriFrost Request */ req_config; /* TriFrost Request Id (take note: this CAN be different from the traceId used in logger, this is the inbound request id) */ req_id = null; /* TriFrost Request body */ req_body = null; /* Whether or not the context is initialized */ is_initialized = false; /* Whether or not the context is done/finished and should not be written to anymore */ is_done = false; /* Whether or not the context was aborted and should not be written to anymore */ is_aborted = false; /* Response Headers */ res_headers = {}; /* Response Code (for usage in runtimes working with numerical response codes) */ res_code = 200; /* Response Body */ res_body = null; /* TriFrost Cookies. We compute this on an as-needed basis */ $cookies = null; /** * MARK: Constructor */ constructor(logger, cfg, req) { this.ctx_config = cfg; this.req_config = req; /* Determine request id for logger */ const ridConfig = cfg.requestId; if (ridConfig) { for (let i = 0; i < ridConfig.inbound.length; i++) { const val = req.headers[ridConfig.inbound[i]]; if (typeof val === 'string' && (!ridConfig.validate || ridConfig.validate(val))) { this.req_id = val; break; } } } if (!this.req_id) this.req_id = (0, Generic_1.hexId)(16); /* Instantiate logger */ this.#logger = logger.spawn({ traceId: this.req_id, env: cfg.env, }); } /** * MARK: Getters */ /** * Whether or not the context was initialized */ get isInitialized() { return this.is_initialized; } /** * Whether or not the response was finished */ get isDone() { return this.is_done; } /** * Whether or not the request was aborted */ get isAborted() { return this.is_aborted; } /** * Whether or not the request is in a locked state and can not be written to anymore */ get isLocked() { return this.is_done || this.is_aborted; } /** * Returns the TriFrost environment */ get env() { return this.ctx_config.env; } /** * Returns the method for the context */ get method() { return this.req_config.method; } /** * Returns the name of the route the context is for (defaults to registration path) */ get name() { return this.#name; } /** * Kind of context: This denotes the purpose of the context. * - 'notfound': This context is being run for a notfound catchall * - 'health': This context is being run on a route specifically meant for health checks * - 'std': General context, run everything :) * - 'options': Options run */ get kind() { return this.#kind; } /** * Returns the path for the context */ get path() { return this.req_config.path; } /** * Returns the host of the context. */ get host() { if (this.#host) return this.#host; this.#host = this.getHostFromHeaders() ?? (0, Generic_1.determineHost)(this.ctx_config.env); return this.#host; } /** * Returns the domain of the context (extracted from host) */ get domain() { if (this.#domain !== undefined) return this.#domain; this.#domain = (0, Http_1.extractDomainFromHost)(this.host); return this.#domain; } /** * Returns the ip address of the request for the context */ get ip() { if (this.#ip !== undefined) return this.#ip; let val = this.getIPFromHeaders(); if (!val) { val = this.getIP(); if (val && !RGX_IP.test(val)) val = null; } this.#ip = val; return val; } /** * Request ID */ get requestId() { return this.req_id; } /** * Request Query parameters */ get query() { if (!this.#query) this.#query = new URLSearchParams(this.req_config.query); return this.#query; } /** * Cache Instance */ get cache() { if (!this.#cache) { const resolved = this.ctx_config.cache.resolve(this); this.#cache = resolved['spawn'](this); } return this.#cache; } /** * Cookies for context */ get cookies() { if (!this.$cookies) this.$cookies = new Cookies_1.Cookies(this, this.ctx_config.cookies); return this.$cookies; } /** * Logger */ get logger() { return this.#logger; } /** * Request Headers */ get headers() { return this.req_config.headers; } /** * Current set response headers */ get resHeaders() { return { ...this.res_headers }; } /** * Request Body */ get body() { return this.req_body || {}; } /** * Security nonce */ get nonce() { if (this.#nonce) return this.#nonce; /* Check state nonce */ if (typeof this.state?.nonce === 'string') { this.#nonce = this.state?.nonce; return this.#nonce; } /* Fall back to using request id */ this.#nonce = btoa(this.requestId); return this.#nonce; } /** * Internal State */ get state() { return this.#state; } /** * Returns the response code for the context */ get statusCode() { return this.res_code; } /** * Returns the currently configured timeout value */ get timeout() { return this.#timeout; } /** * Returns the currently registered after hooks */ get afterHooks() { return this.#after; } /** * MARK: State Mgmt */ /** * Expands the state and sets values */ setState(patch) { this.#state = { ...this.#state, ...patch }; return this; } /** * Remove a set of keys from the state */ delState(keys) { /* Delete each key from the copy */ for (let i = 0; i < keys.length; i++) delete this.#state[keys[i]]; return this; } /** * MARK: Timeouts */ /** * Sets the timeout */ setTimeout(val) { if (Number.isInteger(val) && val > 0) { this.clearTimeout(); this.#timeout = val; this.#timeout_id = setTimeout(() => { this.#timeout_id = null; this.#logger.error('Request timed out'); this.abort(408); }, val); } else if (val === null) { this.clearTimeout(); } else { this.#logger.error('Context@setTimeout: Expects a value above 0 or null', { val }); } } /** * Clears the existing timeout */ clearTimeout() { if (this.#timeout_id) clearTimeout(this.#timeout_id); this.#timeout = null; this.#timeout_id = null; } /** * MARK: Headers */ /** * Set a header as part of the response to be returned to the callee * * Example: * ctx.setHeader('Content-Type', 'application/json'); */ setHeader(key, val) { this.res_headers[String(key).toLowerCase()] = String(val); } /** * Sets multiple headers at once as part of the response to be returned to the callee * * Example: * ctx.setHeader('Content-Type', 'application/json'); */ setHeaders(obj) { for (const key in obj) this.res_headers[String(key).toLowerCase()] = String(obj[key]); } /** * Remove a header that was previously set as part of the response to be returned to the callee * * Example: * ctx.delHeader('Content-Type'); */ delHeader(key) { delete this.res_headers[String(key).toLowerCase()]; } /** * Remove multiple headers from the response * * Example: * ctx.delHeader('Content-Type'); */ delHeaders(keys) { for (let i = 0; i < keys.length; i++) { delete this.res_headers[String(keys[i]).toLowerCase()]; } } /** * Alias for setHeader('Content-Type', ...) with built-in safety for internally known mime types * * Example: * ctx.setType('text/html') */ setType(val) { if (!constants_1.MimeTypesSet.has(val)) return; this.res_headers['content-type'] = val; } /** * MARK: Status */ /** * Sets the response status code to a known HTTP status code */ setStatus(status) { if (!(status in constants_1.HttpCodeToStatus)) throw new Error('Context@setStatus: Invalid status code ' + status); /* Patch logger attributes to reflect status for observability */ if (status !== this.res_code) { this.#logger.setAttributes({ 'http.status_code': status, 'otel.status_code': status >= 500 ? 'ERROR' : 'OK', }); } this.res_code = status; } /** * MARK: Body */ /** * Sets the body of the response to be returned to the callee */ setBody(value) { if (typeof value === 'string') { this.res_body = value; } else if (value === null) { this.res_body = null; } } /** * MARK: LifeCycle */ /** * Initializes the request body and parses it into Json or FormData depending on its type */ async init(match, handler) { try { /* No need to do anything if already initialized */ if (this.is_initialized) return; /* Set is_initialized to true to ensure no further calls to init can happen */ this.is_initialized = true; /* Set params as baseline state */ this.#state = match.params; /* Set name */ this.#name = match.route.name; /* Set kind */ this.#kind = match.route.kind; /* If we have a method that allows writing to we need to load up the body from the request */ switch (this.req_config.method) { case constants_1.HttpMethods.POST: case constants_1.HttpMethods.PATCH: case constants_1.HttpMethods.PUT: case constants_1.HttpMethods.DELETE: { const body = await handler(match.route.bodyParser); if (body === null) { this.setStatus(413); } else { this.req_body = body; } break; } default: break; } } catch (err) { this.#logger.error(err); this.status(400); } } /** * Runs a fetch request and automatically appends the request id as well as spans. * * @param {string|URL} input * @param {RequestInit} init */ async fetch(input, init = {}) { const url = typeof input === 'string' ? input : input.toString(); const method = init?.method || 'GET'; return this.#logger.span(`fetch ${method} ${url}`, async () => { /* Inject trace ID into headers */ if (this.ctx_config.requestId?.outbound) { const headers = new Headers(init.headers || {}); headers.set(this.ctx_config.requestId.outbound, this.#logger.traceId); init.headers = headers; } try { const res = await globalThis.fetch(input, init); this.#logger.setAttributes({ 'http.method': method, 'http.url': url, 'http.status_code': res.status, 'otel.status_code': res.status >= 500 ? 'ERROR' : 'OK', 'span.kind': 'client', }); return res; } catch (err) { this.#logger.setAttributes({ 'http.method': method, 'http.url': url, 'otel.status_code': 'ERROR', }); this.#logger.error(err); throw err; } }); } /** * Abort the request * * @param {HttpStatusCode?} status - Status to abort with (defaults to 503) */ abort(status) { if (this.is_aborted) return; this.#logger.debug('Context@abort: Aborting request'); /* Set aborted to ensure nobody else writes data */ this.is_aborted = true; /* Set status, fallback to service-unavailable if not provided */ this.setStatus(status || 503); /* Clear timeout */ this.clearTimeout(); } /** * End the request and respond to callee */ end() { /* Set done to ensure nobody else writes data */ this.is_done = true; /* Clear timeout */ this.clearTimeout(); } /** * Register an after hook */ addAfter(fn) { if (typeof fn !== 'function') return; this.#after.push(fn); } /** * MARK: Response */ /** * Render a JSX body to a string */ async render(body, opts) { return (0, Generic_1.prependDocType)((0, render_1.rootRender)(this, body, (0, object_1.isObject)(opts) ? { ...this.ctx_config, ...opts } : this.ctx_config)); } /** * Respond with a file */ async file(input, opts) { try { if (this.isLocked) throw new Error('Context@file: Cannot modify a finalized response'); /* Cache Control */ if (opts?.cacheControl) (0, CacheControl_1.ParseAndApplyCacheControl)(this, opts.cacheControl); let stream; let size = null; let name; if ((0, string_1.isNeString)(input)) { /* Get a streamable */ const result = await this.getStream(input); if (!result) return this.status(404); stream = result.stream; size = result.size; name = input.split('/').pop(); } else if ((0, object_1.isObject)(input) && input.stream) { if (!(0, string_1.isNeString)(input.name)) throw new Error('Context@file: name is required when passing a stream'); stream = input.stream; size = input.size ?? null; name = input.name; } else { throw new Error('Context@file: Invalid Payload'); } /* Try determining the mime type from the name if no mime type was set already */ if (!this.res_headers['content-type']) { const mime = constants_1.ExtensionToMimeType.get(name.split('.').pop()); if (mime) this.res_headers['content-type'] = mime; } /** * Set Content-Disposition header depending on download option * @note As per RFC 6266 we make use of filename* with UTF-8 */ const download = opts?.download === true ? (0, Http_1.encodeFilename)(name) : typeof opts?.download === 'string' ? (0, Http_1.encodeFilename)(opts.download) : null; if (download) { this.res_headers['content-disposition'] = download.ascii.length ? 'attachment; filename="' + download.ascii + "\"; filename*=UTF-8''" + download.encoded : 'attachment; filename="download"; filename*=UTF-8\'\'' + download.encoded; } /* Pass the stream to the runtime-specific stream method */ this.stream(stream, size); } catch (err) { this.#logger.error(err, { input, opts }); } } /** * Respond with HTML */ async html(body = '', opts) { try { /* Ensure we dont double write */ if (this.isLocked) throw new Error('Context@html: Cannot modify a finalized response'); /* Cache Control */ if (opts?.cacheControl) (0, CacheControl_1.ParseAndApplyCacheControl)(this, opts.cacheControl); /* Set mime type if no mime type was set already */ if (!this.res_headers['content-type']) this.res_headers['content-type'] = constants_1.MimeTypes.HTML; /* Render html */ let html = typeof body === 'string' ? body : await this.render(body, this.ctx_config); /* Auto-prepend <!DOCTYPE html> if starts with <html */ html = (0, Generic_1.prependDocType)(html.trimStart()); /** * If html starts with doctype we know its a full page render * - full page: set tfnonce cookie and add tfnonce script for clientside usage * - partial page: swap out nonce usage with cookie nonce to ensure compliance with used values */ const csp = this.res_headers['content-security-policy']; if (csp && csp.indexOf('nonce') > 0) { if (html.startsWith('<!DOCTYPE')) { this.cookies.set(nonce_1.NONCEMARKER, this.nonce, { httponly: true, secure: true, maxage: 86400, samesite: 'Lax', }); html = (0, Generic_1.injectBefore)(html, (0, nonce_1.NONCE_WIN_SCRIPT)(this.nonce), ['</head>', '</body>', '</html>']); } else { const cookieNonce = this.cookies.get(nonce_1.NONCEMARKER); if (cookieNonce) { html = html.replace(/nonce="[^"]+"/g, 'nonce="' + cookieNonce + '"'); this.res_headers['content-security-policy'] = csp.replace(/'nonce-[^']*'/g, "'nonce-" + cookieNonce + "'"); } } } this.res_body = html; /* Set status if provided */ this.setStatus(opts?.status || this.res_code); this.end(); } catch (err) { this.#logger.error(err, { body, opts }); } } /** * Respond with JSON */ json(body = {}, opts) { try { /* Ensure we dont double write */ if (this.isLocked) throw new Error('Context@json: Cannot modify a finalized response'); /* Run sanity check on body payload */ if (Object.prototype.toString.call(body) !== '[object Object]' && !Array.isArray(body)) throw new Error('Context@json: Invalid Payload'); /* Cache Control */ if (opts?.cacheControl) (0, CacheControl_1.ParseAndApplyCacheControl)(this, opts.cacheControl); /* Set mime type if no mime type was set already */ if (!this.res_headers['content-type']) this.res_headers['content-type'] = constants_1.MimeTypes.JSON; this.res_body = JSON.stringify(body); /* Set status if provided */ this.setStatus(opts?.status || this.res_code); this.end(); } catch (err) { this.#logger.error(err, { body, opts }); } } /** * Respond with a status and no body */ status(status) { try { /* Ensure we dont double write */ if (this.isLocked) throw new Error('Context@status: Cannot modify a finalized response'); this.res_body = null; this.setStatus(status); this.end(); } catch (err) { this.#logger.error(err, { status }); } } /** * Respond with plain text */ text(body, opts) { try { if (typeof body !== 'string') throw new Error('Context@text: Invalid Payload'); /* Ensure we dont double write */ if (this.isLocked) throw new Error('Context@text: Cannot modify a finalized response'); /* Cache Control */ if (opts?.cacheControl) (0, CacheControl_1.ParseAndApplyCacheControl)(this, opts.cacheControl); /* Set mime type if no mime type was set already */ if (!this.res_headers['content-type']) this.res_headers['content-type'] = constants_1.MimeTypes.TEXT; this.res_body = body; /* Set status if provided */ this.setStatus(opts?.status || this.res_code); this.end(); } catch (err) { this.#logger.error(err, { body, opts }); } } /** * Respond by redirecting * * @note Default status is 303 See Other * @note Default keep_query is true */ redirect(to, opts) { try { if (typeof to !== 'string' || (opts?.status && !(opts.status in constants_1.HttpRedirectStatusesToCode))) throw new Error('Context@redirect: Invalid Payload'); /* Ensure we dont double write */ if (this.isLocked) throw new Error('Context@redirect: Cannot modify a finalized response'); let url = to.trim(); /* If not absolute or protocol-relative, and not root-relative, prepend host */ const is_absolute = RGX_URL.test(url); const is_relative = url.startsWith('/'); const is_proto_relative = url.startsWith('//'); /* If the url is not fully qualified prepend the protocol and host */ if (!is_absolute && !is_relative && !is_proto_relative) { const host = this.host; if (host === '0.0.0.0') throw new Error('Context@redirect: Unable to determine host'); const normalized = host.startsWith('http://') ? 'https://' + host.slice(7) : host.startsWith('http') ? host // eslint-disable-line prettier/prettier : 'https://' + host; // eslint-disable-line prettier/prettier url = normalized.replace(/\/+$/, '') + '/' + url.replace(/^\/+/, ''); } /* If keep_query is passed as true and a query exists add it to normalized to */ if (this.query.size && opts?.keep_query !== false) { const prefix = url.indexOf('?') >= 0 ? '&' : '?'; url += prefix + this.query.toString(); } /* This is a redirect, as such a body should not be present */ this.res_body = null; this.res_headers.location = url; this.setStatus(opts?.status ?? 303); this.end(); } catch (err) { this.#logger.error(err, { to, opts }); } } /** * MARK: Protected */ /** * If trustProxy is true tries to compute the IP from well-known headers */ getIPFromHeaders() { if (this.ctx_config.trustProxy !== true) return null; const headers = this.headers; for (let i = 0; i < exports.IP_HEADER_CANDIDATES.length; i++) { const name = exports.IP_HEADER_CANDIDATES[i]; let val = headers[name]; if (typeof val !== 'string') continue; val = val.trim(); if (!val.length) continue; const candidate = name === 'x-forwarded-for' ? val.split(',', 1)[0]?.trim() : val; if (!candidate || !RGX_IP.test(candidate)) continue; /* Promote to front of the array for next call */ if (i !== 0) { exports.IP_HEADER_CANDIDATES.splice(i, 1); exports.IP_HEADER_CANDIDATES.unshift(name); } return candidate; } return null; } /** * If trustProxy is true tries to compute the Host from well-known headers */ getHostFromHeaders() { if (this.ctx_config.trustProxy !== true) return null; const headers = this.headers; if ((0, string_1.isNeString)(headers['x-forwarded-host'])) return headers['x-forwarded-host'].trim(); const forwarded = this.headers['forwarded']; if ((0, string_1.isNeString)(forwarded)) { const m = forwarded.match(/host=([^;]+)/i); if (m) return m[1].trim(); } return (0, string_1.isNeString)(headers.host) ? headers.host.trim() : null; } /** * Stream a response from a streamlike value */ stream(stream, size) { if (this.isLocked) return; /* Lock the context to ensure no other responding can happen as we stream */ this.is_done = true; /* Add Content-Length to headers */ if (Number.isInteger(size) && size > 0) this.res_headers['content-length'] = '' + size; /* Clear timeout */ this.clearTimeout(); } } exports.Context = Context;