From 003985d192b61c0d76834bc33f47063fe6a6c194 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Fri, 3 Apr 2026 16:36:52 -0400 Subject: [PATCH] docs: document the modular architecture for services --- docs/.vitepress/config.ts | 145 ++++++------- docs/development/architecture.md | 338 +++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+), 72 deletions(-) create mode 100644 docs/development/architecture.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 700fbba..7383321 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,77 +1,78 @@ -import { defineConfig } from 'vitepress'; +import { defineConfig } from "vitepress"; export default defineConfig({ - title: 'Headplane', - description: 'The missing dashboard for Headscale', - cleanUrls: true, - head: [['link', { rel: 'icon', href: '/favicon.ico' }]], - themeConfig: { - logo: '/logo.svg', - nav: [ - { text: 'Home', link: '/' }, - { text: 'Changelog', link: '/CHANGELOG' }, - ], - search: { - provider: 'local', - }, - sidebar: [ - { - text: 'Getting Started', - items: [ - { text: 'What is Headplane?', link: '/introduction' }, - { - text: 'Installation', - link: '/install', - items: [ - { text: 'Limited Mode', link: '/install/limited-mode' }, - { text: 'Native Mode', link: '/install/native-mode' }, - { text: 'Docker', link: '/install/docker' }, - ], - }, - { - text: 'Configuration', - link: '/configuration', - items: [ - { text: 'Common Issues', link: '/configuration/common-issues' }, - { - text: 'Sensitive Values', - link: '/configuration#sensitive-values', - }, - ], - }, - { text: 'Nix', link: '/Nix' }, - { text: 'NixOS', link: '/NixOS-options' }, - { - text: 'Features', - items: [ - { text: 'Single Sign-On (SSO)', link: '/features/sso' }, - { text: 'Headplane Agent / SSH', link: '/features/agent' }, - ], - }, - { - text: 'Development', - collapsed: true, - items: [ - { text: 'Contributing', link: '/CONTRIBUTING' }, - { text: 'Security', link: '/SECURITY' }, - ], - }, - ], - }, - ], + title: "Headplane", + description: "The missing dashboard for Headscale", + cleanUrls: true, + head: [["link", { rel: "icon", href: "/favicon.ico" }]], + themeConfig: { + logo: "/logo.svg", + nav: [ + { text: "Home", link: "/" }, + { text: "Changelog", link: "/CHANGELOG" }, + ], + search: { + provider: "local", + }, + sidebar: [ + { + text: "Getting Started", + items: [ + { text: "What is Headplane?", link: "/introduction" }, + { + text: "Installation", + link: "/install", + items: [ + { text: "Limited Mode", link: "/install/limited-mode" }, + { text: "Native Mode", link: "/install/native-mode" }, + { text: "Docker", link: "/install/docker" }, + ], + }, + { + text: "Configuration", + link: "/configuration", + items: [ + { text: "Common Issues", link: "/configuration/common-issues" }, + { + text: "Sensitive Values", + link: "/configuration#sensitive-values", + }, + ], + }, + { text: "Nix", link: "/Nix" }, + { text: "NixOS", link: "/NixOS-options" }, + { + text: "Features", + items: [ + { text: "Single Sign-On (SSO)", link: "/features/sso" }, + { text: "Headplane Agent / SSH", link: "/features/agent" }, + ], + }, + { + text: "Development", + collapsed: true, + items: [ + { text: "Architecture", link: "/development/architecture" }, + { text: "Contributing", link: "/CONTRIBUTING" }, + { text: "Security", link: "/SECURITY" }, + ], + }, + ], + }, + ], - socialLinks: [ - { icon: 'github', link: 'https://github.com/tale/headplane' }, - { icon: 'githubsponsors', link: 'https://github.com/sponsors/tale' }, - { icon: 'kofi', link: 'https://ko-fi.com/atale' }, - ], + socialLinks: [ + { icon: "github", link: "https://github.com/tale/headplane" }, + { icon: "githubsponsors", link: "https://github.com/sponsors/tale" }, + { icon: "kofi", link: "https://ko-fi.com/atale" }, + ], - lastUpdated: { - text: 'Updated at', - formatOptions: { - dateStyle: 'full', - timeStyle: 'medium', - }, - }, - }, + lastUpdated: { + text: "Updated at", + formatOptions: { + dateStyle: "full", + timeStyle: "medium", + }, + }, + }, }); diff --git a/docs/development/architecture.md b/docs/development/architecture.md new file mode 100644 index 0000000..4166313 --- /dev/null +++ b/docs/development/architecture.md @@ -0,0 +1,338 @@ +--- +title: Architecture +description: Service architecture patterns used in Headplane's server code. +outline: [2, 3] +--- + +# Architecture + +Headplane's server code is organized as independent service modules within a +single Node.js process. Each service manages its own state and lifecycle +without relying on a shared god-object or dependency injection framework. + +This page documents the patterns that all server-side services must follow. + +## Core Pattern: Closure Factories + +Every service is a **factory function** that takes its dependencies as +arguments, closes over its private state, and returns a plain object of +functions. No classes, no decorators, no module-level globals. + +```ts +// ✅ Correct: closure factory +export function createOidcService(config: OidcConfig): OidcService { + // Private state — owned by this instance, invisible outside + let endpoints: ResolvedEndpoints | undefined; + let cachedAuthMethod: string | undefined; + + function status() { + if (endpoints) return { state: "ready", endpoints }; + return { state: "pending" }; + } + + async function startFlow() { + // Uses `config` and `endpoints` from closure + } + + function invalidate() { + endpoints = undefined; + cachedAuthMethod = undefined; + } + + return { status, startFlow, invalidate }; +} +``` + +```ts +// ❌ Wrong: module-level global state +let endpoints: ResolvedEndpoints | undefined; + +export function init(config: OidcConfig) { + // Mutates module globals — untestable, import-order fragile +} + +export function startFlow() { + // Reads from module globals — can't have two instances +} +``` + +```ts +// ❌ Wrong: class with `this` +export class OidcService { + private endpoints?: ResolvedEndpoints; + // Adds ceremony without adding value over closures +} +``` + +### Why Closures? + +- **Testable**: Create a fresh instance per test with different config. No + `vi.resetModules()`, no import-order hacks, no singletons to clean up. +- **Composable**: Services can depend on other services by accepting them as + factory arguments. No container registration, no string keys. +- **Hot-reloadable**: Call `service.reload(newConfig)` or create a new + instance. Old state is garbage collected. +- **Explicit**: Every dependency is visible in the factory signature. No + hidden ambient state. + +## Service Interface + +Every service should define a TypeScript interface for its public API. This +is what consumers (routes, other services, tests) depend on — never the +internal implementation. + +```ts +export interface OidcService { + status(): OidcStatus; + startFlow(): Promise>; + handleCallback(params: URLSearchParams, state: FlowState): Promise>; + invalidate(): void; + reload(config: OidcConfig): void; +} +``` + +### Lifecycle Hooks + +Services that run background work (timers, polling, watch loops) should +expose lifecycle hooks. These keep the background behavior local to the +service that owns it: + +```ts +export interface AuthService { + require(request: Request): Promise; + can(principal: Principal, cap: Capabilities): boolean; + // Lifecycle + start(): void; // Begin session pruning interval + stop(): void; // Clear interval, clean up +} + +export function createAuthService(opts: AuthServiceOptions): AuthService { + let pruneTimer: NodeJS.Timeout | undefined; + + return { + require(request) { + /* ... */ + }, + can(principal, cap) { + /* ... */ + }, + start() { + pruneTimer = setInterval(() => void pruneExpiredSessions(), 15 * 60 * 1000); + }, + stop() { + if (pruneTimer) clearInterval(pruneTimer); + }, + }; +} +``` + +## Result Type + +Services that can fail use the shared `Result` type instead of +throwing exceptions. This makes error handling explicit at every call site. + +```ts +import { type Result, ok, err } from "~/server/result"; + +// Returning success +return ok({ url, flowState }); + +// Returning failure +return err({ code: "discovery_failed", message: "..." }); +``` + +Routes and other callers use the discriminated union: + +```ts +const result = await runtime.oidc.startFlow(); +if (!result.ok) { + // result.error is typed — render the right UI + return redirect(`/login?s=${result.error.code}`); +} +// result.value is typed +return redirect(result.value.url); +``` + +`Result` lives in `app/server/result.ts` and is intentionally minimal: + +```ts +type Result = { ok: true; value: T } | { ok: false; error: E }; +``` + +## Composition Root + +All services are wired together in a single place: `server/index.ts`. This +is the **composition root** — the only file that knows about every service +and how they connect. + +```ts +export interface AppRuntime { + config: HeadplaneConfig; + db: DbClient; + auth: AuthService; + oidc?: OidcService; + hsApi: HeadscaleInterface; + agents?: AgentManager; + stop(): Promise; +} + +export async function createAppRuntime(): Promise { + const config = await loadConfig(); + const db = await createDbClient(/* ... */); + const auth = createAuthService({ db /* ... */ }); + const oidc = config.oidc + ? createOidcService({ + /* ... */ + }) + : undefined; + + return { + config, + db, + auth, + oidc, + async stop() { + auth.stop?.(); + }, + }; +} +``` + +React Router's `AppLoadContext` wraps the runtime: + +```ts +const runtime = await createAppRuntime(); + +getLoadContext() { + return { runtime }; +} +``` + +Routes access services through `context.runtime`: + +```ts +export async function loader({ context }: Route.LoaderArgs) { + const principal = await context.runtime.auth.require(request); + // ... +} +``` + +### Dependency Direction + +Services can depend on other services, but only through explicit factory +arguments — never by importing another service's module and reading its +state: + +```ts +// ✅ Correct: explicit dependency +export function createAuthService(opts: { + db: DbClient; + // ... +}): AuthService {} + +// ❌ Wrong: hidden coupling +import { getDb } from "~/server/db"; +export function createAuthService(): AuthService { + const db = getDb(); // Where does this come from? Is it initialized? +} +``` + +## Error Handling + +### Config-Time vs Flow-Time + +Services distinguish between errors that happen during setup (config-time) +and errors that happen during a user action (flow-time). This distinction +determines where and how errors are surfaced: + +| Type | When | UI Surface | Example | +| ----------- | ------------------------ | ------------------------ | ----------------------------------------- | +| Config-time | Before user acts | Banner on login page | `discovery_failed`, `invalid_api_key` | +| Flow-time | After user starts a flow | Redirect with error code | `token_exchange_failed`, `state_mismatch` | +| Non-fatal | During a flow | Logged only | `userinfo_failed` | + +### Error Codes + +Every service error should have a unique `code` string that maps to: + +1. A log message with actionable detail (for the operator) +2. A UI component (for the user) +3. A documentation section (for troubleshooting) + +```ts +export interface OidcError { + code: OidcErrorCode; // Machine-readable, used in URLs and UI switches + message: string; // Human-readable, for server logs only + hint?: string; // Troubleshooting suggestion for logs +} +``` + +## Testing + +### Unit Tests + +Create a fresh service instance per test with the exact config you need. +No mocking frameworks required: + +```ts +import { createOidcService } from "~/server/oidc/provider"; + +test("status is pending before first discovery", () => { + const oidc = createOidcService(testConfig); + expect(oidc.status().state).toBe("pending"); +}); + +test("invalidate clears cached endpoints", async () => { + const oidc = createOidcService(testConfig); + await oidc.discover(); + oidc.invalidate(); + expect(oidc.status().state).toBe("pending"); +}); +``` + +### Faking Services + +For route tests, build a partial runtime with only the services you need: + +```ts +function createTestRuntime(overrides: Partial = {}): AppRuntime { + return { + config: testConfig, + db: createTestDb(), + auth: createTestAuth(), + hsApi: createTestHsApi(), + stop: async () => {}, + ...overrides, + }; +} + +test("login page shows SSO button when OIDC is ready", () => { + const runtime = createTestRuntime({ + oidc: createOidcService(testOidcConfig), + }); + // Test the route loader with this runtime +}); +``` + +### Integration Tests + +Use real OIDC providers in containers (Dex, Keycloak) via `testcontainers` +to test the full flow without browser automation: + +```ts +// Configure Dex with static client + static passwords +// Hit the token endpoint directly +// Validate the entire server-side flow end-to-end +``` + +## Adding a New Service + +1. **Define the interface** in a new file under `app/server//`. +2. **Write the factory** function that takes explicit deps and returns the + interface. Keep state in closure variables. +3. **Add lifecycle hooks** (`start`/`stop`/`reload`/`invalidate`) if the + service has background work or cached state. +4. **Use `Result`** for operations that can fail. Define a typed error + with a `code` field. +5. **Wire it in `createAppRuntime()`** in `server/index.ts`. +6. **Write tests** that create isolated instances — no module mocking needed.