@biblioteksentralen/cloud-run-core
Version:
Core package for NodeJS services using Cloud Run
447 lines (430 loc) • 13.9 kB
JavaScript
// 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