@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
161 lines • 7.49 kB
JavaScript
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