refactor(server): replace AppContext undefined sentinels with Feature<T>

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e5550-4435-7118-8393-cdcc97042178
This commit is contained in:
Aarnav Tale
2026-05-23 16:32:22 -04:00
parent fb4b0b1404
commit d4eee702e9
25 changed files with 253 additions and 124 deletions
+1 -4
View File
@@ -31,10 +31,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
try { try {
const principal = await context.auth.require(request); const { principal, api } = await context.apiForRequest(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const user = const user =
principal.kind === "oidc" principal.kind === "oidc"
+1 -2
View File
@@ -26,8 +26,7 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
}); });
} }
const apiKey = context.auth.getHeadscaleApiKey(principal); const { api } = await context.apiForRequest(request);
const api = context.hsApi.getRuntimeClient(apiKey);
try { try {
const { policy, updatedAt } = await api.setPolicy(policyData); const { policy, updatedAt } = await api.setPolicy(policyData);
return data({ return data({
+1 -2
View File
@@ -29,8 +29,7 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
}; };
// Try to load the ACL policy from the API. // Try to load the ACL policy from the API.
const apiKey = context.auth.getHeadscaleApiKey(principal); const { api } = await context.apiForRequest(request);
const api = context.hsApi.getRuntimeClient(apiKey);
try { try {
const { policy, updatedAt } = await api.getPolicy(); const { policy, updatedAt } = await api.getPolicy();
flags.writable = updatedAt !== null; flags.writable = updatedAt !== null;
+7 -2
View File
@@ -24,7 +24,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const qp = new URL(request.url).searchParams; const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined; const urlState = qp.get("s") ?? undefined;
const oidcService = context.oidc?.service; const oidcService = context.oidc.state === "enabled" ? context.oidc.value : undefined;
const oidcStatus = oidcService const oidcStatus = oidcService
? await oidcService.discover().then( ? await oidcService.discover().then(
(r) => (r.ok ? oidcService.status() : oidcService.status()), (r) => (r.ok ? oidcService.status() : oidcService.status()),
@@ -32,7 +32,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
) )
: undefined; : undefined;
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") { if (
oidcService &&
context.config.oidc?.disable_api_key_login &&
oidcStatus?.state === "ready" &&
urlState !== "logout"
) {
return redirect("/oidc/start"); return redirect("/oidc/start");
} }
+9 -4
View File
@@ -23,15 +23,20 @@ export async function action({ request, context }: ActionFunctionArgs<AppContext
// ended. Disabled by default because the post_logout_redirect_uri must be // ended. Disabled by default because the post_logout_redirect_uri must be
// pre-registered on the IdP — turning this on without registering it would // pre-registered on the IdP — turning this on without registering it would
// strand users on the IdP's error page. // strand users on the IdP's error page.
if (principal?.kind === "oidc" && context.oidc?.useEndSession && context.oidc.service) { if (
const status = context.oidc.service.status(); principal?.kind === "oidc" &&
context.oidc.state === "enabled" &&
context.config.oidc?.use_end_session
) {
const service = context.oidc.value;
const status = service.status();
if (status.state !== "ready") { if (status.state !== "ready") {
// Trigger discovery if it hasn't happened yet so we can find the // Trigger discovery if it hasn't happened yet so we can find the
// end_session_endpoint without forcing a re-login. // end_session_endpoint without forcing a re-login.
await context.oidc.service.discover(); await service.discover();
} }
const endSessionUrl = context.oidc.service.buildEndSessionUrl(principal.idToken); const endSessionUrl = service.buildEndSessionUrl(principal.idToken);
if (endSessionUrl) { if (endSessionUrl) {
url = endSessionUrl; url = endSessionUrl;
} }
+4 -4
View File
@@ -7,10 +7,10 @@ import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback"; import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const service = context.oidc?.service; if (context.oidc.state !== "enabled") {
if (!service) { throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
throw data("OIDC is not enabled or misconfigured", { status: 501 });
} }
const service = context.oidc.value;
const url = new URL(request.url); const url = new URL(request.url);
if (url.searchParams.toString().length === 0) { if (url.searchParams.toString().length === 0) {
@@ -68,7 +68,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Only persist the id_token when RP-initiated logout is enabled — otherwise // Only persist the id_token when RP-initiated logout is enabled — otherwise
// we'd be storing a credential we never use. // we'd be storing a credential we never use.
const idToken = context.oidc?.useEndSession ? identity.idToken : undefined; const idToken = context.config.oidc?.use_end_session ? identity.idToken : undefined;
return redirect("/", { return redirect("/", {
headers: { headers: {
+3 -3
View File
@@ -10,10 +10,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/"); return redirect("/");
} catch {} } catch {}
const service = context.oidc?.service; if (context.oidc.state !== "enabled") {
if (!service) { throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
throw data("OIDC is not enabled or misconfigured", { status: 501 });
} }
const service = context.oidc.value;
const result = await service.startFlow(); const result = await service.startFlow();
if (!result.ok) { if (!result.ok) {
+3 -5
View File
@@ -24,12 +24,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Unclaimed users they can pick from before anything else. // Unclaimed users they can pick from before anything else.
let unlinked = false; let unlinked = false;
if ( if (
typeof context.oidc === "object" && context.oidc.state === "enabled" &&
principal.kind === "oidc" && principal.kind === "oidc" &&
!principal.user.headscaleUserId !principal.user.headscaleUserId
) { ) {
const apiKey = context.auth.getHeadscaleApiKey(principal); const { api } = await context.apiForRequest(request);
const api = context.hsApi.getRuntimeClient(apiKey);
let headscaleUsers: { id: string; name: string }[] = []; let headscaleUsers: { id: string; name: string }[] = [];
try { try {
@@ -64,8 +63,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} }
// No UI access — show the download/connect page // No UI access — show the download/connect page
const apiKey = context.auth.getHeadscaleApiKey(principal); const { api } = await context.apiForRequest(request);
const api = context.hsApi.getRuntimeClient(apiKey);
let linkedUserName: string | undefined; let linkedUserName: string | undefined;
if (principal.kind === "oidc" && principal.user.headscaleUserId) { if (principal.kind === "oidc" && principal.user.headscaleUserId) {
+1 -2
View File
@@ -7,10 +7,9 @@ import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/machine"; import type { Route } from "./+types/machine";
export async function machineAction({ request, context }: Route.ActionArgs) { export async function machineAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request); const { principal, api } = await context.apiForRequest(request);
const formData = await request.formData(); const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const action = formData.get("action_id")?.toString(); const action = formData.get("action_id")?.toString();
if (!action) { if (!action) {
+5 -5
View File
@@ -22,7 +22,6 @@ import Routes from "./dialogs/routes";
import { machineAction } from "./machine-actions"; import { machineAction } from "./machine-actions";
export async function loader({ request, params, context }: Route.LoaderArgs) { export async function loader({ request, params, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
if (!params.id) { if (!params.id) {
throw new Error("No machine ID provided"); throw new Error("No machine ID provided");
} }
@@ -38,7 +37,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
} }
} }
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal)); const { api } = await context.apiForRequest(request);
const [nodesSnap, usersSnap] = await Promise.all([ const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api), context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api), context.hsLive.get(usersResource, api),
@@ -50,18 +49,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(null, { status: 404 }); throw data(null, { status: 404 });
} }
const lookup = await context.agents?.lookup([node.nodeKey]); const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const lookup = await agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup); const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].toSorted(); const tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0"); const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync(); const agentSync = agents?.lastSync();
return { return {
agent: agentSync agent: agentSync
? { ? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null, syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount, nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(), nodeKey: agents?.agentNodeKey(),
} }
: undefined, : undefined,
existingTags: sortNodeTags(nodes), existingTags: sortNodeTags(nodes),
+5 -4
View File
@@ -30,7 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const writablePermission = context.auth.can(principal, Capabilities.write_machines); const writablePermission = context.auth.can(principal, Capabilities.write_machines);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal)); const { api } = await context.apiForRequest(request);
const [nodesSnap, usersSnap] = await Promise.all([ const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api), context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api), context.hsLive.get(usersResource, api),
@@ -45,17 +45,18 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} }
} }
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey)); const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const stats = await agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats); const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0"); const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync(); const agentSync = agents?.lastSync();
return { return {
agent: agentSync agent: agentSync
? { ? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null, syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount, nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(), nodeKey: agents?.agentNodeKey(),
} }
: undefined, : undefined,
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined, headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
+9 -9
View File
@@ -11,13 +11,13 @@ import { formatTimeDelta } from "~/utils/time";
import type { Route } from "./+types/agent"; import type { Route } from "./+types/agent";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request); await context.auth.require(request);
if (!context.agents) { if (context.agents.state !== "enabled") {
return { enabled: false as const }; return { enabled: false as const, reason: context.agents.reason };
} }
const sync = context.agents.lastSync(); const sync = context.agents.value.lastSync();
return { return {
enabled: true as const, enabled: true as const,
syncedAt: sync.syncedAt?.toISOString() ?? null, syncedAt: sync.syncedAt?.toISOString() ?? null,
@@ -29,12 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export async function action({ request, context }: Route.ActionArgs) { export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request); await context.auth.require(request);
if (!context.agents) { if (context.agents.state !== "enabled") {
return { success: false, error: "Agent is not enabled" }; return { success: false, error: context.agents.reason };
} }
await context.agents.triggerSync(); await context.agents.value.triggerSync();
const sync = context.agents.lastSync(); const sync = context.agents.value.lastSync();
return { success: !sync.error, error: sync.error }; return { success: !sync.error, error: sync.error };
} }
@@ -47,7 +47,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8"> <div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<Title>Headplane Agent</Title> <Title>Headplane Agent</Title>
<Notice title="Agent Not Enabled"> <Notice title="Agent Not Enabled">
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "} {loaderData.reason}. To learn how to set up the agent, visit the{" "}
<Link external styled to="https://headplane.dev/docs/agent"> <Link external styled to="https://headplane.dev/docs/agent">
documentation documentation
</Link> </Link>
+1 -3
View File
@@ -7,9 +7,7 @@ import type { PreAuthKey } from "~/types";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) { export async function authKeysAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request); const { principal, api } = await context.apiForRequest(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys); const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys); const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
+1 -3
View File
@@ -19,9 +19,7 @@ import AuthKeyRow from "./auth-key-row";
import AddAuthKey from "./dialogs/add-auth-key"; import AddAuthKey from "./dialogs/add-auth-key";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request); const { principal, api } = await context.apiForRequest(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const usersSnap = await context.hsLive.get(usersResource, api); const usersSnap = await context.hsLive.get(usersResource, api);
const users = usersSnap.data; const users = usersSnap.data;
+2 -1
View File
@@ -8,7 +8,8 @@ import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) { export async function loader({ context }: Route.LoaderArgs) {
return { return {
config: context.hs.writable(), config: context.hs.writable(),
isOidcEnabled: context.oidc?.service.status().state === "ready", isOidcEnabled:
context.oidc.state === "enabled" && context.oidc.value.status().state === "ready",
}; };
} }
+2 -5
View File
@@ -37,18 +37,15 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(sshErrors.wasm_missing, 405); throw data(sshErrors.wasm_missing, 405);
} }
if (context.agents == null) { if (context.agents.state !== "enabled") {
throw data(sshErrors.agent_required, 400); throw data(sshErrors.agent_required, 400);
} }
const principal = await context.auth.require(request); const { principal, api } = await context.apiForRequest(request);
if (principal.kind === "api_key") { if (principal.kind === "api_key") {
throw data(sshErrors.oidc_required, 403); throw data(sshErrors.oidc_required, 403);
} }
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const hostname = params.id; const hostname = params.id;
const username = new URL(request.url).searchParams.get("user") || undefined; const username = new URL(request.url).searchParams.get("user") || undefined;
+1 -2
View File
@@ -54,8 +54,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiError: string | undefined; let apiError: string | undefined;
try { try {
const apiKey = context.auth.getHeadscaleApiKey(principal); const { api } = await context.apiForRequest(request);
const api = context.hsApi.getRuntimeClient(apiKey);
const [nodesSnap, usersSnap] = await Promise.all([ const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api), context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api), context.hsLive.get(usersResource, api),
+1 -2
View File
@@ -24,8 +24,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
}); });
} }
const apiKey = context.auth.getHeadscaleApiKey(principal); const { api } = await context.apiForRequest(request);
const api = context.hsApi.getRuntimeClient(apiKey);
switch (action) { switch (action) {
case "create_user": { case "create_user": {
const name = formData.get("username")?.toString(); const name = formData.get("username")?.toString();
+1 -3
View File
@@ -4,9 +4,7 @@ import log from "~/utils/log";
import type { Route } from "./+types/live"; import type { Route } from "./+types/live";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request); const { api } = await context.apiForRequest(request);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
// Ensure resources are loaded before streaming // Ensure resources are loaded before streaming
await Promise.all([ await Promise.all([
+10 -1
View File
@@ -36,10 +36,19 @@ try {
} }
const ctx = await createAppContext(config); const ctx = await createAppContext(config);
ctx.auth.start(); ctx.startServices();
export { config }; export { config };
/**
* Disposes the per-process context. Invoked by the production
* supervisor on SIGTERM/SIGINT and by the dev Vite plugin on HMR
* reload.
*/
export async function dispose(): Promise<void> {
await ctx.dispose();
}
// TODO: `getLoadContext` is the right place to handle reverse proxy // TODO: `getLoadContext` is the right place to handle reverse proxy
// translation — better than doing it in the OIDC client because it // translation — better than doing it in the OIDC client because it
// applies to all requests, not just OIDC ones. // applies to all requests, not just OIDC ones.
+123 -50
View File
@@ -5,12 +5,14 @@ import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema"; import type { HeadplaneConfig } from "./config/config-schema";
import { loadIntegration } from "./config/integration"; import { loadIntegration } from "./config/integration";
import { createDbClient } from "./db/client.server"; import { createDbClient } from "./db/client.server";
import { disabled, enabled, type Feature } from "./feature";
import { createHeadscaleInterface } from "./headscale/api"; import { createHeadscaleInterface } from "./headscale/api";
import type { RuntimeApiClient } from "./headscale/api/endpoints";
import { loadHeadscaleConfig } from "./headscale/config-loader"; import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store"; import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
import { createAgentManager } from "./hp-agent"; import { type AgentManager, createAgentManager } from "./hp-agent";
import { createOidcService } from "./oidc/provider"; import { createOidcService, type OidcService } from "./oidc/provider";
import { createAuthService } from "./web/auth"; import { createAuthService, type Principal } from "./web/auth";
export type AppContext = Awaited<ReturnType<typeof createAppContext>>; export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
@@ -29,18 +31,12 @@ export async function createAppContext(config: HeadplaneConfig) {
// falling back to the deprecated oidc.headscale_api_key for compatibility. // falling back to the deprecated oidc.headscale_api_key for compatibility.
const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key; const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key;
let agents; const agents = await buildAgents(
if (headscaleApiKey) { config,
agents = await createAgentManager( hsApi.clientHelpers.isAtleast("0.28.0"),
config.integration?.agent, headscaleApiKey ? hsApi.getRuntimeClient(headscaleApiKey) : undefined,
config.headscale.url, db,
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({ const auth = createAuthService({
secret: config.server.cookie_secret, secret: config.server.cookie_secret,
@@ -54,34 +50,43 @@ export async function createAppContext(config: HeadplaneConfig) {
}, },
}); });
const oidc = const oidc = buildOidc(config, headscaleApiKey);
config.oidc && config.oidc.enabled !== false && headscaleApiKey
? { const hsLive = createLiveStore([nodesResource, usersResource]);
service: createOidcService({ const hs = await loadHeadscaleConfig(
issuer: config.oidc.issuer, config.headscale.config_path,
clientId: config.oidc.client_id, config.headscale.config_strict,
clientSecret: config.oidc.client_secret, config.headscale.dns_records_path,
baseUrl: config.server.base_url ?? "", );
authorizationEndpoint: config.oidc.authorization_endpoint, const integration = await loadIntegration(config.integration);
tokenEndpoint: config.oidc.token_endpoint,
userinfoEndpoint: config.oidc.userinfo_endpoint, // Disposers run in reverse-registration order on shutdown.
endSessionEndpoint: config.oidc.end_session_endpoint, const disposers: Array<() => Promise<void> | void> = [() => auth.stop(), () => hsLive.dispose()];
tokenEndpointAuthMethod: if (agents.state === "enabled") {
config.oidc.token_endpoint_auth_method === "client_secret_jwt" disposers.push(() => agents.value.dispose());
? undefined }
: config.oidc.token_endpoint_auth_method,
usePkce: config.oidc.use_pkce, async function apiForRequest(
scope: config.oidc.scope, request: Request,
subjectClaims: config.oidc.subject_claims, ): Promise<{ principal: Principal; api: RuntimeApiClient }> {
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys, const principal = await auth.require(request);
extraParams: config.oidc.extra_params, const apiKey = auth.getHeadscaleApiKey(principal);
profilePictureSource: config.oidc.profile_picture_source, return { principal, api: hsApi.getRuntimeClient(apiKey) };
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri, }
}),
disableApiKeyLogin: config.oidc.disable_api_key_login, function startServices() {
useEndSession: config.oidc.use_end_session, auth.start();
} }
: undefined;
async function dispose() {
for (const d of [...disposers].reverse()) {
try {
await d();
} catch (error) {
log.warn("server", "Error during shutdown: %s", String(error));
}
}
}
return { return {
config, config,
@@ -91,12 +96,80 @@ export async function createAppContext(config: HeadplaneConfig) {
agents, agents,
auth, auth,
oidc, oidc,
hsLive: createLiveStore([nodesResource, usersResource]), hsLive,
hs: await loadHeadscaleConfig( hs,
config.headscale.config_path, integration,
config.headscale.config_strict, apiForRequest,
config.headscale.dns_records_path, startServices,
), dispose,
integration: await loadIntegration(config.integration),
}; };
} }
function buildOidc(
config: HeadplaneConfig,
headscaleApiKey: string | undefined,
): Feature<OidcService> {
if (!config.oidc) {
return disabled("OIDC is not configured");
}
if (config.oidc.enabled === false) {
return disabled("OIDC is disabled in the configuration");
}
if (!headscaleApiKey) {
return disabled("OIDC requires headscale.api_key to be configured");
}
return enabled(
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,
}),
);
}
async function buildAgents(
config: HeadplaneConfig,
supportsTagOnlyKeys: boolean,
apiClient: RuntimeApiClient | undefined,
db: Awaited<ReturnType<typeof createDbClient>>,
): Promise<Feature<AgentManager>> {
const agentConfig = config.integration?.agent;
if (!agentConfig?.enabled) {
return disabled("Agent is not enabled in the configuration");
}
if (!apiClient) {
return disabled("Agent requires headscale.api_key to be configured");
}
if (!supportsTagOnlyKeys) {
return disabled("Agent requires Headscale 0.28 or newer");
}
const manager = await createAgentManager(
agentConfig,
config.headscale.url,
apiClient,
supportsTagOnlyKeys,
db,
);
if (!manager) {
return disabled("Agent failed to initialize (see logs)");
}
return enabled(manager);
}
+20
View File
@@ -0,0 +1,20 @@
// MARK: Feature<T>
//
// A two-state tagged union used in place of `T | undefined` for
// optional features on the AppContext. Carries a human-readable
// reason when the feature is disabled so loaders can surface it (or
// choose to ignore it).
//
// This is deliberately *not* a Result/Either. It does not represent
// async readiness or retryable failure — it represents "is this
// feature wired up at all." Services that have their own runtime
// status (e.g. OIDC discovery) keep that on the service itself.
export type Feature<T> = { state: "enabled"; value: T } | { state: "disabled"; reason: string };
export const enabled = <T>(value: T): Feature<T> => ({ state: "enabled", value });
export const disabled = (reason: string): Feature<never> => ({
state: "disabled",
reason,
});
+2 -1
View File
@@ -12,7 +12,7 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { composeListener, startHttpServer } from "../../runtime/http"; import { composeListener, startHttpServer } from "../../runtime/http";
import requestListener, { config } from "./app"; import requestListener, { config, dispose } from "./app";
// `import.meta.url` resolves to `build/server/index.js`; the built // `import.meta.url` resolves to `build/server/index.js`; the built
// client lives next to it at `build/client/`. // client lives next to it at `build/client/`.
@@ -27,4 +27,5 @@ startHttpServer({
immutableAssets: true, immutableAssets: true,
requestListener, requestListener,
}), }),
onShutdown: dispose,
}); });
+20 -4
View File
@@ -172,6 +172,13 @@ export interface StartOptions {
listener: RequestListener; listener: RequestListener;
tls?: HttpsServerOptions; tls?: HttpsServerOptions;
logger?: Logger; logger?: Logger;
/**
* Optional async hook invoked on SIGINT/SIGTERM after the HTTP
* server stops accepting new connections but before the process
* exits. Use this to dispose long-lived resources (timers,
* subprocesses, DB handles).
*/
onShutdown?: () => Promise<void> | void;
} }
/** /**
@@ -189,15 +196,24 @@ export function startHttpServer(opts: StartOptions): Server {
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port); log.info("Listening on %s://%s:%s", proto, opts.host, opts.port);
}); });
const shutdown = (signal: string) => { const shutdown = async (signal: string) => {
log.info("Received %s, shutting down...", signal); log.info("Received %s, shutting down...", signal);
server.close(() => process.exit(0)); server.close(async () => {
if (opts.onShutdown) {
try {
await opts.onShutdown();
} catch (err) {
log.error("Error during shutdown hook: %o", err);
}
}
process.exit(0);
});
// Force exit if connections don't drain in time. // Force exit if connections don't drain in time.
setTimeout(() => process.exit(0), 5_000).unref(); setTimeout(() => process.exit(0), 5_000).unref();
}; };
process.once("SIGINT", () => shutdown("SIGINT")); process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => shutdown("SIGTERM")); process.once("SIGTERM", () => void shutdown("SIGTERM"));
return server; return server;
} }
+20 -3
View File
@@ -18,6 +18,11 @@ export interface DevServerOptions {
publicDir: string; publicDir: string;
} }
interface AppModule {
default: RequestListener;
dispose?: () => Promise<void> | void;
}
export function headplaneDevServer(options: DevServerOptions): Plugin { export function headplaneDevServer(options: DevServerOptions): Plugin {
return { return {
name: "headplane:dev-server", name: "headplane:dev-server",
@@ -30,6 +35,12 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
res.end("Server entry not loaded yet"); res.end("Server entry not loaded yet");
}; };
// Track the last-loaded module identity so we can call its
// `dispose` when Vite swaps in a new one on HMR. Without this
// the LiveStore and other long-lived services leak intervals
// and subprocesses on every reload.
let currentModule: AppModule | null = null;
const composed = composeListener({ const composed = composeListener({
basename: options.basename, basename: options.basename,
staticRoot: options.publicDir, staticRoot: options.publicDir,
@@ -43,9 +54,15 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
return () => { return () => {
server.middlewares.use(async (req, res, next) => { server.middlewares.use(async (req, res, next) => {
try { try {
const mod = (await server.ssrLoadModule(options.entry)) as { const mod = (await server.ssrLoadModule(options.entry)) as AppModule;
default: RequestListener; if (currentModule && currentModule !== mod) {
}; try {
await currentModule.dispose?.();
} catch (err) {
server.config.logger.warn(`[headplane:dev-server] dispose failed: ${String(err)}`);
}
}
currentModule = mod;
appListener = mod.default; appListener = mod.default;
composed(req, res); composed(req, res);
} catch (err) { } catch (err) {