@@ -0,0 +1,71 @@
|
||||
// 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())
|
||||
Reference in New Issue
Block a user