UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

161 lines • 7.49 kB
import bearerAuthPlugin from "@fastify/bearer-auth"; import { fastifyCors } from "@fastify/cors"; import { errorCodes, fastify } from "fastify"; import { parse as parseQueryString } from "qs"; import { addSszContentTypeParser } from "@lodestar/api/server"; import { ErrorAborted } from "@lodestar/utils"; import { isLocalhostIP } from "../../util/ip.js"; import { ApiError, IndexedError, NodeIsSyncing } from "../impl/errors.js"; import { HttpActiveSocketsTracker } from "./activeSockets.js"; /** * Error code used by Fastify if media type is not supported (415) */ const INVALID_MEDIA_TYPE_CODE = errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE().code; /** * Error code used by Fastify if JSON schema validation failed */ const SCHEMA_VALIDATION_ERROR_CODE = errorCodes.FST_ERR_VALIDATION().code; /** * REST API powered by `fastify` server. */ export class RestApiServer { opts; server; logger; activeSockets; constructor(opts, modules) { this.opts = opts; // Apply opts defaults const { logger, metrics } = modules; const server = fastify({ logger: false, ajv: { customOptions: { coerceTypes: "array" } }, routerOptions: { querystringParser: (str) => parseQueryString(str, { // Array as comma-separated values must be supported to be OpenAPI spec compliant comma: true, // Drop support for array query strings like `id[0]=1&id[1]=2&id[2]=3` as those are not required to // be OpenAPI spec compliant and results are inconsistent, see https://github.com/ljharb/qs/issues/331. // The schema validation will catch this and throw an error as parsed query string results in an object. parseArrays: false, }), }, bodyLimit: opts.bodyLimit, http: { maxHeaderSize: opts.headerLimit }, }); addSszContentTypeParser(server); this.activeSockets = new HttpActiveSocketsTracker(server.server, metrics); // To parse our ApiError -> statusCode server.setErrorHandler((err, _req, res) => { const stacktraces = opts.stacktraces ? err.stack?.split("\n") : undefined; if ("validation" in err && err.validation) { const { instancePath = "unknown", message } = err.validation?.[0] ?? {}; const payload = { code: 400, message: `${instancePath.substring(instancePath.lastIndexOf("/") + 1)} ${message}`, stacktraces, }; void res.status(400).send(payload); } else if (err instanceof IndexedError) { const payload = { code: err.statusCode, message: err.message, failures: err.failures, stacktraces, }; void res.status(err.statusCode).send(payload); } else { // Convert our custom ApiError into status code const statusCode = err instanceof ApiError ? err.statusCode : 500; const payload = { code: statusCode, message: err.message, stacktraces }; void res.status(statusCode).send(payload); } }); server.setNotFoundHandler((req, res) => { const message = `Route ${req.raw.method}:${req.raw.url} not found`; this.logger.warn(message); const payload = { code: 404, message }; void res.code(404).send(payload); }); if (opts.cors) { void server.register(fastifyCors, { origin: opts.cors }); } if (opts.bearerToken) { void server.register(bearerAuthPlugin, { keys: new Set([opts.bearerToken]) }); } // Log all incoming request to debug (before parsing). TODO: Should we hook latter in the lifecycle? https://www.fastify.io/docs/latest/Lifecycle/ // Note: Must be an async method so fastify can continue the release lifecycle. Otherwise we must call done() or the request stalls server.addHook("onRequest", async (req, _res) => { const operationId = getOperationId(req); this.logger.debug(`Req ${req.id} ${req.ip} ${operationId}`); metrics?.requests.inc({ operationId }); }); server.addHook("preHandler", async (req, _res) => { const operationId = getOperationId(req); this.logger.debug(`Exec ${req.id} ${req.ip} ${operationId}`); }); // Log after response server.addHook("onResponse", async (req, res) => { const operationId = getOperationId(req); this.logger.debug(`Res ${req.id} ${operationId} - ${res.raw.statusCode}`); metrics?.responseTime.observe({ operationId }, res.elapsedTime / 1000); }); server.addHook("onError", async (req, _res, err) => { // Don't log ErrorAborted errors, they happen on node shutdown and are not useful // Don't log NodeISSyncing errors, they happen very frequently while syncing and the validator polls duties if (err instanceof ErrorAborted || err instanceof NodeIsSyncing) return; const operationId = getOperationId(req); if (err instanceof ApiError || [INVALID_MEDIA_TYPE_CODE, SCHEMA_VALIDATION_ERROR_CODE].includes(err.code)) { this.logger.warn(`Req ${req.id} ${operationId} failed`, { reason: err.message }); } else { this.logger.error(`Req ${req.id} ${operationId} error`, {}, err); } metrics?.errors.inc({ operationId }); }); this.server = server; this.logger = logger; } /** * Start the REST API server. */ async listen() { try { const host = this.opts.address; await this.server.listen({ port: this.opts.port, host }); const { address, port } = this.server.addresses()[0]; this.logger.info("Started REST API server", { address: `http://${address}:${port}` }); if (!host || !isLocalhostIP(host)) { this.logger.warn("REST API server is exposed, ensure untrusted traffic cannot reach this API"); } } catch (e) { this.logger.error("Error starting REST api server", this.opts, e); throw e; } } /** * Close the server instance and terminate all existing connections. */ async close() { // In NodeJS land calling close() only causes new connections to be rejected. // Existing connections can prevent .close() from resolving for potentially forever. // In Lodestar case when the BeaconNode wants to close we will attempt to gracefully // close all existing connections but forcefully terminate after timeout for a fast shutdown. // Inspired by https://github.com/gajus/http-terminator/ await this.activeSockets.terminate(); await this.server.close(); this.logger.debug("REST API server closed"); } /** For child classes to override */ shouldIgnoreError(_err) { return false; } } function getOperationId(req) { return req.routeOptions.schema?.operationId ?? "unknown"; } //# sourceMappingURL=base.js.map