import "./src/bootstrap/async-local-storage-polyfill"; import { randomUUID } from "node:crypto"; import { createServer } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import next from "next"; import type { Level } from "pino"; 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"; import { logger } from "./src/lib/logger"; const log = logger.child({ mod: "server" }); 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 }); const handle = app.getRequestHandler(); // `/_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"; } app.prepare().then(() => { const nextUpgradeHandler = app.getUpgradeHandler(); const httpServer = createServer((req, res) => { 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(); }); }); const wss = new WebSocketServer({ noServer: true }); attachWsServer(wss); httpServer.on("upgrade", (req, socket, head) => { const { pathname } = new URL(req.url ?? "/", "http://internal"); 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()); return; } authenticateUpgrade(req) .then((ok) => { if (!ok) { log.warn({ path: pathname }, "rejected unauthenticated WS upgrade"); 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); }); }) .catch((error: unknown) => { log.error({ err: error }, "error authenticating WS upgrade"); socket.destroy(); }); }); httpServer.listen(port, hostname, () => { log.info( { hostname, port, mode: dev ? "development" : "production" }, "triggershell ready", ); }); const shutdown = (signal: string) => { log.info({ signal }, "shutting down"); killAllRuns(); httpServer.close(() => process.exit(0)); // Force-exit if graceful shutdown hangs (e.g. a stuck WS connection). setTimeout(() => { log.warn("graceful shutdown timed out, forcing exit"); process.exit(1); }, 5000).unref(); }; process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); });