Files
template-app/apps/web/server.mjs
T
2026-11-14 09:56:45 +00:00

72 lines
3.3 KiB
JavaScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The production server: Hono's Node server in front of the Start build (`vite build`), the build's
// static files first, then the Start handler for everything else -- pages, `/api/*` and `/auth/*`.
// `src/server.ts` sets the security headers on what the handler answers; the static files get the ones
// that need no nonce here.
//
// Usage: node server.mjs [--port <n>] (PORT, HOSTNAME)
import { resolve } from "node:path"
import { pathToFileURL } from "node:url"
import { serve } from "@hono/node-server"
import { serveStatic } from "@hono/node-server/serve-static"
import { Hono } from "hono"
// A direct Node start uses the same production security policy as the container.
process.env.NODE_ENV ??= "production"
const here = new URL(".", import.meta.url).pathname
const output = resolve(here, "dist")
const { default: handler } = await import(pathToFileURL(resolve(output, "server/server.js")).href)
const app = new Hono()
app.use("*", async (c, next) => {
await next()
c.header("X-Content-Type-Options", "nosniff")
c.header("X-Frame-Options", "DENY")
c.header("Referrer-Policy", "strict-origin-when-cross-origin")
c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
})
app.use("*", serveStatic({ root: resolve(output, "client") }))
app.all("*", (c) => handler.fetch(c.req.raw))
const flag = process.argv.indexOf("--port")
const port = Number(flag >= 0 ? process.argv[flag + 1] : (process.env.PORT ?? 3510))
const server = serve({ fetch: app.fetch, port, hostname: process.env.HOSTNAME ?? "0.0.0.0" })
// SIGTERM, which the platform sends before it replaces the container: this process owns the exit, in this order.
//
// 1. Stop taking requests and let the ones in flight finish, because their usage joins the queue as they end.
// 2. Drain the runtime (`drain` of src/server/app.ts): the usage queue, the exporters, the pools. An unflushed
// queue is unbilled usage, so nothing exits before this ends. No runtime means no request built one.
// 3. Exit: 0 when everything finished, 1 when the drain did not.
//
// Each step is bounded, the two together inside compose.yaml's `stop_grace_period` (20 s), after which the container
// is killed and nothing is flushed. Step 1 does not wait out an open live stream: it has no end, and the drain closes it.
const requestsMs = Number(process.env.SHUTDOWN_REQUESTS_MS || 10_000)
const flushMs = Number(process.env.SHUTDOWN_FLUSH_MS || 8_000)
const within = (ms, work) => {
let timer
const elapsed = new Promise((resolve) => {
timer = setTimeout(() => resolve(false), ms)
})
return Promise.race([work.then(() => true), elapsed]).finally(() => clearTimeout(timer))
}
let stopping = false
async function stop() {
if (stopping) return
stopping = true
await within(requestsMs, new Promise((resolve) => server.close(resolve)))
const drained = await within(
flushMs,
Promise.resolve().then(() => globalThis[Symbol.for("conusai.app.drain")]?.()),
).catch((error) => {
console.error(`web: the drain failed: ${error instanceof Error ? error.message : "failed"}`)
return false
})
if (!drained) console.error(`web: the runtime was not drained within ${flushMs} ms`)
process.exit(drained ? 0 : 1)
}
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => void stop())