feat: update to the v8 middleware api

This commit is contained in:
Aarnav Tale
2026-06-20 12:38:09 -04:00
parent 3f9dcd5eb4
commit 5d6eef5843
30 changed files with 467 additions and 239 deletions
+16 -13
View File
@@ -9,7 +9,7 @@ runs only on the Node process — never in the browser.
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
├── context.ts ← createAppContext() — assembles the RouterContextProvider data
├── result.ts ← Result<T, E> helper used across the server modules
├── config/ ← YAML config loading, schema, env-overrides, integrations
@@ -26,9 +26,10 @@ 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.
Loads config → builds the application context (via [`context.ts`](./context.ts))
seeds React Router's `RouterContextProvider` with the named service contexts
→ 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
@@ -71,17 +72,19 @@ process:
- 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`.
The returned object owns process-lifetime services, but route handlers consume
those services through named React Router contexts such as `authContext`,
`headscaleContext`, and `headscaleConfigContext`:
When a route needs the type, import it from `~/server/context`:
When a route needs a service, import the matching context from
`~/server/context`:
```ts
import type { AppContext } from "~/server/context";
import { authContext } from "~/server/context";
export async function loader({ context }: LoaderFunctionArgs<AppContext>) {
// …
export async function loader({ context, request }: Route.LoaderArgs) {
const auth = context.get(authContext);
const principal = await auth.require(request);
}
```
@@ -105,7 +108,7 @@ file is loaded, not by runtime conditionals.
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>`.
to the returned object. Expose it through a named React Router context
and seed that context in [`app.ts`](./app.ts)'s `getLoadContext`.
3. If it's purely a helper (pure functions, type definitions), import
it directly from the module that needs it.
+29 -2
View File
@@ -12,6 +12,7 @@
import { exit, versions } from "node:process";
import { createRequestListener } from "@react-router/node";
import { RouterContextProvider } from "react-router";
import * as build from "virtual:react-router/server-build";
import log from "~/utils/log";
@@ -19,7 +20,20 @@ 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";
import {
agentsContext,
appConfigContext,
authContext,
createAppContext,
dbContext,
headscaleApiKeyContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
integrationContext,
oidcContext,
requestApiContext,
} from "./context";
log.info("server", "Running Node.js %s", versions.node);
@@ -65,6 +79,19 @@ export default createRequestListener({
mode: import.meta.env.MODE,
getLoadContext: (request, client) => {
ctx.auth.registerRequestClientAddress(request, client.address);
return ctx;
const routerContext = new RouterContextProvider();
routerContext.set(agentsContext, ctx.agents);
routerContext.set(appConfigContext, ctx.config);
routerContext.set(authContext, ctx.auth);
routerContext.set(dbContext, ctx.db);
routerContext.set(headscaleContext, ctx.headscale);
routerContext.set(headscaleApiKeyContext, ctx.headscaleApiKey);
routerContext.set(headscaleConfigContext, ctx.hs);
routerContext.set(headscaleLiveStoreContext, ctx.hsLive);
routerContext.set(integrationContext, ctx.integration);
routerContext.set(oidcContext, ctx.oidc);
routerContext.set(requestApiContext, ctx.apiForRequest);
return routerContext;
},
});
+13 -4
View File
@@ -1,5 +1,7 @@
import { join } from "node:path";
import { createContext } from "react-router";
import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema";
@@ -14,10 +16,17 @@ import { createOidcService, type OidcService } from "./oidc/provider";
import { createAuthService, type Principal } from "./web/auth";
export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
declare module "react-router" {
interface AppLoadContext extends AppContext {}
}
export const agentsContext = createContext<AppContext["agents"]>();
export const appConfigContext = createContext<AppContext["config"]>();
export const authContext = createContext<AppContext["auth"]>();
export const dbContext = createContext<AppContext["db"]>();
export const headscaleContext = createContext<AppContext["headscale"]>();
export const headscaleApiKeyContext = createContext<AppContext["headscaleApiKey"]>();
export const headscaleConfigContext = createContext<AppContext["hs"]>();
export const headscaleLiveStoreContext = createContext<AppContext["hsLive"]>();
export const integrationContext = createContext<AppContext["integration"]>();
export const oidcContext = createContext<AppContext["oidc"]>();
export const requestApiContext = createContext<AppContext["apiForRequest"]>();
export async function createAppContext(config: HeadplaneConfig) {
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));