perf: switch to SSE dispatched changes

Previously we would use naive revalidators which would invalidate EVERY
SINGLE action loader every 3 seconds, resulting in several fetches. It
would also bubble fetches across the layout actions into the individual
pages.

This new approach selectively has live stores of resources which then
poll for changes on the server side and then dispatches updates to the
client via a new /events/live SSE endpoint.
This commit is contained in:
Aarnav Tale
2026-03-16 23:30:05 -04:00
parent 27f8fa0b42
commit 25dc09e025
21 changed files with 719 additions and 395 deletions
-27
View File
@@ -1,27 +0,0 @@
import { useProgressBar } from "react-aria";
import cn from "~/utils/cn";
export interface ProgressBarProps {
isVisible: boolean;
}
export default function ProgressBar(props: ProgressBarProps) {
const { isVisible } = props;
const { progressBarProps } = useProgressBar({
label: "Loading...",
isIndeterminate: true,
});
return (
<div
{...progressBarProps}
aria-hidden={!isVisible}
className={cn(
"fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0",
"bg-mist-950 dark:bg-mist-50",
isVisible && "animate-loading opacity-100",
)}
/>
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ export default function Link(props: LinkProps): JSX.Element {
}
return (
<RouterLink to={props.to} className={props.className}>
<RouterLink to={props.to} prefetch="intent" className={props.className}>
{props.children}
</RouterLink>
);
-1
View File
@@ -1,4 +1,3 @@
// oxlint-disable react/no-multi-comp
import { Menu as BaseMenu } from "@base-ui/react/menu";
import type { ComponentProps, JSX, ReactNode } from "react";
+22 -3
View File
@@ -1,9 +1,10 @@
import { Outlet, redirect } from "react-router";
import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import { pruneEphemeralNodes } from "~/server/db/pruner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import log from "~/utils/log";
@@ -11,6 +12,24 @@ import type { Route } from "./+types/app";
import Footer from "./footer";
import Header from "./header";
export const shouldRevalidate: ShouldRevalidateFunction = ({
currentUrl,
nextUrl,
formAction,
defaultShouldRevalidate,
}) => {
if (formAction) {
return defaultShouldRevalidate;
}
// Allow programmatic revalidations (e.g. SSE-triggered) where the URL hasn't changed
if (currentUrl.href === nextUrl.href) {
return defaultShouldRevalidate;
}
return false;
};
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
try {
const principal = await context.auth.require(request);
@@ -52,8 +71,8 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
// stale link so the user gets prompted to re-link.
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
try {
const hsUsers = await api.getUsers();
if (!hsUsers.some((u) => u.id === principal.user.headscaleUserId)) {
const usersSnap = await context.hsLive.get(usersResource, api);
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
await context.auth.unlinkHeadscaleUser(principal.user.id);
}
} catch {
+2 -10
View File
@@ -1,9 +1,8 @@
import type { LinksFunction, MetaFunction } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import "@fontsource-variable/inter";
import { ExternalScripts } from "remix-utils/external-scripts";
import ProgressBar from "~/components/ProgressBar";
import ToastProvider from "~/components/ToastProvider";
import { LiveDataProvider } from "~/utils/live-data";
import { useToastQueue } from "~/utils/toast";
@@ -60,12 +59,5 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
}
export default function App() {
const nav = useNavigation();
return (
<>
<ProgressBar isVisible={nav.state === "loading"} />
<Outlet />
</>
);
return <Outlet />;
}
+1
View File
@@ -6,6 +6,7 @@ export default [
// API Routes
...prefix("/api", [route("/info", "routes/util/info.ts")]),
...prefix("/events", [route("/live", "routes/util/live.ts")]),
// Authentication Routes
route("/login", "routes/auth/login/page.tsx"),
+7 -4
View File
@@ -10,6 +10,7 @@ import Button from "~/components/button";
import Card from "~/components/Card";
import Link from "~/components/link";
import LinkAccount from "~/layout/link-account";
import { usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
@@ -33,10 +34,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let headscaleUsers: { id: string; name: string }[] = [];
try {
const [apiUsers, claimed] = await Promise.all([
api.getUsers(),
const [usersSnap, claimed] = await Promise.all([
context.hsLive.get(usersResource, api),
context.auth.claimedHeadscaleUserIds(),
]);
const apiUsers = usersSnap.data;
headscaleUsers = apiUsers
.filter((u) => !claimed.has(u.id))
.map((u) => ({ id: u.id, name: getUserDisplayName(u) }));
@@ -68,8 +71,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let linkedUserName: string | undefined;
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
try {
const users = await api.getUsers();
const hsUser = users.find((u) => u.id === principal.user.headscaleUserId);
const usersSnap = await context.hsLive.get(usersResource, api);
const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId);
linkedUserName = hsUser?.name;
} catch {
// API unavailable, skip linked user resolution
+8
View File
@@ -1,6 +1,7 @@
import { data, redirect } from "react-router";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { nodesResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/machine";
@@ -43,6 +44,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
const node = await api.registerNode(user, registrationKey);
await context.hsLive.refresh(nodesResource, api);
return redirect(`/machines/${node.id}`);
}
@@ -78,16 +80,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
const name = String(formData.get("name"));
await api.renameNode(nodeId, name);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine renamed" };
}
case "delete": {
await api.deleteNode(nodeId);
await context.hsLive.refresh(nodesResource, api);
return redirect("/machines");
}
case "expire": {
await api.expireNode(nodeId);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine expired" };
}
@@ -105,6 +110,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
);
await context.hsLive.refresh(nodesResource, api);
return { success: true as const, message: "Tags updated" };
} catch (error) {
if (isDataWithApiError(error) && error.data.statusCode === 400) {
@@ -169,6 +175,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
await api.approveNodeRoutes(nodeId, newApproved);
await context.hsLive.refresh(nodesResource, api);
return { message: "Routes updated" };
}
@@ -181,6 +188,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}
await api.setNodeUser(nodeId, user);
await context.hsLive.refresh(nodesResource, api);
return { message: "Machine reassigned" };
}
+7 -1
View File
@@ -9,6 +9,7 @@ import Chip from "~/components/Chip";
import Link from "~/components/link";
import StatusCircle from "~/components/StatusCircle";
import Tooltip from "~/components/Tooltip";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import cn from "~/utils/cn";
import { getOSInfo, getTSVersion } from "~/utils/host-info";
import { mapNodes, sortNodeTags } from "~/utils/node-info";
@@ -40,7 +41,12 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
]);
const nodes = nodesSnap.data;
const users = usersSnap.data;
const node = nodes.find((node) => node.id === params.id);
const lookup = await context.agents?.lookup([node.nodeKey]);
+7 -1
View File
@@ -6,6 +6,7 @@ import Input from "~/components/Input";
import Link from "~/components/link";
import PageError from "~/components/page-error";
import Tooltip from "~/components/Tooltip";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortNodeTags } from "~/utils/node-info";
@@ -29,7 +30,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
]);
const nodes = nodesSnap.data;
const users = usersSnap.data;
let magic: string | undefined;
if (context.hs.readable()) {
+3 -1
View File
@@ -6,6 +6,7 @@ import Link from "~/components/link";
import Notice from "~/components/Notice";
import Select from "~/components/Select";
import TableList from "~/components/TableList";
import { usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
@@ -22,7 +23,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const users = await api.getUsers();
const usersSnap = await context.hsLive.get(usersResource, api);
const users = usersSnap.data;
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
let missing: { user: User; error: unknown }[] = [];
+7 -1
View File
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import PageError from "~/components/page-error";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { Capabilities, Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Machine, User } from "~/types";
@@ -55,7 +56,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
try {
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
[nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
]);
nodes = nodesSnap.data;
apiUsers = usersSnap.data;
} catch (error) {
log.warn("api", "Failed to fetch Headscale API data: %s", String(error));
apiError =
+4
View File
@@ -1,5 +1,6 @@
import { data } from "react-router";
import { usersResource } from "~/server/headscale/live-store";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
@@ -38,6 +39,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
}
await api.createUser(name, email, displayName);
await context.hsLive.refresh(usersResource, api);
return { message: "User created successfully" };
}
case "delete_user": {
@@ -49,6 +51,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
}
await api.deleteUser(userId);
await context.hsLive.refresh(usersResource, api);
return { message: "User deleted successfully" };
}
case "rename_user": {
@@ -72,6 +75,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
}
await api.renameUser(userId, newName);
await context.hsLive.refresh(usersResource, api);
return { message: "User renamed successfully" };
}
case "reassign_user": {
+60
View File
@@ -0,0 +1,60 @@
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import log from "~/utils/log";
import type { Route } from "./+types/live";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
// Ensure resources are loaded before streaming
await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
]);
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const send = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
};
const versions = context.hsLive.getVersions();
log.debug("sse", "Client connected, sending hello with versions: %o", versions);
send("hello", versions);
const unsubscribe = context.hsLive.subscribe((resource, version) => {
log.debug("sse", "Sending change event: %s v%s", resource, version);
send("changed", { resource, version });
});
const heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(": heartbeat\n\n"));
} catch {
clearInterval(heartbeat);
}
}, 30_000);
request.signal.addEventListener("abort", () => {
log.debug("sse", "Client disconnected");
unsubscribe();
clearInterval(heartbeat);
try {
controller.close();
} catch {}
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
+5
View File
@@ -1,5 +1,6 @@
import { eq, isNotNull } from "drizzle-orm";
import { nodesResource } from "~/server/headscale/live-store";
import log from "~/utils/log";
import type { Route } from "../../layout/+types/app";
@@ -45,4 +46,8 @@ export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs
});
await Promise.all(promises.map((p) => p()));
if (toPrune.length > 0) {
await context.hsLive.refresh(nodesResource, api);
}
}
+264 -276
View File
@@ -1,14 +1,17 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { dereference } from '@readme/openapi-parser';
import type { OpenAPIV2 } from 'openapi-types';
import { data } from 'react-router';
import { Agent, type Dispatcher, request } from 'undici';
import log from '~/utils/log';
import endpointSets, { RuntimeApiClient } from './endpoints';
import { undiciToFriendlyError } from './error';
import { HeadscaleAPIError, isApiError } from './error-client';
import { detectApiVersion, isAtLeast, type Version } from './version';
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dereference } from "@readme/openapi-parser";
import type { OpenAPIV2 } from "openapi-types";
import { data } from "react-router";
import { Agent, type Dispatcher, request } from "undici";
import log from "~/utils/log";
import endpointSets, { RuntimeApiClient } from "./endpoints";
import { undiciToFriendlyError } from "./error";
import { HeadscaleAPIError, isApiError } from "./error-client";
import { detectApiVersion, isAtLeast, type Version } from "./version";
/**
* A low-level composed interface for interacting with the Headscale API.
@@ -19,82 +22,82 @@ import { detectApiVersion, isAtLeast, type Version } from './version';
* determine the implementations of API methods when requested for use.
*/
export interface HeadscaleApiInterface {
/**
* The underlying Undici agent used for making requests.
*/
undiciAgent: Agent;
/**
* The underlying Undici agent used for making requests.
*/
undiciAgent: Agent;
/**
* The base URL of the Headscale API.
*/
baseUrl: string;
/**
* The base URL of the Headscale API.
*/
baseUrl: string;
/**
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
* This is used to determine which implementations of API methods to use.
*/
openapiHashes: Record<string, string> | null;
/**
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
* This is used to determine which implementations of API methods to use.
*/
openapiHashes: Record<string, string> | null;
/**
* The detected API version of the connected Headscale instance.
*/
apiVersion: Version;
/**
* The detected API version of the connected Headscale instance.
*/
apiVersion: Version;
/**
* Retrieves a runtime API client for the given API key.
*
* @param apiKey The API key to use for authentication.
* @returns A `RuntimeApiClient` instance for interacting with the API.
*/
getRuntimeClient(apiKey: string): RuntimeApiClient;
/**
* Retrieves a runtime API client for the given API key.
*
* @param apiKey The API key to use for authentication.
* @returns A `RuntimeApiClient` instance for interacting with the API.
*/
getRuntimeClient(apiKey: string): RuntimeApiClient;
/**
* A set of helper methods made available to API method implementations.
* The idea is to make interacting with the API easier by providing
* common functionality that can be reused across multiple methods.
*/
clientHelpers: {
/**
* Checks if the connected Headscale instance's API version
* is at least the specified version.
*
* @param version The version to check against.
* @returns `true` if the API version is at least the specified version, `false` otherwise.
*/
isAtleast(version: Version): boolean;
/**
* A set of helper methods made available to API method implementations.
* The idea is to make interacting with the API easier by providing
* common functionality that can be reused across multiple methods.
*/
clientHelpers: {
/**
* Checks if the connected Headscale instance's API version
* is at least the specified version.
*
* @param version The version to check against.
* @returns `true` if the API version is at least the specified version, `false` otherwise.
*/
isAtleast(version: Version): boolean;
/**
* Makes a raw fetch request to the Headscale API via the Undici agent.
* This method is used internally by API method implementations
* to make requests to the Headscale API.
*
* @param path The API path to request.
* @param options Optional request options.
* @returns A promise that resolves to the response data.
*/
rawFetch(
path: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData>;
/**
* Makes a raw fetch request to the Headscale API via the Undici agent.
* This method is used internally by API method implementations
* to make requests to the Headscale API.
*
* @param path The API path to request.
* @param options Optional request options.
* @returns A promise that resolves to the response data.
*/
rawFetch(
path: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData>;
/**
* Makes a typed API fetch request to the Headscale API.
* This method is used internally by API method implementations
* to make requests to the Headscale API and parse the response.
*
* @param method The HTTP method to use.
* @param apiPath The API path to request.
* @param apiKey The API key to use for authentication.
* @param bodyOrQuery Optional body or query parameters.
* @returns A promise that resolves to the typed response data.
*/
apiFetch<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T>;
};
/**
* Makes a typed API fetch request to the Headscale API.
* This method is used internally by API method implementations
* to make requests to the Headscale API and parse the response.
*
* @param method The HTTP method to use.
* @param apiPath The API path to request.
* @param apiKey The API key to use for authentication.
* @param bodyOrQuery Optional body or query parameters.
* @returns A promise that resolves to the typed response data.
*/
apiFetch<T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T>;
};
}
/**
@@ -105,183 +108,168 @@ export interface HeadscaleApiInterface {
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
*/
export async function createHeadscaleInterface(
baseUrl: string,
certPath?: string,
baseUrl: string,
certPath?: string,
): Promise<HeadscaleApiInterface> {
const undiciAgent = await createUndiciAgent(certPath);
let openapiHashes: Record<string, string> | null = null;
let apiVersion: Version;
const undiciAgent = await createUndiciAgent(certPath);
let openapiHashes: Record<string, string> | null = null;
let apiVersion: Version;
const rawFetch = async (
url: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData> => {
const method = options?.method ?? 'GET';
log.debug('api', '%s %s', method, url);
const rawFetch = async (
url: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData> => {
const method = options?.method ?? "GET";
log.debug("api", "%s %s", method, url);
try {
const res = await request(new URL(url, baseUrl), {
dispatcher: undiciAgent,
headers: {
...options?.headers,
Accept: 'application/json',
'User-Agent': `Headplane/${__VERSION__}`,
},
try {
const res = await request(new URL(url, baseUrl), {
dispatcher: undiciAgent,
headers: {
...options?.headers,
Accept: "application/json",
"User-Agent": `Headplane/${__VERSION__}`,
},
body: options?.body,
method,
});
body: options?.body,
method,
});
return res;
} catch (error) {
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
throw data(errorBody, {
status: 502,
statusText: 'Bad Gateway',
});
}
};
return res;
} catch (error) {
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
throw data(errorBody, {
status: 502,
statusText: "Bad Gateway",
});
}
};
const apiFetch = async <T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T> => {
let url = `/api/${apiPath}`;
const options: Partial<Dispatcher.RequestOptions> = {
method: method,
headers: {
Authorization: `Bearer ${apiKey}`,
},
};
const apiFetch = async <T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T> => {
let url = `/api/${apiPath}`;
const options: Partial<Dispatcher.RequestOptions> = {
method: method,
headers: {
Authorization: `Bearer ${apiKey}`,
},
};
if (bodyOrQuery) {
if (method === 'GET' || method === 'DELETE') {
// Filter out undefined values
if (bodyOrQuery) {
if (method === "GET" || method === "DELETE") {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(bodyOrQuery)) {
if (value !== undefined) {
params.append(key, String(value));
}
}
const params = new URLSearchParams();
for (const [key, value] of Object.entries(bodyOrQuery)) {
if (value !== undefined) {
params.append(key, String(value));
}
}
if ([...params.keys()].length > 0) {
url += `?${params.toString()}`;
}
} else {
options.body = JSON.stringify(bodyOrQuery);
options.headers = {
...options.headers,
"Content-Type": "application/json",
};
}
}
if ([...params.keys()].length > 0) {
url += `?${params.toString()}`;
}
} else {
options.body = JSON.stringify(bodyOrQuery);
options.headers = {
...options.headers,
'Content-Type': 'application/json',
};
}
}
const res = await rawFetch(url, options);
if (res.statusCode >= 400) {
log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode);
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
const res = await rawFetch(url, options);
if (res.statusCode >= 400) {
log.debug(
'api',
'%s %s failed with status %d',
method,
apiPath,
res.statusCode,
);
throw data(
{
requestUrl: `${method} ${apiPath}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{ status: 502, statusText: "Bad Gateway" },
);
}
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
return res.body.json() as Promise<T>;
};
throw data(
{
requestUrl: `${method} ${apiPath}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{
status: 502,
statusText: 'Bad Gateway',
},
);
}
/**
* Polls the OpenAPI spec endpoint and generates operation hashes.
* This is used to determine which implementations of API methods to use.
*
* @returns A promise that resolves to the OpenAPI operation hashes.
*/
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
try {
const res = await rawFetch("/swagger/v1/openapiv2.json");
if (res.statusCode !== 200) {
log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode);
return null;
}
return res.body.json() as Promise<T>;
};
const body = await res.body.json();
const spec = await dereference(body as OpenAPIV2.Document);
const hashes = generateSpecHashes(spec);
log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length);
return hashes;
} catch (error) {
if (isApiError(error)) {
log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode);
}
/**
* Polls the OpenAPI spec endpoint and generates operation hashes.
* This is used to determine which implementations of API methods to use.
*
* @returns A promise that resolves to the OpenAPI operation hashes.
*/
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
try {
const res = await rawFetch('/swagger/v1/openapiv2.json');
if (res.statusCode !== 200) {
log.error('api', 'Failed to fetch OpenAPI spec: %d', res.statusCode);
return null;
}
return null;
}
}
const body = await res.body.json();
const spec = await dereference(body as OpenAPIV2.Document);
const hashes = generateSpecHashes(spec);
log.debug(
'api',
'OpenAPI hashes updated (%d endpoints)',
Object.keys(hashes).length,
);
return hashes;
} catch (error) {
if (isApiError(error)) {
log.debug('api', 'Failed to fetch OpenAPI spec: %d', error.statusCode);
}
const isAtleast = (version: Version): boolean => {
return isAtLeast(apiVersion, version);
};
return null;
}
}
openapiHashes = await fetchAndHashOpenapi();
apiVersion = detectApiVersion(openapiHashes);
const isAtleast = (version: Version): boolean => {
return isAtLeast(apiVersion, version);
};
setInterval(async () => {
const hashes = await fetchAndHashOpenapi();
if (hashes) {
openapiHashes = hashes;
apiVersion = detectApiVersion(openapiHashes);
}
}, 60_000); // every 60 seconds
openapiHashes = await fetchAndHashOpenapi();
apiVersion = detectApiVersion(openapiHashes);
setInterval(async () => {
const hashes = await fetchAndHashOpenapi();
if (hashes) {
openapiHashes = hashes;
apiVersion = detectApiVersion(openapiHashes);
}
}, 60_000); // every 60 seconds
return {
undiciAgent,
baseUrl,
openapiHashes,
apiVersion,
getRuntimeClient: (apiKey: string) =>
endpointSets(
{
rawFetch,
apiFetch,
isAtleast,
},
apiKey,
),
clientHelpers: {
rawFetch,
apiFetch,
isAtleast,
},
};
return {
undiciAgent,
baseUrl,
openapiHashes,
apiVersion,
getRuntimeClient: (apiKey: string) => {
return endpointSets(
{
rawFetch,
apiFetch,
isAtleast,
},
apiKey,
);
},
clientHelpers: {
rawFetch,
apiFetch,
isAtleast,
},
};
}
/**
@@ -291,50 +279,50 @@ export async function createHeadscaleInterface(
* @returns A promise that resolves to an `Agent` instance.
*/
async function createUndiciAgent(certPath?: string): Promise<Agent> {
if (!certPath) {
return new Agent();
}
if (!certPath) {
return new Agent();
}
try {
log.debug('config', 'Loading certificate from %s', certPath);
const data = await readFile(certPath, 'utf8');
try {
log.debug("config", "Loading certificate from %s", certPath);
const data = await readFile(certPath, "utf8");
log.info('config', 'Using certificate from %s', certPath);
return new Agent({ connect: { ca: data.trim() } });
} catch (error) {
log.error('config', 'Failed to load Headscale TLS cert: %s', error);
log.debug('config', 'Error Details: %o', error);
return new Agent();
}
log.info("config", "Using certificate from %s", certPath);
return new Agent({ connect: { ca: data.trim() } });
} catch (error) {
log.error("config", "Failed to load Headscale TLS cert: %s", error);
log.debug("config", "Error Details: %o", error);
return new Agent();
}
}
function generateSpecHashes(spec: OpenAPIV2.Document) {
const hashes: Record<string, string> = {};
const seen = new Set<string>();
const hashes: Record<string, string> = {};
const seen = new Set<string>();
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== 'object') {
continue;
}
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== "object") {
continue;
}
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify(
{
path,
method: method.toUpperCase(),
parameters,
responses,
},
Object.keys({ path, method, parameters, responses }).sort(),
);
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify(
{
path,
method: method.toUpperCase(),
parameters,
responses,
},
Object.keys({ path, method, parameters, responses }).sort(),
);
const hash = createHash('md5').update(raw).digest('hex').slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final;
}
}
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final;
}
}
return hashes;
return hashes;
}
+193
View File
@@ -0,0 +1,193 @@
import log from "~/utils/log";
import type { RuntimeApiClient } from "./api/endpoints";
/**
* Defines a resource that can be fetched and polled by the live store.
*/
export interface ResourceDefinition<T> {
/**
* A unique key to identify the resource
*/
readonly key: string;
/**
* How often to poll for changes (in milliseconds)
*/
readonly pollInterval: number;
/**
* A callback to fire to get the latest data for this resource
*/
readonly fetch: (client: RuntimeApiClient) => Promise<T>;
}
/**
* Helper function to define a resource with proper typing to be used
* as a keying input for the live store.
* @param key A unique key to identify the resource
* @param config The resource configuration
*/
export function defineResource<T>(
key: string,
config: Omit<ResourceDefinition<T>, "key">,
): ResourceDefinition<T> {
return { key, ...config };
}
export const nodesResource = defineResource("nodes", {
pollInterval: 5_000,
fetch: (api) => api.getNodes(),
});
export const usersResource = defineResource("users", {
pollInterval: 15_000,
fetch: (api) => api.getUsers(),
});
interface Snapshot<T> {
data: T;
version: string;
fetchedAt: number;
}
type ChangeListener = (resourceKey: string, version: string) => void;
export interface LiveStore {
get<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<Snapshot<T>>;
refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void>;
getVersions(): Record<string, string>;
subscribe(listener: ChangeListener): () => void;
dispose(): void;
}
export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveStore {
const snapshots = new Map<string, Snapshot<unknown>>();
const serializedCache = new Map<string, string>();
const listeners = new Set<ChangeListener>();
const intervals = new Map<string, ReturnType<typeof setInterval>>();
let storedApiClient: RuntimeApiClient | undefined;
let versionCounter = 0;
function notifyListeners(resourceKey: string, version: string) {
for (const listener of listeners) {
listener(resourceKey, version);
}
}
async function fetchResource(
resource: ResourceDefinition<unknown>,
apiClient: RuntimeApiClient,
): Promise<void> {
const data = await resource.fetch(apiClient);
const json = JSON.stringify(data);
const previousJson = serializedCache.get(resource.key);
if (previousJson === json) {
log.debug("api", "Live store: %s unchanged", resource.key);
return;
}
const version = String(++versionCounter);
serializedCache.set(resource.key, json);
const snapshot: Snapshot<unknown> = {
data,
version,
fetchedAt: Date.now(),
};
snapshots.set(resource.key, snapshot);
log.debug("api", "Live store: %s updated (v%s)", resource.key, version);
if (previousJson !== undefined) {
notifyListeners(resource.key, version);
}
}
function ensurePolling(resource: ResourceDefinition<unknown>) {
if (intervals.has(resource.key)) {
return;
}
const interval = setInterval(async () => {
if (!storedApiClient) {
return;
}
try {
await fetchResource(resource, storedApiClient);
} catch (error) {
log.error("api", "Live store: failed to poll %s", resource.key, error);
}
}, resource.pollInterval);
intervals.set(resource.key, interval);
log.debug(
"api",
"Live store: started polling %s every %dms",
resource.key,
resource.pollInterval,
);
}
function findResource(key: string): ResourceDefinition<unknown> | undefined {
return resources.find((r) => r.key === key);
}
return {
async get<T>(
resource: ResourceDefinition<T>,
apiClient: RuntimeApiClient,
): Promise<Snapshot<T>> {
storedApiClient = apiClient;
const def = findResource(resource.key);
if (!def) {
throw new Error(`LiveStore: unknown resource "${resource.key}"`);
}
if (!snapshots.has(resource.key)) {
await fetchResource(def, apiClient);
}
ensurePolling(def);
return snapshots.get(resource.key) as Snapshot<T>;
},
async refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void> {
storedApiClient = apiClient;
const def = findResource(resource.key);
if (!def) {
throw new Error(`LiveStore: unknown resource "${resource.key}"`);
}
await fetchResource(def, apiClient);
},
getVersions(): Record<string, string> {
const versions: Record<string, string> = {};
for (const [key, snapshot] of snapshots) {
versions[key] = snapshot.version;
}
return versions;
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
dispose() {
for (const interval of intervals.values()) {
clearInterval(interval);
}
intervals.clear();
snapshots.clear();
serializedCache.clear();
listeners.clear();
storedApiClient = undefined;
},
};
}
+4
View File
@@ -10,6 +10,7 @@ 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 { createHeadplaneAgent } from "./hp-agent";
import { createAuthService } from "./web/auth";
@@ -53,8 +54,11 @@ 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,
+124 -49
View File
@@ -1,67 +1,142 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { useRevalidator } from 'react-router';
import { useInterval } from 'usehooks-ts';
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useRevalidator } from "react-router";
const LiveDataPausedContext = createContext({
paused: false,
setPaused: (_: boolean) => {},
const LiveDataContext = createContext({
paused: false,
setPaused: (_: boolean) => {},
});
interface LiveDataProps {
children: React.ReactNode;
children: React.ReactNode;
}
type Versions = Record<string, string>;
interface ChangedEvent {
resource: string;
version: string;
}
export function LiveDataProvider({ children }: LiveDataProps) {
const [paused, setPaused] = useState(false);
const revalidator = useRevalidator();
const revalidator = useRevalidator();
const [paused, setPaused] = useState(false);
// Document is marked as optional here because it's not available in SSR
// The optional chain means if document is not defined, visible is false
const [visible, setVisible] = useState(
() =>
typeof document !== 'undefined' && document.visibilityState === 'visible',
);
// This ref is a bit sus but it's needed to ensure the SSE handshake does
// not re-establish on every revalidation. The SSE stream is always stable
const revalidatorRef = useRef(revalidator);
revalidatorRef.current = revalidator;
// Function to revalidate safely
const revalidateIfIdle = () => {
if (revalidator.state === 'idle') {
revalidator.revalidate();
}
};
const versionsRef = useRef<Versions>({});
const isTabDirtyRef = useRef(false);
useEffect(() => {
const handleVisibilityChange = () => {
setVisible(document.visibilityState === 'visible');
if (!paused && document.visibilityState === 'visible') {
revalidateIfIdle();
}
};
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const backoffRef = useRef(1000);
window.addEventListener('online', revalidateIfIdle);
document.addEventListener('focus', revalidateIfIdle);
document.addEventListener('visibilitychange', handleVisibilityChange);
const revalidateIfIdle = useCallback(() => {
if (revalidatorRef.current.state === "idle") {
revalidatorRef.current.revalidate();
}
}, []);
return () => {
window.removeEventListener('online', revalidateIfIdle);
document.removeEventListener('focus', revalidateIfIdle);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [paused, revalidator]);
// SSE connection
useEffect(() => {
if (paused) {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
// Poll only when visible and not paused
useInterval(revalidateIfIdle, visible && !paused ? 3000 : null);
return;
}
return (
<LiveDataPausedContext.Provider value={{ paused, setPaused }}>
{children}
</LiveDataPausedContext.Provider>
);
function connect() {
const sse = new EventSource(`${__PREFIX__}/events/live`);
eventSourceRef.current = sse;
sse.addEventListener("hello", (e) => {
backoffRef.current = 1000;
try {
versionsRef.current = JSON.parse(e.data) as Versions;
} catch {}
});
sse.addEventListener("changed", (e) => {
try {
const data = JSON.parse(e.data) as ChangedEvent;
const current = versionsRef.current[data.resource];
if (current !== undefined && data.version === current) {
return;
}
versionsRef.current = {
...versionsRef.current,
[data.resource]: data.version,
};
if (document.visibilityState !== "visible") {
isTabDirtyRef.current = true;
return;
}
revalidateIfIdle();
} catch {}
});
sse.onerror = () => {
sse.close();
eventSourceRef.current = null;
const delay = backoffRef.current;
backoffRef.current = Math.min(delay * 2, 30_000);
reconnectTimer.current = setTimeout(connect, delay);
};
}
connect();
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current);
reconnectTimer.current = null;
}
};
}, [paused, revalidateIfIdle]);
// If the tab becomes visible and is marked dirty, revalidate
useEffect(() => {
const visibilityCallback = () => {
if (document.visibilityState === "visible" && isTabDirtyRef.current) {
isTabDirtyRef.current = false;
revalidateIfIdle();
}
};
document.addEventListener("visibilitychange", visibilityCallback);
return () => {
document.removeEventListener("visibilitychange", visibilityCallback);
};
}, [revalidateIfIdle]);
// Force a revalidation when the app comes back online
useEffect(() => {
window.addEventListener("online", revalidateIfIdle);
return () => {
window.removeEventListener("online", revalidateIfIdle);
};
}, [revalidateIfIdle]);
return (
<LiveDataContext.Provider value={{ paused, setPaused }}>{children}</LiveDataContext.Provider>
);
}
export function useLiveData() {
const context = useContext(LiveDataPausedContext);
return {
pause: () => context.setPaused(true),
resume: () => context.setPaused(false),
};
const context = useContext(LiveDataContext);
return {
pause: () => context.setPaused(true),
resume: () => context.setPaused(false),
};
}
-1
View File
@@ -87,7 +87,6 @@
"typescript": "^5.9.3",
"ulidx": "2.4.1",
"undici": "7.22.0",
"usehooks-ts": "^3.1.1",
"vite": "8.0.0-beta.0",
"vite-tsconfig-paths": "^6.0.4",
"vitepress": "next",
-19
View File
@@ -209,9 +209,6 @@ importers:
undici:
specifier: 7.22.0
version: 7.22.0
usehooks-ts:
specifier: ^3.1.1
version: 3.1.1(react@19.2.4)
vite:
specifier: 8.0.0-beta.0
version: 8.0.0-beta.0(@types/node@25.3.1)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2)
@@ -4073,9 +4070,6 @@ packages:
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
@@ -4813,12 +4807,6 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
usehooks-ts@3.1.1:
resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==}
engines: {node: '>=16.15.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
utf-8-validate@5.0.10:
resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
engines: {node: '>=6.14.2'}
@@ -8923,8 +8911,6 @@ snapshots:
lodash.camelcase@4.3.0: {}
lodash.debounce@4.0.8: {}
lodash@4.17.23: {}
long@5.3.2: {}
@@ -9827,11 +9813,6 @@ snapshots:
dependencies:
react: 19.2.4
usehooks-ts@3.1.1(react@19.2.4):
dependencies:
lodash.debounce: 4.0.8
react: 19.2.4
utf-8-validate@5.0.10:
dependencies:
node-gyp-build: 4.8.4