feat: ditch hono

This commit is contained in:
Aarnav Tale
2026-04-26 23:52:49 -04:00
parent 5a2098eea5
commit deb284e2b4
14 changed files with 597 additions and 319 deletions
+4
View File
@@ -0,0 +1,4 @@
// Globals replaced at build time by Vite (`define` in `vite.config.ts`).
declare const __PREFIX__: string;
declare const __VERSION__: string;
+2 -2
View File
@@ -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<LoadContext>) {
export async function action({ request, context }: ActionFunctionArgs<AppContext>) {
let principal: Awaited<ReturnType<typeof context.auth.require>> | undefined;
try {
principal = await context.auth.require(request);
+2 -2
View File
@@ -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<LoadContext>) {
export async function loader({ request, context }: LoaderFunctionArgs<AppContext>) {
if (!context.hs.readable()) {
throw new Error("No configuration is available");
}
+111
View File
@@ -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<T, E> 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<AppContext>) {
// …
}
```
## 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.<name>`.
3. If it's purely a helper (pure functions, type definitions), import
it directly from the module that needs it.
+50
View File
@@ -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,
});
+102
View File
@@ -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<ReturnType<typeof createAppContext>>;
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),
};
}
-182
View File
@@ -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);
});
+30
View File
@@ -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,
}),
});
-4
View File
@@ -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"
],
-20
View File
@@ -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 })
+1 -96
View File
@@ -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:
+203
View File
@@ -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<void>((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;
}
+59
View File
@@ -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);
}
});
};
},
};
}
+33 -13
View File
@@ -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),
},
}));