UNPKG

@biblioteksentralen/cloud-run-core

Version:
447 lines (430 loc) 13.9 kB
// src/service/createService.ts import { randomUUID } from "node:crypto"; // src/service/createExpressApp.ts import express from "express"; import bodyParser from "body-parser"; // src/exceptions/AppError.ts var HttpCode = /* @__PURE__ */ ((HttpCode2) => { HttpCode2[HttpCode2["OK"] = 200] = "OK"; HttpCode2[HttpCode2["ACCEPTED"] = 202] = "ACCEPTED"; HttpCode2[HttpCode2["NO_CONTENT"] = 204] = "NO_CONTENT"; HttpCode2[HttpCode2["BAD_REQUEST"] = 400] = "BAD_REQUEST"; HttpCode2[HttpCode2["UNAUTHORIZED"] = 401] = "UNAUTHORIZED"; HttpCode2[HttpCode2["NOT_FOUND"] = 404] = "NOT_FOUND"; HttpCode2[HttpCode2["REQUEST_TIMEOUT"] = 408] = "REQUEST_TIMEOUT"; HttpCode2[HttpCode2["INTERNAL_SERVER_ERROR"] = 500] = "INTERNAL_SERVER_ERROR"; HttpCode2[HttpCode2["SERVICE_UNAVAILABLE"] = 503] = "SERVICE_UNAVAILABLE"; return HttpCode2; })(HttpCode || {}); var AppError = class extends Error { /** * HTTP code to be used when responding to the client. */ httpCode; /** * Whether the error is operational, i.e. whether it is safe to return the error message to the client. */ isOperational; constructor(message, options = {}) { super(message, { cause: options.cause }); Object.setPrototypeOf(this, new.target.prototype); this.name = this.constructor.name; this.httpCode = options.httpCode ?? 500 /* INTERNAL_SERVER_ERROR */; this.isOperational = options.isOperational ?? true; Error.captureStackTrace(this); } }; var ClientRequestError = class extends AppError { /** * Request body that caused the error */ request; /** * List of validation issues */ details; constructor(message, args = {}) { const { request, details, ...rest } = args; super(message, { httpCode: 400 /* BAD_REQUEST */, ...rest }); this.request = request; this.details = details; } }; var Unauthorized = class extends AppError { constructor(message, args = {}) { super(message, { httpCode: 401 /* UNAUTHORIZED */, ...args }); } }; var isAppError = (error) => error instanceof AppError; var isClientError = (error) => isAppError(error) && error.httpCode >= 400 /* BAD_REQUEST */ && error.httpCode < 500 /* INTERNAL_SERVER_ERROR */; // src/exceptions/assertIsError.ts function assertIsError(error) { if (!(error instanceof Error)) { throw new AppError(`Thrown error should be proper Error instances! Got: ${JSON.stringify(error)}`); } } // src/middleware/timeout.ts var createTimeoutMiddleware = (msecs) => (req, res, next) => { req.setTimeout(msecs, () => { next(new AppError("Request Timeout", { httpCode: 408 })); }); res.setTimeout(msecs, () => { next(new AppError("Service Timeout", { httpCode: 503 })); }); next(); }; // src/tracing.ts function parseTraceparent(traceparent) { const traceParts = traceparent?.split(/-/g) ?? []; return { traceId: traceParts[1], spanId: traceParts[2] }; } // src/logging.ts import { pino } from "pino"; var logLevels = ["fatal", "error", "warn", "info", "debug", "trace", "silent"]; var enablePinoPretty = process.stdout.isTTY && process.env.PINO_PRETTY !== "false"; var getLogLevel = () => { const rawLogLevel = process.env.LOG_LEVEL ?? "debug"; const logLevel2 = logLevels.find((level) => level === rawLogLevel); if (!logLevel2) { throw new Error(`Invalid LOG_LEVEL environment variable: ${rawLogLevel}. Must be one of: ${logLevels.join(", ")}`); } return logLevel2; }; var logLevel = getLogLevel(); var transport = enablePinoPretty ? { target: "pino-pretty", options: { colorize: true, messageKey: "message", levelKey: "severity" } } : void 0; var pinoConfig = { level: logLevel, transport, messageKey: "message", formatters: { /** * Changes the shape of the log level to match https://cloud.google.com/logging/docs/structured-logging */ level(severity) { return { severity }; } }, serializers: { err: pino.stdSerializers.errWithCause } }; var logger = pino(pinoConfig); // src/middleware/logging.ts var createPinoLoggerMiddleware = ({ projectId, options = {}, bindings = {} }) => (req, res, next) => { const { traceId, spanId } = parseTraceparent(req.get("traceparent")); const trace = projectId && traceId ? `projects/${projectId}/traces/${traceId}` : void 0; const traceBindings = trace ? { "logging.googleapis.com/trace": trace, "logging.googleapis.com/spanId": spanId } : {}; req.log = logger.child({ ...bindings, ...traceBindings }, options); next(); }; // src/middleware/sentry.ts import * as Sentry from "@sentry/node"; function configureSentry(router, options) { Sentry.init(options); router.use(sentryContextMiddleware); } var sentryContextMiddleware = (req, res, next) => { const { traceId } = parseTraceparent(req.get("traceparent")); if (traceId) Sentry.setTag("gcp_trace", traceId); const clientIdentifier = req.get("client-identifier"); if (clientIdentifier) Sentry.setTag("client_identifier", clientIdentifier); next(); }; // src/service/createExpressApp.ts var createExpressApp = ({ projectId, instanceId, logOptions = {}, logBindings = {}, parseBody = true, requestLimit = "512mb", timeoutMsecs = 36e5, sentry: sentryOptions }) => { const pinoLoggerMiddleware = createPinoLoggerMiddleware({ projectId, options: logOptions, bindings: { ...logBindings, instanceId } }); const app = express(); app.enable("trust proxy"); app.disable("x-powered-by"); app.disable("etag"); if (sentryOptions?.dsn) { configureSentry(app, sentryOptions); } app.use(pinoLoggerMiddleware); if (timeoutMsecs) { app.use(createTimeoutMiddleware(timeoutMsecs)); } if (parseBody) { app.use(bodyParser.json({ limit: requestLimit })); app.use(bodyParser.urlencoded({ limit: requestLimit, extended: true })); } app.use("/favicon.ico|/robots.txt", (req, res) => { res.status(404).send(null); }); return app; }; // src/service/CloudRunService.ts import * as Sentry2 from "@sentry/node"; // src/exceptions/errorHandler.ts var notFoundHandler = (req, res) => { res.status(404).send(JSON.stringify({ error: "Route not found" })); }; var hasType = (err) => !!err && typeof err === "object" && "type" in err && typeof err.type === "string"; var hasBody = (err) => !!err && typeof err === "object" && "body" in err && typeof err.body === "string"; var errorHandler = (err, req, res, next) => { if (res?.headersSent) { return next(err); } if (isAppError(err) && err.isOperational) { if (isClientError(err)) { req.log.info(err); } else { req.log.error(err); } if (res) { const { traceId } = parseTraceparent(req.get("traceparent")); res.status(err.httpCode).json({ error: err.message, details: err instanceof ClientRequestError ? err.details : void 0, traceId }); } return; } if (hasType(err) && err.type === "entity.parse.failed") { req.log.info(err); if (res) { const { traceId } = parseTraceparent(req.get("traceparent")); res.status(400).json({ error: String(err), details: hasBody(err) ? { body: err.body } : void 0, traceId }); } } req.log.error(err); if (res) { const { traceId } = parseTraceparent(req.get("traceparent")); res.status(500 /* INTERNAL_SERVER_ERROR */).json({ error: "Det oppsto en ukjent feil. Feilen er logget.", traceId }); } req.log.fatal( { err }, `Cloud-run-core error handler received unknown error. Restarting process in case the error is related to state.` ); process.exit(1); }; // src/service/CloudRunService.ts import { createHttpTerminator } from "http-terminator"; import { TypedEmitter } from "tiny-typed-emitter"; var CloudRunService = class extends TypedEmitter { name; instanceId; router; port; logger; useSentry; httpTerminator = void 0; state = "initialized"; constructor({ router, logger: logger2, port, name, instanceId, useSentry }) { super(); this.router = router; this.logger = logger2; this.port = port; this.name = name; this.instanceId = instanceId; this.useSentry = useSentry; } async start() { if (this.state !== "initialized") { throw new Error(`Cannot start ${this.name} instance because it is already started.`); } this.router.use(notFoundHandler); if (this.useSentry) { this.router.use(Sentry2.Handlers.errorHandler()); } this.router.use(errorHandler); return new Promise((resolve) => { const server = this.router.listen(this.port, () => { this.logger.info(`Started ${this.name} instance ${this.instanceId}, listening on port ${this.port}`); this.state = "started"; resolve(); }); this.httpTerminator = createHttpTerminator({ server, gracefulTerminationTimeout: 5e3 }); }); } async stop() { this.logger.info(`Stopping service ${this.name} instance ${this.instanceId}.`); if (this.state === "stopped") { this.logger.warn(`${this.name} instance is already in state ${this.state}. Forcing shutdown`); this.emit("shutdown"); } else { if (this.httpTerminator) { await this.httpTerminator.terminate(); } this.emit("shutdown"); this.state = "stopped"; } } }; // src/service/createService.ts var getPort = (options = {}) => { if (options.port) { return options.port; } const envPort = process.env.PORT ? Number(process.env.PORT) : void 0; if (envPort && !Number.isNaN(envPort)) { return envPort; } return 8080; }; var createService = (name, options = {}) => { const serviceName = process.env.K_REVISION ?? name; const instanceId = randomUUID(); const serviceLogger = logger.child({ instanceId }); const port = getPort(options); const handleProcessEvents = options.handleProcessEvents ?? true; const router = createExpressApp({ ...options, instanceId }); const service = new CloudRunService({ router, logger: serviceLogger, port, name: serviceName, instanceId, useSentry: !!options.sentry?.dsn }); if (handleProcessEvents) { process.on("SIGTERM", () => void service.stop()); process.on("SIGINT", () => void service.stop()); process.on("uncaughtException", (err) => { serviceLogger.fatal({ err }, `Exited with uncaught exception: ${err?.message}`); process.exit(1); }); } return service; }; // src/service/createRouter.ts import express2 from "express"; var createRouter = (options) => express2.Router(options); // src/pubsub.ts function parsePubSubMessage(req) { const rawMessage = JSON.stringify(req.body); try { const { message, deliveryAttempt } = parseReceivedMessage(req.body); req.log.debug( { rawMessage }, `Received Pub/Sub message ${message.messageId} ${deliveryAttempt ? `(delivery attempt ${deliveryAttempt})` : ""}` ); const { data, ...metadata } = message; return { data: Buffer.from(data, "base64").toString(), metadata: { deliveryAttempt, ...metadata } }; } catch (err) { req.log.error({ err, rawMessage }, "Received invalid Pub/Sub request"); throw new AppError(err instanceof Error ? err.message : "Failed to parse Pub/Sub message", { httpCode: 202 // since there is no point of Pub/Sub retrying an invalid message. }); } } var isRecord = (value) => typeof value === "object" && value !== null; var isString = (value) => typeof value === "string"; var isNumber = (value) => typeof value === "number"; var isUndefined = (value) => typeof value === "undefined"; var parseReceivedMessage = (body) => { if (!isRecord(body)) { throw new Error("Invalid Pub/Sub message: missing body"); } const { message, subscription, deliveryAttempt } = body; if (!isRecord(message)) { throw new Error("Invalid Pub/Sub message: missing 'message'"); } if (!isString(subscription)) { throw new Error("Invalid Pub/Sub message: missing 'subscription'"); } if (!isNumber(deliveryAttempt) && !isUndefined(deliveryAttempt)) { throw new Error(`Invalid Pub/Sub message: invalid 'deliveryAttempt' type: '${typeof deliveryAttempt}'`); } return { message: parseMessage(message), subscription, deliveryAttempt }; }; var parseMessage = (message) => { const { messageId, data, publishTime, attributes, orderingKey } = message; if (!isString(messageId)) { throw new Error("Invalid Pub/Sub message: missing 'message.messageId'"); } if (!isString(data)) { throw new Error("Invalid Pub/Sub message: missing 'message.data'"); } if (!isString(publishTime) && !isUndefined(publishTime)) { throw new Error(`Invalid Pub/Sub message: invalid 'message.publishTime' type: '${typeof publishTime}'`); } if (!isRecord(attributes) && !isUndefined(attributes)) { throw new Error(`Invalid Pub/Sub message: invalid 'message.attributes' type: '${typeof attributes}'`); } if (!isString(orderingKey) && !isUndefined(orderingKey)) { throw new Error(`Invalid Pub/Sub message: invalid 'message.orderingKey' type: '${typeof orderingKey}'`); } return { messageId, data, publishTime, orderingKey }; }; // src/util/getClientIpAddress.ts var getClientIpAddress = (req) => typeof req.headers["x-forwarded-for"] === "string" ? req.headers["x-forwarded-for"].split(",").slice(-2)[0] : void 0; // src/util/isCloudRunEnvironment.ts var isCloudRunEnvironment = () => !!process.env.CLOUD_RUN_EXECUTION || // => Cloud Run Job !!process.env.K_REVISION; export { AppError, ClientRequestError, HttpCode, Unauthorized, assertIsError, createPinoLoggerMiddleware, createRouter, createService, getClientIpAddress, isAppError, isCloudRunEnvironment, logger, parsePubSubMessage, parseTraceparent }; //# sourceMappingURL=index.js.map