diff --git a/app/components/ProgressBar.tsx b/app/components/ProgressBar.tsx
deleted file mode 100644
index 8f8657e..0000000
--- a/app/components/ProgressBar.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
diff --git a/app/components/link.tsx b/app/components/link.tsx
index b8496cf..c4882c8 100644
--- a/app/components/link.tsx
+++ b/app/components/link.tsx
@@ -42,7 +42,7 @@ export default function Link(props: LinkProps): JSX.Element {
}
return (
-
+
{props.children}
);
diff --git a/app/components/menu.tsx b/app/components/menu.tsx
index 5368903..d09ff07 100644
--- a/app/components/menu.tsx
+++ b/app/components/menu.tsx
@@ -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";
diff --git a/app/layout/app.tsx b/app/layout/app.tsx
index 5b49b25..89a3e5e 100644
--- a/app/layout/app.tsx
+++ b/app/layout/app.tsx
@@ -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 {
diff --git a/app/root.tsx b/app/root.tsx
index 9c2f274..77fa08d 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -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 (
- <>
-
-
- >
- );
+ return ;
}
diff --git a/app/routes.ts b/app/routes.ts
index 7ded34c..1c919eb 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -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"),
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 20340c6..d4fe53a 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -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
diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts
index 0b8254f..7f519c3 100644
--- a/app/routes/machines/machine-actions.ts
+++ b/app/routes/machines/machine-actions.ts
@@ -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" };
}
diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx
index 1903471..0df5995 100644
--- a/app/routes/machines/machine.tsx
+++ b/app/routes/machines/machine.tsx
@@ -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]);
diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx
index 01fc91e..faff4d3 100644
--- a/app/routes/machines/overview.tsx
+++ b/app/routes/machines/overview.tsx
@@ -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()) {
diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx
index 0293cab..6dd9f85 100644
--- a/app/routes/settings/auth-keys/overview.tsx
+++ b/app/routes/settings/auth-keys/overview.tsx
@@ -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 }[] = [];
diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx
index 2782303..d0d8a62 100644
--- a/app/routes/users/overview.tsx
+++ b/app/routes/users/overview.tsx
@@ -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 =
diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts
index f146d99..c261496 100644
--- a/app/routes/users/user-actions.ts
+++ b/app/routes/users/user-actions.ts
@@ -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": {
diff --git a/app/routes/util/live.ts b/app/routes/util/live.ts
new file mode 100644
index 0000000..bb1d438
--- /dev/null
+++ b/app/routes/util/live.ts
@@ -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",
+ },
+ });
+}
diff --git a/app/server/db/pruner.ts b/app/server/db/pruner.ts
index f1b32e7..0ad98fc 100644
--- a/app/server/db/pruner.ts
+++ b/app/server/db/pruner.ts
@@ -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);
+ }
}
diff --git a/app/server/headscale/api/index.ts b/app/server/headscale/api/index.ts
index 056351f..fd72432 100644
--- a/app/server/headscale/api/index.ts
+++ b/app/server/headscale/api/index.ts
@@ -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 | 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 | 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,
- ): Promise;
+ /**
+ * 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,
+ ): Promise;
- /**
- * 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(
- method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
- apiPath: `v1/${string}`,
- apiKey: string,
- bodyOrQuery?: Record,
- ): Promise;
- };
+ /**
+ * 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(
+ method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
+ apiPath: `v1/${string}`,
+ apiKey: string,
+ bodyOrQuery?: Record,
+ ): Promise;
+ };
}
/**
@@ -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 {
- const undiciAgent = await createUndiciAgent(certPath);
- let openapiHashes: Record | null = null;
- let apiVersion: Version;
+ const undiciAgent = await createUndiciAgent(certPath);
+ let openapiHashes: Record | null = null;
+ let apiVersion: Version;
- const rawFetch = async (
- url: string,
- options?: Partial,
- ): Promise => {
- const method = options?.method ?? 'GET';
- log.debug('api', '%s %s', method, url);
+ const rawFetch = async (
+ url: string,
+ options?: Partial,
+ ): Promise => {
+ 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 (
- method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
- apiPath: `v1/${string}`,
- apiKey: string,
- bodyOrQuery?: Record,
- ): Promise => {
- let url = `/api/${apiPath}`;
- const options: Partial = {
- method: method,
- headers: {
- Authorization: `Bearer ${apiKey}`,
- },
- };
+ const apiFetch = async (
+ method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
+ apiPath: `v1/${string}`,
+ apiKey: string,
+ bodyOrQuery?: Record,
+ ): Promise => {
+ let url = `/api/${apiPath}`;
+ const options: Partial = {
+ 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;
+ } 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;
- } catch {
- return null;
- }
- })();
+ return res.body.json() as Promise;
+ };
- 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 | 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;
- };
+ 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 | 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 {
- 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 = {};
- const seen = new Set();
+ const hashes: Record = {};
+ const seen = new Set();
- 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;
}
diff --git a/app/server/headscale/live-store.ts b/app/server/headscale/live-store.ts
new file mode 100644
index 0000000..92b83af
--- /dev/null
+++ b/app/server/headscale/live-store.ts
@@ -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 {
+ /**
+ * 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;
+}
+
+/**
+ * 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(
+ key: string,
+ config: Omit, "key">,
+): ResourceDefinition {
+ 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 {
+ data: T;
+ version: string;
+ fetchedAt: number;
+}
+
+type ChangeListener = (resourceKey: string, version: string) => void;
+
+export interface LiveStore {
+ get(resource: ResourceDefinition, apiClient: RuntimeApiClient): Promise>;
+ refresh(resource: ResourceDefinition, apiClient: RuntimeApiClient): Promise;
+ getVersions(): Record;
+ subscribe(listener: ChangeListener): () => void;
+ dispose(): void;
+}
+
+export function createLiveStore(resources: ResourceDefinition[]): LiveStore {
+ const snapshots = new Map>();
+ const serializedCache = new Map();
+ const listeners = new Set();
+ const intervals = new Map>();
+ 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,
+ apiClient: RuntimeApiClient,
+ ): Promise {
+ 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 = {
+ 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) {
+ 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 | undefined {
+ return resources.find((r) => r.key === key);
+ }
+
+ return {
+ async get(
+ resource: ResourceDefinition,
+ apiClient: RuntimeApiClient,
+ ): Promise> {
+ 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;
+ },
+
+ async refresh(resource: ResourceDefinition, apiClient: RuntimeApiClient): Promise {
+ storedApiClient = apiClient;
+ const def = findResource(resource.key);
+ if (!def) {
+ throw new Error(`LiveStore: unknown resource "${resource.key}"`);
+ }
+
+ await fetchResource(def, apiClient);
+ },
+
+ getVersions(): Record {
+ const versions: Record = {};
+ 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;
+ },
+ };
+}
diff --git a/app/server/index.ts b/app/server/index.ts
index f66cbc5..6fe2772 100644
--- a/app/server/index.ts
+++ b/app/server/index.ts
@@ -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,
diff --git a/app/utils/live-data.tsx b/app/utils/live-data.tsx
index 910966c..7467657 100644
--- a/app/utils/live-data.tsx
+++ b/app/utils/live-data.tsx
@@ -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;
+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({});
+ const isTabDirtyRef = useRef(false);
- useEffect(() => {
- const handleVisibilityChange = () => {
- setVisible(document.visibilityState === 'visible');
- if (!paused && document.visibilityState === 'visible') {
- revalidateIfIdle();
- }
- };
+ const eventSourceRef = useRef(null);
+ const reconnectTimer = useRef | 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 (
-
- {children}
-
- );
+ 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 (
+ {children}
+ );
}
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),
+ };
}
diff --git a/package.json b/package.json
index c2c4fbe..318a951 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ae9e632..df470e5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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