diff --git a/app/globals.d.ts b/app/globals.d.ts new file mode 100644 index 0000000..51cc670 --- /dev/null +++ b/app/globals.d.ts @@ -0,0 +1,4 @@ +// Globals replaced at build time by Vite (`define` in `vite.config.ts`). + +declare const __PREFIX__: string; +declare const __VERSION__: string; diff --git a/app/routes/auth/logout.ts b/app/routes/auth/logout.ts index f34d8e6..5510f5e 100644 --- a/app/routes/auth/logout.ts +++ b/app/routes/auth/logout.ts @@ -1,12 +1,12 @@ import { type ActionFunctionArgs, redirect } from "react-router"; -import type { LoadContext } from "~/server"; +import type { AppContext } from "~/server/context"; export async function loader() { return redirect("/machines"); } -export async function action({ request, context }: ActionFunctionArgs) { +export async function action({ request, context }: ActionFunctionArgs) { let principal: Awaited> | undefined; try { principal = await context.auth.require(request); diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx index dd246be..2e2fbf7 100644 --- a/app/routes/dns/overview.tsx +++ b/app/routes/dns/overview.tsx @@ -4,7 +4,7 @@ import { useLoaderData } from "react-router"; import Code from "~/components/code"; import Notice from "~/components/notice"; import PageError from "~/components/page-error"; -import type { LoadContext } from "~/server"; +import type { AppContext } from "~/server/context"; import { Capabilities } from "~/server/web/roles"; import ManageDomains from "./components/manage-domains"; @@ -15,7 +15,7 @@ import ToggleMagic from "./components/toggle-magic"; import { dnsAction } from "./dns-actions"; // We do not want to expose every config value -export async function loader({ request, context }: LoaderFunctionArgs) { +export async function loader({ request, context }: LoaderFunctionArgs) { if (!context.hs.readable()) { throw new Error("No configuration is available"); } diff --git a/app/server/README.md b/app/server/README.md new file mode 100644 index 0000000..acafce1 --- /dev/null +++ b/app/server/README.md @@ -0,0 +1,111 @@ +# `app/server/` + +Server-side application code for Headplane. Everything in this directory +runs only on the Node process — never in the browser. + +## Layout + +``` +app/server/ +├── app.ts ← The Headplane application (load context, RR listener) +├── main.ts ← Production bootstrap (binds an http(s) server) +├── context.ts ← createAppContext() — assembles the AppLoadContext +├── result.ts ← Result helper used across the server modules +│ +├── config/ ← YAML config loading, schema, env-overrides, integrations +├── db/ ← Drizzle SQLite client + schema +├── headscale/ ← Headscale REST API client + headscale-config loader +├── oidc/ ← OIDC provider abstraction +├── web/ ← Authentication service, identity, RBAC capabilities +└── hp-agent.ts ← Headplane agent process manager +``` + +## Entry points + +There are two SSR entries; both are picked up by Vite via `vite.config.ts`. + +### `app.ts` — the application module + +Loads config → builds the `AppLoadContext` (via [`context.ts`](./context.ts)) +→ exports the React Router `RequestListener` as `default`, plus the +resolved `config` as a named export. + +This module has no opinions about how the server is hosted. It does not +listen on a socket, doesn't compose static-asset serving, and doesn't +handle the basename redirect — that's the runtime's job (see +[`runtime/`](../../runtime/)). + +Consumed by: + +- [`main.ts`](./main.ts) in production builds +- [`runtime/vite-plugin.ts`](../../runtime/vite-plugin.ts) in `react-router dev` + +### `main.ts` — the production bootstrap + +The SSR build input. Rollup bundles this file into +`build/server/index.js`. It: + +1. imports the listener + config from [`app.ts`](./app.ts) +2. wraps the listener with `composeListener` from + [`runtime/http.ts`](../../runtime/http.ts) — adds `/admin → /admin/` + redirect and serves `build/client/` as static assets with immutable + caching for the `assets/` subdirectory +3. binds an http(s) server with `startHttpServer` + +Run with `node /app/build/server/index.js` (this is what the Dockerfile +does). TLS is a one-line addition: pass `tls: { key, cert }` to +`startHttpServer`. + +## Application context + +[`context.ts`](./context.ts) exposes `createAppContext(config)`, which +constructs everything that needs to live for the lifetime of the +process: + +- the SQLite client (`db`) +- the Headscale REST interface (`hsApi`) +- the optional Headplane agent manager (`agents`) +- the auth service (`auth`) +- the optional OIDC service (`oidc`) +- the live store (`hsLive`) +- the (best-effort) parsed Headscale config (`hs`) +- the integration adapter (`integration`) + +The returned object is the `AppLoadContext` exposed to every React +Router loader/action. The module also `declare module "react-router" { interface AppLoadContext extends AppContext {} }` +so route handlers get full type inference on `context`. + +When a route needs the type, import it from `~/server/context`: + +```ts +import type { AppContext } from "~/server/context"; + +export async function loader({ context }: LoaderFunctionArgs) { + // … +} +``` + +## Dev vs. prod + +| Concern | Dev (`react-router dev`) | Prod (`node build/server/index.js`) | +| ---------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------ | +| HTTP server | Vite owns it (`vite.config.ts` `server.host/port`) | `runtime/http.ts` `startHttpServer` | +| Static assets | `./public` (served by `runtime/vite-plugin.ts`) | `build/client/` (served by `runtime/http.ts` static handler) | +| Basename redirect (`/admin` → `/admin/`) | `runtime/vite-plugin.ts` via `composeListener` | `main.ts` via `composeListener` | +| App load (HMR) | `ssrLoadModule(app.ts)` per request | bundled into `build/server/index.js` | +| Entry point | [`app.ts`](./app.ts) | [`main.ts`](./main.ts) | + +There is **no** `if (import.meta.env.PROD)` branch in [`app.ts`](./app.ts) +or [`main.ts`](./main.ts) — the dev/prod split is expressed by which +file is loaded, not by runtime conditionals. + +## Adding a new server-side module + +1. Create the module under an existing subdirectory (or add a new one + that names a coherent concern, e.g. `metrics/`, `ratelimit/`). +2. If it owns process-lifetime state (a connection pool, a service + client, …), construct it in [`context.ts`](./context.ts) and add it + to the returned object — this gives every route automatic access via + `context.`. +3. If it's purely a helper (pure functions, type definitions), import + it directly from the module that needs it. diff --git a/app/server/app.ts b/app/server/app.ts new file mode 100644 index 0000000..cb65050 --- /dev/null +++ b/app/server/app.ts @@ -0,0 +1,50 @@ +// MARK: Headplane Application +// +// Loads configuration, builds the per-process app context, and exports +// the React Router request listener as the default export. +// +// This module is consumed in two places: +// - `app/server/main.ts` — the production bootstrap; wraps the +// listener with static-asset serving and binds an http(s) server. +// - `runtime/vite-plugin.ts` — the dev-mode Vite middleware; loads +// this module through `ssrLoadModule` and dispatches each request. + +import { exit, versions } from "node:process"; + +import { createRequestListener } from "@react-router/node"; +import * as build from "virtual:react-router/server-build"; + +import log from "~/utils/log"; + +import type { HeadplaneConfig } from "./config/config-schema"; +import { ConfigError } from "./config/error"; +import { loadConfig } from "./config/load"; +import { createAppContext } from "./context"; + +log.info("server", "Running Node.js %s", versions.node); + +let config: HeadplaneConfig; +try { + config = await loadConfig(); +} catch (error) { + if (error instanceof ConfigError) { + log.error("server", "Unable to load configuration: %s", error.message); + } else { + log.error("server", "Failed to load configuration: %s", error); + } + exit(1); +} + +const ctx = await createAppContext(config); +ctx.auth.start(); + +export { config }; + +// TODO: `getLoadContext` is the right place to handle reverse proxy +// translation — better than doing it in the OIDC client because it +// applies to all requests, not just OIDC ones. +export default createRequestListener({ + build, + mode: import.meta.env.MODE, + getLoadContext: () => ctx, +}); diff --git a/app/server/context.ts b/app/server/context.ts new file mode 100644 index 0000000..7f524c9 --- /dev/null +++ b/app/server/context.ts @@ -0,0 +1,102 @@ +import { join } from "node:path"; + +import log from "~/utils/log"; + +import type { HeadplaneConfig } from "./config/config-schema"; +import { loadIntegration } from "./config/integration"; +import { createDbClient } from "./db/client.server"; +import { createHeadscaleInterface } from "./headscale/api"; +import { loadHeadscaleConfig } from "./headscale/config-loader"; +import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store"; +import { createAgentManager } from "./hp-agent"; +import { createOidcService } from "./oidc/provider"; +import { createAuthService } from "./web/auth"; + +export type AppContext = Awaited>; + +declare module "react-router" { + interface AppLoadContext extends AppContext {} +} + +export async function createAppContext(config: HeadplaneConfig) { + const db = await createDbClient(join(config.server.data_path, "hp_persist.db")); + const hsApi = await createHeadscaleInterface( + config.headscale.url, + config.headscale.tls_cert_path, + ); + + // Resolve the Headscale API key: headscale.api_key takes precedence, + // falling back to the deprecated oidc.headscale_api_key for compatibility. + const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key; + + let agents; + if (headscaleApiKey) { + agents = await createAgentManager( + config.integration?.agent, + config.headscale.url, + hsApi.getRuntimeClient(headscaleApiKey), + hsApi.clientHelpers.isAtleast("0.28.0"), + db, + ); + } else if (config.integration?.agent?.enabled) { + log.warn("agent", "Agent is enabled but no headscale.api_key is configured"); + } + + const auth = createAuthService({ + secret: config.server.cookie_secret, + headscaleApiKey, + db, + cookie: { + name: "_hp_auth", + secure: config.server.cookie_secure, + maxAge: config.server.cookie_max_age, + domain: config.server.cookie_domain, + }, + }); + + const oidc = + config.oidc && config.oidc.enabled !== false && headscaleApiKey + ? { + service: createOidcService({ + issuer: config.oidc.issuer, + clientId: config.oidc.client_id, + clientSecret: config.oidc.client_secret, + baseUrl: config.server.base_url ?? "", + authorizationEndpoint: config.oidc.authorization_endpoint, + tokenEndpoint: config.oidc.token_endpoint, + userinfoEndpoint: config.oidc.userinfo_endpoint, + endSessionEndpoint: config.oidc.end_session_endpoint, + tokenEndpointAuthMethod: + config.oidc.token_endpoint_auth_method === "client_secret_jwt" + ? undefined + : config.oidc.token_endpoint_auth_method, + usePkce: config.oidc.use_pkce, + scope: config.oidc.scope, + subjectClaims: config.oidc.subject_claims, + allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys, + extraParams: config.oidc.extra_params, + profilePictureSource: config.oidc.profile_picture_source, + postLogoutRedirectUri: config.oidc.post_logout_redirect_uri, + }), + disableApiKeyLogin: config.oidc.disable_api_key_login, + useEndSession: config.oidc.use_end_session, + } + : undefined; + + return { + config, + db, + hsApi, + headscaleApiKey, + agents, + auth, + oidc, + hsLive: createLiveStore([nodesResource, usersResource]), + hs: await loadHeadscaleConfig( + config.headscale.config_path, + config.headscale.config_strict, + config.headscale.dns_records_path, + ), + integration: await loadIntegration(config.integration), + }; +} diff --git a/app/server/index.ts b/app/server/index.ts deleted file mode 100644 index f0555a8..0000000 --- a/app/server/index.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { join } from "node:path"; -import { exit, versions } from "node:process"; - -import { createHonoServer } from "react-router-hono-server/node"; - -import log from "~/utils/log"; - -import { loadIntegration } from "./config/integration"; -import { loadConfig } from "./config/load"; -import { createDbClient } from "./db/client.server"; -import { createHeadscaleInterface } from "./headscale/api"; -import { loadHeadscaleConfig } from "./headscale/config-loader"; -import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store"; -import { createAgentManager } from "./hp-agent"; -import { createAuthService } from "./web/auth"; - -declare global { - const __PREFIX__: string; - const __VERSION__: string; -} - -// MARK: Side-Effects -// This module contains a side-effect because everything running here -// exists for the lifetime of the process, making it appropriate. -log.info("server", "Running Node.js %s", versions.node); -let config: HeadplaneConfig; - -try { - config = await loadConfig(); -} catch (error) { - if (error instanceof ConfigError) { - log.error("server", "Unable to load configuration: %s", error.message); - } - - exit(1); -} - -const db = await createDbClient(join(config.server.data_path, "hp_persist.db")); -const hsApi = await createHeadscaleInterface(config.headscale.url, config.headscale.tls_cert_path); - -// Resolve the Headscale API key: headscale.api_key takes precedence, -// falling back to the deprecated oidc.headscale_api_key for compatibility. -const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key; - -const agents = headscaleApiKey - ? await createAgentManager( - config.integration?.agent, - config.headscale.url, - hsApi.getRuntimeClient(headscaleApiKey), - hsApi.clientHelpers.isAtleast("0.28.0"), - db, - ) - : (() => { - if (config.integration?.agent?.enabled) { - log.warn("agent", "Agent is enabled but no headscale.api_key is configured"); - } - return undefined; - })(); - -// We also use this file to load anything needed by the react router code. -// These are usually per-request things that we need access to, like the -// helper that can issue and revoke cookies. -export type LoadContext = typeof appLoadContext; - -import "react-router"; -import { HeadplaneConfig } from "./config/config-schema"; -import { ConfigError } from "./config/error"; -import { createOidcService } from "./oidc/provider"; - -declare module "react-router" { - interface AppLoadContext extends LoadContext {} -} - -const hsLive = createLiveStore([nodesResource, usersResource]); - -const appLoadContext = { - config, - hsLive, - hs: await loadHeadscaleConfig( - config.headscale.config_path, - config.headscale.config_strict, - config.headscale.dns_records_path, - ), - - auth: createAuthService({ - secret: config.server.cookie_secret, - headscaleApiKey, - db, - cookie: { - name: "_hp_auth", - secure: config.server.cookie_secure, - maxAge: config.server.cookie_max_age, - domain: config.server.cookie_domain, - }, - }), - - headscaleApiKey, - hsApi, - agents, - integration: await loadIntegration(config.integration), - oidc: - config.oidc && config.oidc.enabled !== false && headscaleApiKey - ? { - service: createOidcService({ - issuer: config.oidc.issuer, - clientId: config.oidc.client_id, - clientSecret: config.oidc.client_secret, - baseUrl: config.server.base_url ?? "", - authorizationEndpoint: config.oidc.authorization_endpoint, - tokenEndpoint: config.oidc.token_endpoint, - userinfoEndpoint: config.oidc.userinfo_endpoint, - endSessionEndpoint: config.oidc.end_session_endpoint, - tokenEndpointAuthMethod: - config.oidc.token_endpoint_auth_method === "client_secret_jwt" - ? undefined - : config.oidc.token_endpoint_auth_method, - usePkce: config.oidc.use_pkce, - scope: config.oidc.scope, - subjectClaims: config.oidc.subject_claims, - allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys, - extraParams: config.oidc.extra_params, - profilePictureSource: config.oidc.profile_picture_source, - postLogoutRedirectUri: config.oidc.post_logout_redirect_uri, - }), - disableApiKeyLogin: config.oidc.disable_api_key_login, - useEndSession: config.oidc.use_end_session, - } - : undefined, - db, -}; - -declare module "react-router" { - interface AppLoadContext extends LoadContext {} -} - -export default createHonoServer({ - overrideGlobalObjects: true, - port: config.server.port, - hostname: config.server.host, - beforeAll: async (app) => { - app.use(__PREFIX__, async (c) => { - return c.redirect(`${__PREFIX__}/`); - }); - }, - serveStaticOptions: { - publicAssets: { - // This is part of our monkey-patch for react-router-hono-server - // To see the first part, go to the patches/ directory. - rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""), - }, - clientAssets: { - // This is part of our monkey-patch for react-router-hono-server - // To see the first part, go to the patches/ directory. - rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""), - }, - }, - - // Only log in development mode - defaultLogger: import.meta.env.DEV, - getLoadContext() { - // TODO: This is the place where we can handle reverse proxy translation - // This is better than doing it in the OIDC client, since we can do it - // for all requests, not just OIDC ones. - return appLoadContext; - }, - - listeningListener(info) { - log.info("server", "Running on %s:%s", info.address, info.port); - }, -}); - -appLoadContext.auth.start(); - -process.on("SIGINT", () => { - log.info("server", "Received SIGINT, shutting down..."); - process.exit(0); -}); - -process.on("SIGTERM", () => { - log.info("server", "Received SIGTERM, shutting down..."); - process.exit(0); -}); diff --git a/app/server/main.ts b/app/server/main.ts new file mode 100644 index 0000000..2babfef --- /dev/null +++ b/app/server/main.ts @@ -0,0 +1,30 @@ +// MARK: Production Bootstrap +// +// The production SSR build entry. Imports the React Router request +// listener from `./app`, wraps it with static-asset serving (out of +// `build/client`) and basename redirect, then binds an http(s) server. +// +// This file is NOT loaded in dev — `react-router dev` boots through +// Vite, and the dev-only `runtime/vite-plugin.ts` dispatches requests +// straight to `./app`'s default export. + +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { composeListener, startHttpServer } from "../../runtime/http"; +import requestListener, { config } from "./app"; + +// `import.meta.url` resolves to `build/server/index.js`; the built +// client lives next to it at `build/client/`. +const clientDir = resolve(dirname(fileURLToPath(import.meta.url)), "../client"); + +startHttpServer({ + host: config.server.host, + port: config.server.port, + listener: composeListener({ + basename: __PREFIX__, + staticRoot: clientDir, + immutableAssets: true, + requestListener, + }), +}); diff --git a/package.json b/package.json index 7348d88..058317b 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "react-error-boundary": "^6.1.1", "react-router": "^7.14.0", "react-router-dom": "^7.14.0", - "react-router-hono-server": "2.25.2", "restty": "^0.1.35", "tailwind-merge": "3.5.0", "ulidx": "2.4.1", @@ -105,9 +104,6 @@ "ssh2", "utf-8-validate" ], - "patchedDependencies": { - "react-router-hono-server": "patches/react-router-hono-server.patch" - }, "ignoredBuiltDependencies": [ "better-sqlite3" ], diff --git a/patches/react-router-hono-server.patch b/patches/react-router-hono-server.patch deleted file mode 100644 index 7559432..0000000 --- a/patches/react-router-hono-server.patch +++ /dev/null @@ -1,20 +0,0 @@ -diff --git a/dist/adapters/node.js b/dist/adapters/node.js -index e081ab36a04ea100cf5da3fb5eb54c5174186f4d..35433810d05a829f2c824d63714e71bf5ed069a0 100644 ---- a/dist/adapters/node.js -+++ b/dist/adapters/node.js -@@ -49,13 +49,13 @@ async function createHonoServer(options) { - } - await mergedOptions.beforeAll?.(app); - app.use( -- `/${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`, -+ `${import.meta.env.REACT_ROUTER_HONO_SERVER_BASENAME}${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`, - cache(60 * 60 * 24 * 365), - // 1 year - serveStatic({ root: clientBuildPath, ...mergedOptions.serveStaticOptions?.clientAssets }) - ); - app.use( -- "*", -+ `${import.meta.env.REACT_ROUTER_HONO_SERVER_BASENAME}*`, - cache(60 * 60), - // 1 hour - serveStatic({ root: PRODUCTION ? clientBuildPath : "./public", ...mergedOptions.serveStaticOptions?.publicAssets }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 763b41d..2a6a48a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,11 +7,6 @@ settings: overrides: '@codemirror/state': 6.6.0 -patchedDependencies: - react-router-hono-server: - hash: e94667f181837f2501959f5cf01fa0a9e6ea2883dcb1137be8b1c6178979bc77 - path: patches/react-router-hono-server.patch - importers: .: @@ -103,9 +98,6 @@ importers: react-router-dom: specifier: ^7.14.0 version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-router-hono-server: - specifier: 2.25.2 - version: 2.25.2(patch_hash=e94667f181837f2501959f5cf01fa0a9e6ea2883dcb1137be8b1c6178979bc77)(@hono/node-server@1.19.13(hono@4.11.5))(@react-router/dev@7.14.0(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(terser@5.39.0)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))(@types/react@19.2.14)(bufferutil@4.0.9)(hono@4.11.5)(react-router@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) restty: specifier: ^0.1.35 version: 0.1.35(typescript@6.0.2) @@ -1006,32 +998,6 @@ packages: engines: {node: '>=6'} hasBin: true - '@hono/node-server@1.19.13': - resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - - '@hono/node-ws@1.3.0': - resolution: {integrity: sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==} - engines: {node: '>=18.14.1'} - peerDependencies: - '@hono/node-server': ^1.19.2 - hono: ^4.6.0 - - '@hono/vite-dev-server@0.25.1': - resolution: {integrity: sha512-H6EqoPtCV+uH1fu8nKyRy5Wh3LU012QxloEYMlUcyMjjEfVFiRXPjHx/0TQiiRi140Vu8San5A4XQWyElKFKaw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: '*' - miniflare: '*' - wrangler: '*' - peerDependenciesMeta: - miniflare: - optional: true - wrangler: - optional: true - '@humanwhocodes/momoa@2.0.4': resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} engines: {node: '>=10.10.0'} @@ -2882,10 +2848,6 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - hono@4.11.5: - resolution: {integrity: sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==} - engines: {node: '>=16.9.0'} - hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -3500,28 +3462,6 @@ packages: react: '>=18' react-dom: '>=18' - react-router-hono-server@2.25.2: - resolution: {integrity: sha512-hNgPx9lB8lTtlJAt7GWIB/S3LIStQTi4Y24ZcX3kCw05McDhUwEFxnN5WIK+F5j1iQxLgkX2vnkjsWdS7zc7Ng==} - engines: {node: '>=22.20.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^4.20250317.0 - '@hono/node-server': ^1.19.11 - '@react-router/dev': ^7.9.0 - '@types/react': ^19.0.0 - hono: ^4.12.8 - miniflare: ^3.20241205.0 - react-router: ^7.13.1 - vite: '>=7.3.1' - wrangler: ^4.73.0 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - miniflare: - optional: true - wrangler: - optional: true - react-router@7.14.0: resolution: {integrity: sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==} engines: {node: '>=20.0.0'} @@ -4948,25 +4888,6 @@ snapshots: protobufjs: 7.5.4 yargs: 17.7.2 - '@hono/node-server@1.19.13(hono@4.11.5)': - dependencies: - hono: 4.11.5 - - '@hono/node-ws@1.3.0(@hono/node-server@1.19.13(hono@4.11.5))(bufferutil@4.0.9)(hono@4.11.5)(utf-8-validate@5.0.10)': - dependencies: - '@hono/node-server': 1.19.13(hono@4.11.5) - hono: 4.11.5 - ws: 8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@hono/vite-dev-server@0.25.1(hono@4.11.5)': - dependencies: - '@hono/node-server': 1.19.13(hono@4.11.5) - hono: 4.11.5 - minimatch: 9.0.9 - '@humanwhocodes/momoa@2.0.4': {} '@iconify-json/simple-icons@1.2.48': @@ -6662,8 +6583,6 @@ snapshots: dependencies: '@types/hast': 3.0.4 - hono@4.11.5: {} - hookable@5.5.3: {} hpagent@1.2.0: {} @@ -7285,21 +7204,6 @@ snapshots: react-dom: 19.2.5(react@19.2.5) react-router: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-router-hono-server@2.25.2(patch_hash=e94667f181837f2501959f5cf01fa0a9e6ea2883dcb1137be8b1c6178979bc77)(@hono/node-server@1.19.13(hono@4.11.5))(@react-router/dev@7.14.0(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(terser@5.39.0)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))(@types/react@19.2.14)(bufferutil@4.0.9)(hono@4.11.5)(react-router@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - '@drizzle-team/brocli': 0.11.0 - '@hono/node-server': 1.19.13(hono@4.11.5) - '@hono/node-ws': 1.3.0(@hono/node-server@1.19.13(hono@4.11.5))(bufferutil@4.0.9)(hono@4.11.5)(utf-8-validate@5.0.10) - '@hono/vite-dev-server': 0.25.1(hono@4.11.5) - '@react-router/dev': 7.14.0(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(terser@5.39.0)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) - '@types/react': 19.2.14 - hono: 4.11.5 - react-router: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - bufferutil - - utf-8-validate - react-router@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: cookie: 1.1.1 @@ -8031,6 +7935,7 @@ snapshots: optionalDependencies: bufferutil: 4.0.9 utf-8-validate: 5.0.10 + optional: true wsl-utils@0.1.0: dependencies: diff --git a/runtime/http.ts b/runtime/http.ts new file mode 100644 index 0000000..875de5f --- /dev/null +++ b/runtime/http.ts @@ -0,0 +1,203 @@ +// MARK: HTTP Runtime +// +// Bare Node http(s) runtime that hosts a `RequestListener` (typically the +// React Router request listener from `@react-router/node`) and serves +// static assets out of a directory. +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { + type IncomingMessage, + type RequestListener, + type Server, + createServer as createHttpServer, +} from "node:http"; +import type { ServerResponse } from "node:http"; +import { + type ServerOptions as HttpsServerOptions, + createServer as createHttpsServer, +} from "node:https"; +import { extname, normalize, resolve, sep } from "node:path"; + +import mime from "mime"; + +export interface Logger { + info: (msg: string, ...args: unknown[]) => void; + error: (msg: string, ...args: unknown[]) => void; +} + +// TODO: Replace with Pino! +const defaultLogger: Logger = { + info: (msg, ...args) => console.log(`[runtime] ${msg}`, ...args), + error: (msg, ...args) => console.error(`[runtime] ${msg}`, ...args), +}; + +export interface StaticOptions { + root: string; + basename: string; + assetsDir: string; + immutableAssets: boolean; +} + +/** + * Returns a static-file middleware. The callback resolves to `true` when + * the request was served, `false` when the caller should fall through to + * the next handler (e.g. the React Router request listener). + */ +function createStaticHandler(opts: StaticOptions) { + const root = resolve(opts.root); + const prefix = opts.basename.endsWith("/") ? opts.basename : `${opts.basename}/`; + const assetsPrefix = `${prefix}${opts.assetsDir}/`; + + return async function serveStatic(req: IncomingMessage, res: ServerResponse) { + if (req.method !== "GET" && req.method !== "HEAD") return false; + if (!req.url) return false; + + let pathname: string; + try { + pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname); + } catch { + return false; + } + + if (!pathname.startsWith(prefix)) return false; + const rel = pathname.slice(prefix.length); + if (!rel || rel.endsWith("/")) return false; + + // Resolve and confine to root to prevent path traversal. + const file = resolve(root, normalize(rel)); + if (file !== root && !file.startsWith(root + sep)) return false; + + let st; + try { + st = await stat(file); + } catch { + return false; + } + if (!st.isFile()) return false; + + const isAsset = pathname.startsWith(assetsPrefix); + res.setHeader( + "Cache-Control", + isAsset && opts.immutableAssets + ? "public, max-age=31536000, immutable" + : "public, max-age=3600", + ); + + const mimeType = mime.getType(extname(file)) ?? "application/octet-stream"; + res.setHeader("Content-Type", mimeType); + res.setHeader("Content-Length", String(st.size)); + res.setHeader("Last-Modified", st.mtime.toUTCString()); + res.statusCode = 200; + + if (req.method === "HEAD") { + res.end(); + return true; + } + + await new Promise((resolve_, reject) => { + const stream = createReadStream(file); + stream.on("error", reject); + stream.on("end", () => resolve_()); + stream.pipe(res); + }); + + return true; + }; +} + +export interface ListenerOptions { + basename: string; + staticRoot?: string; + assetsDir?: string; + immutableAssets?: boolean; + requestListener: RequestListener; + logger?: Logger; +} + +/** + * Composes the full Node `RequestListener` chain: + * 1. `${basename}` → 302 to `${basename}/` + * 2. Static asset serving (optional) + * 3. Delegate to the downstream request listener + */ +export function composeListener(opts: ListenerOptions): RequestListener { + const log = opts.logger ?? defaultLogger; + const basename = opts.basename; + const serveStatic = opts.staticRoot + ? createStaticHandler({ + root: opts.staticRoot, + basename, + assetsDir: opts.assetsDir ?? "assets", + immutableAssets: opts.immutableAssets ?? true, + }) + : null; + + return (req, res) => { + if (req.url) { + try { + const url = new URL(req.url, "http://localhost"); + if (url.pathname === basename) { + res.statusCode = 302; + res.setHeader("Location", `${basename}/${url.search}`); + res.end(); + return; + } + } catch {} + } + + if (!serveStatic) { + opts.requestListener(req, res); + return; + } + + serveStatic(req, res) + .then((handled) => { + if (!handled) opts.requestListener(req, res); + }) + .catch((err) => { + log.error("Static handler failed: %s", err); + if (!res.headersSent) { + res.statusCode = 500; + res.end("Internal Server Error"); + } else { + res.destroy(err as Error); + } + }); + }; +} + +export interface StartOptions { + host: string; + port: number; + listener: RequestListener; + tls?: HttpsServerOptions; + logger?: Logger; +} + +/** + * Creates and starts an http(s) server. Wires up SIGINT/SIGTERM for + * graceful shutdown. + */ +export function startHttpServer(opts: StartOptions): Server { + const log = opts.logger ?? defaultLogger; + const server = opts.tls + ? createHttpsServer(opts.tls, opts.listener) + : createHttpServer(opts.listener); + + server.listen(opts.port, opts.host, () => { + const proto = opts.tls ? "https" : "http"; + log.info("Listening on %s://%s:%s", proto, opts.host, opts.port); + }); + + const shutdown = (signal: string) => { + log.info("Received %s, shutting down...", signal); + server.close(() => process.exit(0)); + // Force exit if connections don't drain in time. + setTimeout(() => process.exit(0), 5_000).unref(); + }; + + process.once("SIGINT", () => shutdown("SIGINT")); + process.once("SIGTERM", () => shutdown("SIGTERM")); + + return server; +} diff --git a/runtime/vite-plugin.ts b/runtime/vite-plugin.ts new file mode 100644 index 0000000..0f6ea29 --- /dev/null +++ b/runtime/vite-plugin.ts @@ -0,0 +1,59 @@ +// MARK: Vite Plugin +// +// In development we want `react-router dev` (Vite) to host the entire +// app, so we register a single connect-style middleware that: +// 1. loads the SSR entry through Vite's `ssrLoadModule` (HMR-aware), +// 2. dispatches every request through a `composeListener` chain +// (basename redirect → static asset fallback → app handler). + +import type { RequestListener } from "node:http"; + +import type { Plugin } from "vite"; + +import { composeListener } from "./http"; + +export interface DevServerOptions { + entry: string; + basename: string; + publicDir: string; +} + +export function headplaneDevServer(options: DevServerOptions): Plugin { + return { + name: "headplane:dev-server", + apply: "serve", + configureServer(server) { + // Lazy reference to the loaded entry; recomputed per request so + // Vite's HMR picks up changes via `ssrLoadModule`. + let appListener: RequestListener = (_req, res) => { + res.statusCode = 503; + res.end("Server entry not loaded yet"); + }; + + const composed = composeListener({ + basename: options.basename, + staticRoot: options.publicDir, + immutableAssets: false, + requestListener: (req, res) => appListener(req, res), + }); + + // Defer registration so our middleware runs AFTER Vite's + // transform/asset middlewares (those serve `/@vite/...`, + // `/node_modules/...`, module transforms, etc.). + return () => { + server.middlewares.use(async (req, res, next) => { + try { + const mod = (await server.ssrLoadModule(options.entry)) as { + default: RequestListener; + }; + appListener = mod.default; + composed(req, res); + } catch (err) { + if (err instanceof Error) server.ssrFixStacktrace(err); + next(err); + } + }); + }; + }, + }; +} diff --git a/vite.config.ts b/vite.config.ts index 1fcd7f0..6a3029e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,20 +3,24 @@ import { readFile } from "node:fs/promises"; import { reactRouter } from "@react-router/dev/vite"; import tailwindcss from "@tailwindcss/vite"; -import { reactRouterHonoServer } from "react-router-hono-server/dev"; import { defineConfig } from "vite"; import { parse } from "yaml"; -const prefix = process.env.__INTERNAL_PREFIX || "/admin"; -if (prefix.endsWith("/")) { +import { headplaneDevServer } from "./runtime/vite-plugin"; + +const PROD_ENTRY = "./app/server/main.ts"; +const DEV_ENTRY = "./app/server/app.ts"; + +const PREFIX = process.env.__INTERNAL_PREFIX || "/admin"; +if (PREFIX.endsWith("/")) { throw new Error("Prefix must not end with a slash"); } // Derive version: HEADPLANE_VERSION env > git describe > package.json const isNext = process.env.IMAGE_TAG?.includes("next"); -let version: string; +let VERSION: string; if (process.env.HEADPLANE_VERSION) { - version = process.env.HEADPLANE_VERSION; + VERSION = process.env.HEADPLANE_VERSION; } else { try { const describe = execSync("git describe --tags", { encoding: "utf-8" }) @@ -25,13 +29,14 @@ if (process.env.HEADPLANE_VERSION) { const tag = execSync("git describe --tags --abbrev=0", { encoding: "utf-8" }) .trim() .replace(/^v/, ""); - version = describe === tag ? tag : `${tag}-dev+${describe.split("-").pop()}`; + VERSION = describe === tag ? tag : `${tag}-dev+${describe.split("-").pop()}`; } catch { const pkg = await readFile("package.json", "utf-8"); - version = JSON.parse(pkg).version; + VERSION = JSON.parse(pkg).version; } } -if (!version) { + +if (!VERSION) { throw new Error("Unable to determine version"); } @@ -40,8 +45,16 @@ const config = await readFile("config.example.yaml", "utf-8"); const { server } = parse(config); export default defineConfig(({ command, isSsrBuild }) => ({ - base: command === "build" ? `${prefix}/` : undefined, - plugins: [reactRouterHonoServer(), reactRouter(), tailwindcss()], + base: command === "build" ? `${PREFIX}/` : undefined, + plugins: [ + headplaneDevServer({ + entry: DEV_ENTRY, + basename: PREFIX, + publicDir: new URL("./public", import.meta.url).pathname, + }), + reactRouter(), + tailwindcss(), + ], server: { host: server.host, port: server.port, @@ -52,9 +65,16 @@ export default defineConfig(({ command, isSsrBuild }) => ({ build: { target: "baseline-widely-available", sourcemap: true, - rolldownOptions: + rollupOptions: command === "build" ? { + // Override the SSR build entry so React Router emits the + // production bootstrap (`app/server/main.ts`) as + // `build/server/index.js`. It transitively imports the + // SSR entry, which pulls in the React Router server build + // via the virtual module `virtual:react-router/server-build`. + input: isSsrBuild ? PROD_ENTRY : undefined, + // Exclude WASM from the client since it fetches from the server external: isSsrBuild ? [] : [/\.wasm(\?url)?$/], } @@ -64,7 +84,7 @@ export default defineConfig(({ command, isSsrBuild }) => ({ noExternal: command === "build" ? true : undefined, }, define: { - __VERSION__: JSON.stringify(isNext ? `${version}-next` : version), - __PREFIX__: JSON.stringify(prefix), + __VERSION__: JSON.stringify(isNext ? `${VERSION}-next` : VERSION), + __PREFIX__: JSON.stringify(PREFIX), }, }));