Files
triggershell/server.ts
T

126 lines
4.5 KiB
TypeScript
Raw Permalink Normal View History

2026-08-15 18:37:30 +02:00
import "./src/bootstrap/async-local-storage-polyfill";
2026-08-19 09:54:16 +02:00
import { randomUUID } from "node:crypto";
2026-08-15 18:37:30 +02:00
import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
2026-08-15 18:37:30 +02:00
import next from "next";
2026-08-19 09:54:16 +02:00
import type { Level } from "pino";
2026-08-15 18:37:30 +02:00
import { WebSocketServer } from "ws";
import { getConfig } from "./src/lib/config/load";
import { migrateOnBoot } from "./src/lib/db/client";
import { syncAuthFromConfig } from "./src/lib/auth/sync";
import { reconcileOrphanedRuns } from "./src/lib/runner/engine";
import { killAllRuns } from "./src/lib/runner/registry";
import { attachWsServer, authenticateUpgrade } from "./src/lib/ws/server";
2026-08-19 09:54:16 +02:00
import { logger } from "./src/lib/logger";
const log = logger.child({ mod: "server" });
2026-08-15 18:37:30 +02:00
const dev = process.env.NODE_ENV !== "production";
const { config } = getConfig();
const port = Number(process.env.PORT ?? config.server.port);
const hostname = process.env.HOST ?? config.server.host;
migrateOnBoot();
syncAuthFromConfig();
reconcileOrphanedRuns();
// `dir` must be this file's own directory, not `process.cwd()` - when launched by the installed
// `triggershell` CLI, the working directory is wherever the user's config lives, not the package.
const dir = path.dirname(fileURLToPath(import.meta.url));
// Exposed via `process.env` (not just the local `dir` const) so Route Handlers/Server Components -
// compiled through Next's own module graph, separate from this file's - can find it too. See
// `docs.ts`'s use of this and the `globalThis` comment in `runner/events.ts` for the same reasoning.
process.env.TRIGGERSHELL_APP_ROOT = dir;
const app = next({ dev, dir, hostname, port });
2026-08-15 18:37:30 +02:00
const handle = app.getRequestHandler();
2026-08-19 09:54:16 +02:00
// `/_next/*` asset requests happen dozens of times per page load and carry no operational
// signal - logged at debug so they don't drown out page/API requests in the default info level.
function accessLogLevel(pathname: string, statusCode: number): Level {
if (statusCode >= 500) return "error";
if (statusCode >= 400) return "warn";
return pathname.startsWith("/_next/") ? "debug" : "info";
}
2026-08-15 18:37:30 +02:00
app.prepare().then(() => {
const nextUpgradeHandler = app.getUpgradeHandler();
2026-08-15 18:37:30 +02:00
const httpServer = createServer((req, res) => {
2026-08-19 09:54:16 +02:00
const reqId = req.headers["x-request-id"]?.toString() ?? randomUUID();
req.headers["x-request-id"] = reqId;
const startedAt = process.hrtime.bigint();
res.on("finish", () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
const { pathname } = new URL(req.url ?? "/", "http://internal");
log[accessLogLevel(pathname, res.statusCode)](
{
reqId,
method: req.method,
path: pathname,
status: res.statusCode,
durationMs: Math.round(durationMs),
},
"request",
);
});
handle(req, res).catch((error: unknown) => {
log.error({ reqId, err: error }, "unhandled error handling request");
if (!res.headersSent) res.writeHead(500).end();
});
2026-08-15 18:37:30 +02:00
});
const wss = new WebSocketServer({ noServer: true });
attachWsServer(wss);
httpServer.on("upgrade", (req, socket, head) => {
const { pathname } = new URL(req.url ?? "/", "http://internal");
2026-08-15 18:37:30 +02:00
if (pathname !== "/ws/runs") {
// Anything else (e.g. Next's own dev-mode HMR websocket at /_next/hmr) is Next's to handle.
nextUpgradeHandler(req, socket, head).catch(() => socket.destroy());
2026-08-15 18:37:30 +02:00
return;
}
authenticateUpgrade(req)
.then((ok) => {
if (!ok) {
2026-08-19 09:54:16 +02:00
log.warn({ path: pathname }, "rejected unauthenticated WS upgrade");
2026-08-15 18:37:30 +02:00
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
})
2026-08-19 09:54:16 +02:00
.catch((error: unknown) => {
log.error({ err: error }, "error authenticating WS upgrade");
socket.destroy();
});
2026-08-15 18:37:30 +02:00
});
httpServer.listen(port, hostname, () => {
2026-08-19 09:54:16 +02:00
log.info(
{ hostname, port, mode: dev ? "development" : "production" },
"triggershell ready",
2026-08-15 18:37:30 +02:00
);
});
const shutdown = (signal: string) => {
2026-08-19 09:54:16 +02:00
log.info({ signal }, "shutting down");
2026-08-15 18:37:30 +02:00
killAllRuns();
httpServer.close(() => process.exit(0));
// Force-exit if graceful shutdown hangs (e.g. a stuck WS connection).
2026-08-19 09:54:16 +02:00
setTimeout(() => {
log.warn("graceful shutdown timed out, forcing exit");
process.exit(1);
}, 5000).unref();
2026-08-15 18:37:30 +02:00
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
});