diff --git a/Dockerfile b/Dockerfile
index 21c66b57e..afc7b5ded 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,8 +3,11 @@
FROM node:24.18.0-alpine AS base
FROM base AS builder
+ARG TARGETPLATFORM
WORKDIR /app
-RUN apk add --no-cache libc6-compat curl bash && apk update
+# Native dependencies normally use prebuilds, but the build must remain
+# reproducible when a prebuild download times out and compilation is required.
+RUN apk add --no-cache libc6-compat curl bash python3 make g++
RUN corepack enable pnpm
COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./
@@ -22,13 +25,14 @@ COPY --parents ./packages/definitions/src ./
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
- pnpm fetch && \
+ pnpm fetch --ignore-scripts && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
-# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
-# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
+# Install only from the fetched, committed lockfile so local and Docker builds
+# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
+# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
- pnpm install --recursive
+ npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1
COPY . .
@@ -41,6 +45,7 @@ RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
+ --mount=type=cache,id=homarr-next-build-${TARGETPLATFORM},target=/app/apps/nextjs/.next/cache,sharing=locked \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" \
pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx
index ad249e734..e7dc6960d 100644
--- a/apps/docs/docs/advanced/environment-variables/index.mdx
+++ b/apps/docs/docs/advanced/environment-variables/index.mdx
@@ -25,15 +25,13 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
:::
-| Environment Variable | Description | Possible values | Default |
-| ------------------------ | ----------- | --------------- | ------- |
-| ``PUID`` | User ID to run the container as | Any valid user ID | ``0`` |
-| ``PGID`` | Group ID to run the container as | Any valid group ID | ``0`` |
-| ``LOG_LEVEL`` | Log level to use | ``debug`` / ``info`` / ``warn`` / ``error`` | ``info`` |
-| ``NO_EXTERNAL_CONNECTION`` | Disables some requests that need internet connection | `true` or `false` | `false` |
-| ``ENABLE_DNS_CACHING`` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
-
-
+| Environment Variable | Description | Possible values | Default |
+| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
+| `PUID` | User ID to run the container as | Any valid user ID | `0` |
+| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
+| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
+| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
+| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
## Authentication
@@ -43,9 +41,9 @@ See [Single Sign-On](/docs/advanced/single-sign-on) for more informations.
The `SECRET_ENCRYPTION_KEY` is required. If none is specified before starting the container, a random key will be shown in the error message and the container will exit.
-| Environment Variable | Description | Possible values | Default |
-| ------------------------ | ----------- | --------------- | ------- |
-| ``SECRET_ENCRYPTION_KEY`` | Secret used to encrypt secrets in database. | 64 character hex string | - |
+| Environment Variable | Description | Possible values | Default |
+| ----------------------- | ------------------------------------------- | ----------------------- | ------- |
+| `SECRET_ENCRYPTION_KEY` | Secret used to encrypt secrets in database. | 64 character hex string | - |
:::info
A random secret can be generated by using the following command: `openssl rand -hex 32`
@@ -53,27 +51,27 @@ A random secret can be generated by using the following command: `openssl rand -
## Docker
-| Environment Variable | Description | Possible values | Default |
-| ------------------------ | ----------- | --------------- | ------- |
-| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
-| ``DOCKER_PORTS`` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
-| ``DOCKER_SOCKET_PATHS`` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
+| Environment Variable | Description | Possible values | Default |
+| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
+| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
+| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
+| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
## Database
-| Environment Variable | Description | Possible values | Default |
-| ------------------------ | ----------- | --------------- | ------- |
-| ``DB_DRIVER`` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | ``better-sqlite3`` / ``mysql2`` / ``node-postgres`` | ``better-sqlite3`` |
-| ``DB_DIALECT`` | Database dialect to use. | ``sqlite`` / ``mysql`` / ``postgresql`` | ``sqlite`` |
-| ``DB_URL`` | Database URL to connect to. | Any valid database URL | ``/appdata/db/db.sqlite`` |
-| ``DB_HOST`` | Database host to connect to. | Any valid database host | - |
-| ``DB_PORT`` | Database port to connect to. | Any valid database port | - |
-| ``DB_NAME`` | Database name to connect to. | Any valid database name | - |
-| ``DB_USER`` | Database user to connect with. | Any valid database user | - |
-| ``DB_PASSWORD`` | Database password to connect with. | Any valid database password | - |
+
+| Environment Variable | Description | Possible values | Default |
+| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- |
+| `DB_DRIVER` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | `better-sqlite3` / `mysql2` / `node-postgres` | `better-sqlite3` |
+| `DB_DIALECT` | Database dialect to use. | `sqlite` / `mysql` / `postgresql` | `sqlite` |
+| `DB_URL` | Database URL to connect to. | Any valid database URL | `/appdata/db/db.sqlite` |
+| `DB_HOST` | Database host to connect to. | Any valid database host | - |
+| `DB_PORT` | Database port to connect to. | Any valid database port | - |
+| `DB_NAME` | Database name to connect to. | Any valid database name | - |
+| `DB_USER` | Database user to connect with. | Any valid database user | - |
+| `DB_PASSWORD` | Database password to connect with. | Any valid database password | - |
You can either use the url or host, port, name and credentials combined. The URL will be prioritized over the other values.
-
## Redis
By default Redis is running within the installation. (for example in Docker).
@@ -83,25 +81,23 @@ However for example for K8s it can be useful to use an external Redis instance.
External redis is currently not supported with Proxmox Community Scripts
:::
-| Environment Variable | Description | Possible values | Default |
-| ------------------------ | ----------- | --------------- | ------- |
-| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
-| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
-| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
-| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
-| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - |
-| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - |
-| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
-
+| Environment Variable | Description | Possible values | Default |
+| ---------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------ | ------- |
+| `REDIS_IS_EXTERNAL` | Whether Redis is running externally. | `true` / `false` | `false` |
+| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
+| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
+| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
+| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - |
+| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - |
+| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
## Proxy
-| Environment Variable | Description | Possible values | Default |
-| ------------------------ | ----------- | --------------- | ------- |
-| ``HTTP_PROXY`` | HTTP proxy to use | Any valid HTTP proxy URL | - |
-| ``HTTPS_PROXY`` | HTTPS proxy to use | Any valid HTTPS proxy URL | - |
-| ``NO_PROXY`` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - |
-
+| Environment Variable | Description | Possible values | Default |
+| -------------------- | ------------------------------------------------------------------- | ------------------------- | ------- |
+| `HTTP_PROXY` | HTTP proxy to use | Any valid HTTP proxy URL | - |
+| `HTTPS_PROXY` | HTTPS proxy to use | Any valid HTTPS proxy URL | - |
+| `NO_PROXY` | Comma separated list of hosts that should be excluded from proxying | Any valid hostnames | - |
## Advanced deployments
@@ -109,6 +105,6 @@ External redis is currently not supported with Proxmox Community Scripts
The advanced deployments environment variables should only be used if you know what you are doing.
:::
-| Environment Variable | Description |
-| ------------------------ | ----------- |
-| ``DB_MIGRATIONS_DISABLED`` | Disable db migrations. For example for helm charts |
+| Environment Variable | Description |
+| ------------------------ | -------------------------------------------------- |
+| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts |
diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts
index 329cf89f1..2105259be 100644
--- a/apps/nextjs/next.config.ts
+++ b/apps/nextjs/next.config.ts
@@ -46,6 +46,7 @@ const nextConfig: NextConfig = {
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
+ turbopackFileSystemCacheForBuild: true,
useTypeScriptCli: true,
},
turbopack: {
diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index 4a3e0dd3e..3c33a1545 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -59,11 +59,10 @@
"@modelcontextprotocol/sdk": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
- "@tanstack/query-async-storage-persister": "catalog:",
+ "@tanstack/react-devtools": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:",
- "@tanstack/react-query-persist-client": "catalog:",
"@trpc/client": "catalog:",
"@trpc/next": "catalog:",
"@trpc/react-query": "catalog:",
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx b/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx
new file mode 100644
index 000000000..a23164547
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/_client-providers/development-tools.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { usePathname } from "next/navigation";
+import { TanStackDevtools } from "@tanstack/react-devtools";
+import type { TanStackDevtoolsReactPlugin } from "@tanstack/react-devtools";
+import type { QueryClient } from "@tanstack/react-query";
+import { useQueryClient } from "@tanstack/react-query";
+import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
+
+const valueStyle = { fontFamily: "monospace", overflowWrap: "anywhere" } as const;
+const metricStyle = {
+ background: "color-mix(in srgb, currentColor 4%, transparent)",
+ border: "1px solid color-mix(in srgb, currentColor 18%, transparent)",
+ borderRadius: 8,
+ padding: 12,
+} as const;
+
+const readDiagnostics = (queryClient: QueryClient) => {
+ const queries = queryClient.getQueryCache().getAll();
+ const mutations = queryClient.getMutationCache().getAll();
+ const board = document.querySelector("[data-homarr-dev-benchmark-board]");
+ const widgetItems = board?.querySelectorAll('[data-type="item"]').length ?? 0;
+ const readyWidgets = board?.querySelectorAll("[data-homarr-widget-ready]").length ?? 0;
+ const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
+
+ return {
+ active: queries.filter((query) => query.getObserversCount() > 0).length,
+ errors: queries.filter((query) => query.state.status === "error").length,
+ fetching: queries.filter((query) => query.state.fetchStatus === "fetching").length,
+ pendingMutations: mutations.filter((mutation) => mutation.state.status === "pending").length,
+ stale: queries.filter((query) => query.isStale()).length,
+ total: queries.length,
+ readyWidgets,
+ widgetItems,
+ scriptKiB: Math.round(
+ resources
+ .filter((resource) => resource.initiatorType === "script")
+ .reduce((total, resource) => total + resource.decodedBodySize, 0) / 1024,
+ ),
+ };
+};
+
+const Metric = ({ label, value }: { label: string; value: number | string }) => (
+
+);
+
+const HomarrDevtoolsPanel = ({ active }: { active: boolean }) => {
+ const pathname = usePathname();
+ const queryClient = useQueryClient();
+ const [diagnostics, setDiagnostics] = useState(() => readDiagnostics(queryClient));
+
+ useEffect(() => {
+ if (!active) return;
+
+ const update = () => setDiagnostics(readDiagnostics(queryClient));
+ let frame: number | undefined;
+ const scheduleUpdate = () => {
+ if (frame !== undefined) return;
+ frame = window.requestAnimationFrame(() => {
+ frame = undefined;
+ update();
+ });
+ };
+ const unsubscribeQueries = queryClient.getQueryCache().subscribe(scheduleUpdate);
+ const unsubscribeMutations = queryClient.getMutationCache().subscribe(scheduleUpdate);
+ const observer = new MutationObserver(scheduleUpdate);
+ observer.observe(document.body, { childList: true, subtree: true });
+ update();
+
+ return () => {
+ unsubscribeQueries();
+ unsubscribeMutations();
+ observer.disconnect();
+ if (frame !== undefined) window.cancelAnimationFrame(frame);
+ };
+ }, [active, pathname, queryClient]);
+
+ if (!active) return null;
+
+ return (
+
+ );
+};
+
+const developmentToolsPlugins = [
+ {
+ id: "tanstack-query",
+ name: "TanStack Query",
+ render: () => ,
+ },
+ {
+ id: "homarr-performance",
+ name: "Homarr",
+ defaultOpen: true,
+ render: (_element, { devtoolsOpen }) => ,
+ },
+] satisfies TanStackDevtoolsReactPlugin[];
+
+export const DevelopmentTools = () => (
+
+);
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts
deleted file mode 100644
index 3af24c508..000000000
--- a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-persister.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
-import superjson from "superjson";
-
-import { fetchApi } from "@homarr/api/client";
-import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
-
-const queryCacheStorage = {
- getItem: (_key: string) => null as string | null,
- async setItem(_key: string, value: string) {
- const boardId = getActiveQueryCacheBoardId();
- if (!boardId) return;
- await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
- console.warn("[query-cache] persist failed", error);
- });
- },
- async removeItem(_key: string) {
- const boardId = getActiveQueryCacheBoardId();
- if (!boardId) return;
- await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
- console.warn("[query-cache] remove failed", error);
- });
- },
-};
-
-export const createWidgetQueryPersister = () =>
- createAsyncStoragePersister({
- storage: typeof window === "undefined" ? undefined : queryCacheStorage,
- key: queryCacheStoragePrefix,
- throttleTime: 2000,
- serialize: (data) => superjson.stringify(data),
- deserialize: (data) => superjson.parse(data),
- });
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts
new file mode 100644
index 000000000..77aa7c918
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.spec.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { TRPCClientError } from "@trpc/client";
+
+import { createQueryRetry } from "./query-retry";
+
+const createTrpcError = (code: string) => {
+ const error = Object.assign(new Error(code), { data: { code } });
+ Object.setPrototypeOf(error, TRPCClientError.prototype);
+ return error;
+};
+
+describe("createQueryRetry", () => {
+ it.each(["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "UNPROCESSABLE_CONTENT"])(
+ "does not retry the permanent %s response",
+ (code) => {
+ expect(createQueryRetry(3)(0, createTrpcError(code))).toBe(false);
+ },
+ );
+
+ it("retries transient and network failures only up to the configured limit", () => {
+ const retry = createQueryRetry(3);
+
+ expect(retry(0, createTrpcError("INTERNAL_SERVER_ERROR"))).toBe(true);
+ expect(retry(2, new Error("connection reset"))).toBe(true);
+ expect(retry(3, new Error("connection reset"))).toBe(false);
+ });
+});
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts
new file mode 100644
index 000000000..09b5f19c3
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/_client-providers/query-retry.ts
@@ -0,0 +1,20 @@
+import { TRPCClientError } from "@trpc/client";
+
+const nonRetryableTrpcCodes = new Set([
+ "PARSE_ERROR",
+ "BAD_REQUEST",
+ "UNAUTHORIZED",
+ "FORBIDDEN",
+ "NOT_FOUND",
+ "METHOD_NOT_SUPPORTED",
+ "CONFLICT",
+ "PRECONDITION_FAILED",
+ "PAYLOAD_TOO_LARGE",
+ "UNPROCESSABLE_CONTENT",
+]);
+
+export const createQueryRetry = (maxRetries: number) => (failureCount: number, error: unknown) => {
+ if (failureCount >= maxRetries) return false;
+ if (error instanceof TRPCClientError && error.data?.code && nonRetryableTrpcCodes.has(error.data.code)) return false;
+ return true;
+};
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts
new file mode 100644
index 000000000..f82001a5e
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.spec.ts
@@ -0,0 +1,52 @@
+// @vitest-environment jsdom
+
+import { act, createElement } from "react";
+import { createRoot } from "react-dom/client";
+import { describe, expect, it, vi } from "vitest";
+
+import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
+
+describe("getSessionQueryScope", () => {
+ it("is stable when permission order changes", () => {
+ expect(getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view", "integration-use"] } })).toBe(
+ getSessionQueryScope({ user: { id: "user-a", permissions: ["integration-use", "board-view"] } }),
+ );
+ });
+
+ it("changes for a different user or permission set", () => {
+ const original = getSessionQueryScope({ user: { id: "user-a", permissions: ["board-view"] } });
+
+ expect(getSessionQueryScope({ user: { id: "user-b", permissions: ["board-view"] } })).not.toBe(original);
+ expect(getSessionQueryScope({ user: { id: "user-a", permissions: [] } })).not.toBe(original);
+ expect(getSessionQueryScope(null)).toBeNull();
+ });
+
+ it("removes the previous session subtree before requesting a document reload", async () => {
+ const host = document.createElement("div");
+ const root = createRoot(host);
+ const onScopeChange = vi.fn();
+ const renderScope = async (currentScope: string) => {
+ await act(async () => {
+ root.render(
+ createElement(
+ SessionQueryScopeGuard,
+ { currentScope, initialScope: "a", onScopeChange },
+ createElement("span", null, "session-a-secret"),
+ ),
+ );
+ });
+ };
+
+ try {
+ await renderScope("a");
+ expect(host.textContent).toBe("session-a-secret");
+
+ await renderScope("b");
+ expect(host.textContent).not.toContain("session-a-secret");
+ expect(host.querySelector('[role="status"]')?.getAttribute("aria-busy")).toBe("true");
+ expect(onScopeChange).toHaveBeenCalledOnce();
+ } finally {
+ await act(async () => root.unmount());
+ }
+ });
+});
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts
new file mode 100644
index 000000000..d2fa1e732
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/_client-providers/session-query-scope.ts
@@ -0,0 +1,39 @@
+import type { PropsWithChildren } from "react";
+import { createElement, useEffect } from "react";
+
+interface QuerySession {
+ user: {
+ id: string;
+ permissions: string[];
+ };
+}
+
+export const getSessionQueryScope = (session: QuerySession | null | undefined) =>
+ session ? JSON.stringify([session.user.id, session.user.permissions.toSorted()]) : null;
+
+interface SessionQueryScopeGuardProps {
+ initialScope: string | null;
+ currentScope: string | null;
+ onScopeChange: () => void;
+}
+
+export const SessionQueryScopeGuard = ({
+ initialScope,
+ currentScope,
+ onScopeChange,
+ children,
+}: PropsWithChildren) => {
+ const hasScopeChanged = initialScope !== currentScope;
+ useEffect(() => {
+ if (hasScopeChanged) onScopeChange();
+ }, [hasScopeChanged, onScopeChange]);
+
+ if (!hasScopeChanged) return children;
+
+ return createElement("div", {
+ "aria-busy": true,
+ "aria-label": "Refreshing session",
+ role: "status",
+ style: { minHeight: "100dvh" },
+ });
+};
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
index a51dd7fad..535d88e1c 100644
--- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
+++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
@@ -1,11 +1,10 @@
"use client";
import type { PropsWithChildren } from "react";
-import { useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
-import { QueryClient } from "@tanstack/react-query";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
-import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
createWSClient,
httpBatchStreamLink,
@@ -24,23 +23,24 @@ import { TRPCClientError } from "@trpc/client";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
- isPersistableWidgetQueryKey,
- queryCacheBuster,
+ dashboardSupportingQueryPolicies,
queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
} from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
+import { useSession } from "@homarr/auth/client";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
-import { createWidgetQueryPersister } from "./query-cache-persister";
+import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
+import { createQueryRetry } from "./query-retry";
-const ReactQueryDevtools = dynamic(
- () => import("@tanstack/react-query-devtools").then(({ ReactQueryDevtools: DevelopmentTools }) => DevelopmentTools),
- { ssr: false },
-);
+const DevelopmentTools =
+ process.env.NODE_ENV === "development"
+ ? dynamic(() => import("./development-tools").then(({ DevelopmentTools: Tools }) => Tools), { ssr: false })
+ : () => null;
const getWebSocketProtocol = () => {
if (typeof window === "undefined") {
@@ -63,19 +63,46 @@ const constructWebsocketUrl = () => {
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
};
-const wsClient = createWSClient({
- url: constructWebsocketUrl(),
-});
+export function TRPCReactProvider({ children }: PropsWithChildren) {
+ const { data: session } = useSession();
+ const sessionQueryScope = getSessionQueryScope(session);
+ const [initialSessionQueryScope] = useState(() => sessionQueryScope);
-export function TRPCReactProvider(props: PropsWithChildren) {
- const [persister] = useState(() => createWidgetQueryPersister());
+ return (
+
+ {children}
+
+ );
+}
+
+const reloadPage = () => window.location.reload();
+
+const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
+ const wsClient = useMemo(
+ () =>
+ createWSClient({
+ url: constructWebsocketUrl(),
+ lazy: { enabled: true, closeMs: 30_000 },
+ }),
+ [],
+ );
+ useEffect(
+ () => () => {
+ void wsClient.close();
+ },
+ [wsClient],
+ );
const [queryClient] = useState(() => {
const client = new QueryClient({
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
gcTime: queryCacheDefaultGcTimeMs,
- retry: 3,
+ retry: createQueryRetry(env.NODE_ENV === "development" ? 1 : 3),
},
mutations: {
onError(error) {
@@ -93,21 +120,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
},
},
});
- client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
+ client.setQueryDefaults([["widget"]], {
+ refetchInterval: queryCacheDefaultRefetchIntervalMs,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ });
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
client.setQueryDefaults(queryKey, {
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
});
}
+ for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
+ client.setQueryDefaults(queryKey, policy);
+ }
return client;
});
- const [trpcClient] = useState(() => {
+ useEffect(() => () => queryClient.clear(), [queryClient]);
+
+ const trpcClient = useMemo(() => {
return clientApi.createClient({
links: [
loggerLink({
- enabled: (opts) =>
- env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
+ enabled: (opts) => opts.direction === "down" && opts.result instanceof Error,
}),
splitLink({
condition: ({ type }) => type === "subscription",
@@ -147,25 +182,14 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}),
],
});
- });
+ }, [wsClient]);
return (
-
- query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
- },
- }}
- >
- {props.children}
- {process.env.NODE_ENV === "development" && }
-
+
+ {children}
+ {process.env.NODE_ENV === "development" && }
+
);
-}
+};
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx
new file mode 100644
index 000000000..e49fefe5e
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_board-add-menu.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { useCallback } from "react";
+import { Group, Menu } from "@mantine/core";
+import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
+
+import { useSession } from "@homarr/auth/client";
+import { useModalAction } from "@homarr/modals";
+import { AppSelectModal } from "@homarr/modals-collection";
+import { useI18n } from "@homarr/translation/client";
+
+import { useItemActions } from "~/components/board/items/item-actions";
+import { ItemSelectModal } from "~/components/board/items/item-select-modal";
+import { useCategoryActions } from "~/components/board/sections/category/category-actions";
+import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
+import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
+import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
+import { HeaderButton } from "~/components/layout/header/button";
+
+export const BoardAddMenu = () => {
+ const { data: session } = useSession();
+ const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
+ const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
+ const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
+ const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
+ const { addCategoryToEnd } = useCategoryActions();
+ const { addDynamicSection } = useDynamicSectionActions();
+ const { createItem } = useItemActions();
+ const t = useI18n();
+
+ const handleAddCategory = useCallback(
+ () =>
+ openCategoryEditModal(
+ {
+ category: { id: "new", name: "" },
+ onSuccess({ name }) {
+ addCategoryToEnd({ name });
+ },
+ submitLabel: t("section.category.create.submit"),
+ },
+ { title: (translate) => translate("section.category.create.title") },
+ ),
+ [addCategoryToEnd, openCategoryEditModal, t],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ } onClick={() => openItemSelectModal()}>
+ {t("item.action.create")}
+
+ }
+ onClick={() =>
+ openAppSelectModal({
+ onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
+ withCreate: session?.user.permissions.includes("app-create") ?? false,
+ })
+ }
+ >
+ {t("app.action.add")}
+
+ } onClick={() => openIntegrationSelectModal({})}>
+ {t("integration.action.create")}
+
+
+ } onClick={handleAddCategory}>
+ {t("section.category.action.create")}
+
+ } onClick={addDynamicSection}>
+ {t("section.dynamic.action.create")}
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
index 7cc39e4fb..2bb868f7d 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
@@ -1,14 +1,11 @@
import type { Metadata } from "next";
-import { cache, Suspense } from "react";
+import { cache } from "react";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
-import type { PersistedClient } from "@tanstack/react-query-persist-client";
-import superjson from "superjson";
-import { queryCacheBuster } from "@homarr/api/query-cache";
import { makeQueryClient } from "@homarr/api/shared";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
@@ -17,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
-import { getQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -62,7 +58,6 @@ export const createBoardContentPage = >(
}
return acc;
}, new Map());
-
const [integrations] = await Promise.all([
getIntegrationsWithPermissionsAsync(session),
...Array.from(itemsMap).map(([kind, items]) =>
@@ -78,19 +73,12 @@ export const createBoardContentPage = >(
),
]);
- const userId = session?.user.id ?? "anonymous";
-
return (
- <>
-
-
-
-
-
-
-
-
- >
+
+
+
+
+
);
},
generateMetadataAsync: async ({ params }: { params: Promise }): Promise => {
@@ -121,19 +109,3 @@ export const createBoardContentPage = >(
},
};
};
-
-async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
- try {
- const serialized = await getQueryCacheAsync(userId, boardId);
- if (!serialized) return null;
-
- const persisted = superjson.parse(serialized);
- if (!persisted || persisted.buster !== queryCacheBuster) return null;
- if (!persisted.clientState?.queries?.length) return null;
-
- return ;
- } catch (error) {
- logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
- return null;
- }
-}
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx
index 50b0aff66..44d0a77c6 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx
@@ -1,7 +1,24 @@
"use client";
import dynamic from "next/dynamic";
+import { SimpleGrid, Skeleton, Stack } from "@mantine/core";
+
+const BoardLoadingShell = () => (
+
+
+ {[2, 1, 2, 2, 1, 2].map((rows, index) => (
+
+ ))}
+
+
+);
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
ssr: false,
+ loading: BoardLoadingShell,
});
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
index 30c91de40..6036a4905 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
@@ -1,45 +1,36 @@
"use client";
-import { useCallback, useEffect } from "react";
+import { useCallback, useEffect, useState } from "react";
+import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
-import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
-import { Box, Group, Menu, ScrollArea } from "@mantine/core";
+import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
-import {
- IconBox,
- IconBoxAlignTop,
- IconChevronDown,
- IconLayoutBoard,
- IconPencil,
- IconPencilOff,
- IconPlug,
- IconPlus,
- IconReplace,
- IconResize,
- IconSettings,
-} from "@tabler/icons-react";
+import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
-import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
-import { useConfirmModal, useModalAction } from "@homarr/modals";
-import { AppSelectModal } from "@homarr/modals-collection";
+import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
-import { useItemActions } from "~/components/board/items/item-actions";
-import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client";
-import { useCategoryActions } from "~/components/board/sections/category/category-actions";
-import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
-import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
-import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
+import { TourTarget } from "~/components/layout/header/tour-target";
+
+const loadBoardAddMenu = () => import("./_board-add-menu");
+const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined);
+const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), {
+ loading: () => (
+
+
+
+ ),
+});
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
@@ -52,16 +43,16 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return (
<>
- {isEditMode && }
+ {isEditMode && }
{!demoReadOnly && (
-
+
-
+
)}
@@ -69,94 +60,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
);
};
-const AddMenu = () => {
- const { data: session } = useSession();
- const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
- const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
- const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
- const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
- const { addCategoryToEnd } = useCategoryActions();
- const { addDynamicSection } = useDynamicSectionActions();
- const { createItem } = useItemActions();
- const t = useI18n();
-
- const handleAddCategory = useCallback(
- () =>
- openCategoryEditModal(
- {
- category: {
- id: "new",
- name: "",
- },
- onSuccess({ name }) {
- addCategoryToEnd({ name });
- },
- submitLabel: t("section.category.create.submit"),
- },
- {
- title: (t) => t("section.category.create.title"),
- },
- ),
- [addCategoryToEnd, openCategoryEditModal, t],
- );
-
- const handleSelectItem = useCallback(() => {
- openItemSelectModal();
- }, [openItemSelectModal]);
-
- const handleSelectApp = useCallback(() => {
- openAppSelectModal({
- onSelect: (app) => {
- createItem({
- kind: "app",
- options: { appId: app.id },
- });
- },
- withCreate: session?.user.permissions.includes("app-create") ?? false,
- });
- }, [openAppSelectModal, createItem]);
-
- const handleAddIntegration = useCallback(() => {
- openIntegrationSelectModal({});
- }, [openIntegrationSelectModal]);
-
- return (
-
-
-
-
-
-
-
-
-
-
- } onClick={handleSelectItem}>
- {t("item.action.create")}
-
-
- } onClick={handleSelectApp}>
- {t("app.action.add")}
-
-
- } onClick={handleAddIntegration}>
- {t("integration.action.create")}
-
-
-
-
- } onClick={handleAddCategory}>
- {t("section.category.action.create")}
-
-
- } onClick={addDynamicSection}>
- {t("section.dynamic.action.create")}
-
-
-
- );
-};
-
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
@@ -197,21 +100,29 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
usePreventLeaveWithDirty(isEditMode);
return (
-
-
+
+
{isEditMode ? : }
-
+
);
};
const SelectBoardsMenu = () => {
- const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
+ const [isOpen, setIsOpen] = useState(false);
+ const utils = clientApi.useUtils();
+ const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
+ const preloadBoards = () => void utils.board.getAllBoards.prefetch();
return (
-
-
-
+
+
+
@@ -219,6 +130,11 @@ const SelectBoardsMenu = () => {
+ {isPending && (
+
+
+
+ )}
{boards.map((board) => (
{
-
+
);
};
@@ -246,15 +162,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
useEffect(() => {
if (!isDirty) return;
- const handleClick = (event: Event) => {
- const target = (event.target as HTMLElement).closest("a");
-
- if (!target) {
- console.warn("No anchor element found for click event", event);
- return;
- }
+ const handleClick = (event: MouseEvent) => {
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+ const target = event.target instanceof Element ? event.target.closest(anchorSelector) : null;
+ if (!target) return;
event.preventDefault();
+ event.stopPropagation();
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
@@ -280,19 +194,14 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
event.returnValue = true;
};
- const anchors = document.querySelectorAll(anchorSelector);
- anchors.forEach((link) => {
- link.addEventListener("click", handleClick);
- });
+ document.addEventListener("click", handleClick, true);
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
- anchors.forEach((link) => {
- link.removeEventListener("click", handleClick);
- });
+ document.removeEventListener("click", handleClick, true);
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
- }, [isDirty]);
+ }, [isDirty, openConfirmModal, router, t]);
};
diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx
index 3cddc6685..24ebe3d03 100644
--- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
+import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
@@ -11,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
-import { BoardTourProvider } from "~/components/onboarding/board-tour";
+import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
+import { env } from "~/env";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import type { Params } from "./(content)/_creator";
@@ -21,11 +23,6 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
-const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
- if (!hasSession) return <>{children}>;
- return {children} ;
-};
-
interface CreateBoardLayoutProps {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise;
@@ -43,8 +40,32 @@ export const createBoardLayout = ({
}: PropsWithChildren<{
params: Promise;
}>) => {
- const session = await auth();
- const initialBoard = await getInitialBoard(await params).catch((error) => {
+ const resolvedParams = await params;
+ const sessionPromise = auth();
+ const initialBoardPromise = getInitialBoard(resolvedParams).then(
+ (board) => ({ status: "fulfilled", board }) as const,
+ (error: unknown) => ({ status: "rejected", error }) as const,
+ );
+ const colorSchemePromise = getCurrentColorSchemeAsync();
+ const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
+ if (!withTour || !session || env.DEMO_MODE) return false;
+
+ try {
+ const user = await getRscUserSettingsAsync(session.user.id);
+ return user !== undefined && !user.completedBoardTour;
+ } catch (error) {
+ logger.error(new Error("Failed to load the board tour status", { cause: error }));
+ return false;
+ }
+ });
+ const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
+ sessionPromise,
+ initialBoardPromise,
+ colorSchemePromise,
+ shouldRunBoardTourPromise,
+ ]);
+ if (initialBoardResult.status === "rejected") {
+ const { error } = initialBoardResult;
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login");
@@ -60,8 +81,8 @@ export const createBoardLayout = ({
}
throw error;
- });
- const colorScheme = await getCurrentColorSchemeAsync();
+ }
+ const initialBoard = initialBoardResult.board;
return (
@@ -69,7 +90,7 @@ export const createBoardLayout = ({
-
+
}
@@ -78,7 +99,7 @@ export const createBoardLayout = ({
/>
{children}
-
+
diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx
index 598a0a258..ea62beadf 100644
--- a/apps/nextjs/src/app/[locale]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/layout.tsx
@@ -13,13 +13,11 @@ import { notFound } from "next/navigation";
import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl";
+import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
+import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { createLogger } from "@homarr/core/infrastructure/logs";
-import { db } from "@homarr/db";
-import { eq } from "@homarr/db";
-import { getServerSettingsAsync } from "@homarr/db/queries";
-import { users } from "@homarr/db/schema";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { SettingsProvider } from "@homarr/settings";
@@ -82,42 +80,27 @@ export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ locale: SupportedLanguage }>;
}) {
- if (!isLocaleSupported((await props.params).locale)) {
+ const { locale } = await props.params;
+ if (!isLocaleSupported(locale)) {
notFound();
}
- const session = await auth();
- const user = session
- ? await db.query.users
- .findFirst({
- columns: {
- id: true,
- name: true,
- email: true,
- emailVerified: true,
- image: true,
- provider: true,
- homeBoardId: true,
- mobileHomeBoardId: true,
- firstDayOfWeek: true,
- pingIconsEnabled: true,
- enableRightClickOnWidgets: true,
- defaultSearchEngineId: true,
- openSearchInNewTab: true,
- ddgBangs: true,
- completedManageTour: true,
- completedBoardTour: true,
- },
- where: eq(users.id, session.user.id),
- })
- .catch((error: unknown) => {
+ const sessionPromise = auth();
+ const userPromise = sessionPromise.then((session) =>
+ session
+ ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error }));
return null;
})
- : null;
- const serverSettings = await getServerSettingsAsync(db);
- const colorScheme = await getCurrentColorSchemeAsync();
- const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
+ : null,
+ );
+ const [session, user, serverSettings, colorScheme] = await Promise.all([
+ sessionPromise,
+ userPromise,
+ getRscServerSettingsAsync(),
+ getCurrentColorSchemeAsync(),
+ ]);
+ const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
const StackedProvider = composeWrappers([
(innerProps) => {
@@ -156,8 +139,6 @@ export default async function Layout(props: {
(innerProps) => ,
]);
- const { locale } = await props.params;
-
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
{
+ if (!session || nextEnv.DEMO_MODE) return false;
+
+ try {
+ const user = await getRscUserSettingsAsync(session.user.id);
+ return user !== undefined && !user.completedManageTour;
+ } catch (error) {
+ logger.error(new Error("Failed to load the management tour status", { cause: error }));
+ return false;
+ }
+ });
+ const [t, session, shouldRunManageTour] = await Promise.all([
+ getScopedI18n("management.navbar"),
+ sessionPromise,
+ shouldRunManageTourPromise,
+ ]);
const navigationLinks: NavigationLink[] = [
{
label: t("items.home"),
@@ -225,7 +245,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
);
- if (!session) return shell;
-
- return {shell} ;
+ return (
+
+ {shell}
+
+ );
}
diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx
index 76f33a8db..82e50d218 100644
--- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_reset-tours.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useRouter } from "next/navigation";
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
@@ -9,6 +10,7 @@ import { useScopedI18n } from "@homarr/translation/client";
export const ResetTours = () => {
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
+ const router = useRouter();
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
@@ -18,6 +20,7 @@ export const ResetTours = () => {
message: t("resetSuccess.message"),
});
void utils.user.getTourStatus.invalidate();
+ router.refresh();
},
});
diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx
index 3e604ec7f..882842ee7 100644
--- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx
+++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx
@@ -6,37 +6,33 @@ import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@ta
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
-import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
+import { clientApi } from "@homarr/api/client";
+import type { WidgetKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
-import { showSuccessNotification } from "@homarr/notifications";
+import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
-import { getWidgetComponent } from "@homarr/widgets/client";
import { WidgetError } from "@homarr/widgets/errors";
-import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
-import { WidgetEditModal } from "@homarr/widgets/modals";
+import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
+import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal";
import type { Dimensions } from "./_dimension-modal";
import { PreviewDimensionsModal } from "./_dimension-modal";
interface WidgetPreviewPageContentProps {
kind: WidgetKind;
- integrationData: {
- id: string;
- name: string;
- url: string;
- kind: IntegrationKind;
- }[];
}
-export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
+export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps) => {
const settings = useSettings();
const t = useI18n();
- const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
+ const utils = clientApi.useUtils();
+ const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
- const currentDefinition = use(loadWidgetDefinition(kind));
+ const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
const [editMode, setEditMode] = useState(false);
+ const [isEditorLoading, setIsEditorLoading] = useState(false);
const [dimensions, setDimensions] = useState({
width: 128,
height: 128,
@@ -55,31 +51,41 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
},
});
- const handleOpenEditWidgetModal = useCallback(() => {
- openWidgetEditModal(
- {
- kind,
- value: state,
- onSuccessfulEdit: (value) => {
- setState(value);
+ const handleOpenEditWidgetModal = useCallback(async () => {
+ const hasIntegrationSupport = "supportedIntegrations" in currentDefinition;
+ setIsEditorLoading(true);
+ preloadWidgetEditModal();
+ try {
+ const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
+ openWidgetEditModal(
+ {
+ kind,
+ definition: currentDefinition,
+ value: state,
+ onSuccessfulEdit: (value) => {
+ setState(value);
+ },
+ integrationData: integrationData.filter((integration) =>
+ (currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
+ ),
+ integrationSupport: hasIntegrationSupport,
+ settings,
},
- integrationData: integrationData.filter(
- (integration) =>
- "supportedIntegrations" in currentDefinition &&
- (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
- ),
- integrationSupport: "supportedIntegrations" in currentDefinition,
- settings,
- },
- {
- title(t) {
- return `${t("item.edit.title")} - ${t(`widget.${kind}.name`)}`;
+ {
+ title(translate) {
+ return `${translate("item.edit.title")} - ${translate(`widget.${kind}.name`)}`;
+ },
},
- },
- );
- }, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
-
- const Comp = getWidgetComponent(kind);
+ );
+ } catch (error) {
+ showErrorNotification({
+ title: t("common.error"),
+ message: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ setIsEditorLoading(false);
+ }
+ }, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode);
@@ -95,8 +101,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
});
}, [dimensions, openPreviewDimensionsModal]);
- const updateOptions = ({ newOptions }: { newOptions: Record }) =>
- setState({ ...state, options: { ...state.options, newOptions } });
+ const updateOptions = useCallback(
+ ({ newOptions }: { newOptions: Record }) =>
+ setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })),
+ [],
+ );
return (
<>
@@ -109,7 +118,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
)}
>
- void handleOpenEditWidgetModal()}
+ onFocus={preloadWidgetEditModal}
+ onPointerEnter={preloadWidgetEditModal}
+ loading={isEditorLoading}
aria-label={t("common.action.edit")}
>
diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx
index a11bf8e9f..9cdf91241 100644
--- a/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx
@@ -1,24 +1,18 @@
import type { PropsWithChildren } from "react";
-import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
+import { widgetKinds } from "@homarr/definitions";
+import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
-const getLinks = async () => {
- const definitions = await loadAllWidgetDefinitions();
- return [...definitions].map(([key, definition]) => {
- return {
- href: `/widgets/${key}`,
- icon: definition.icon,
- label: key,
- };
- });
-};
-
-export default async function WidgetPreviewLayout({ children }: PropsWithChildren) {
- const links = await getLinks();
+const links = widgetKinds.map((kind) => ({
+ href: `/widgets/${kind}`,
+ icon: widgetCatalogIcons[kind],
+ label: kind,
+}));
+export default function WidgetPreviewLayout({ children }: PropsWithChildren) {
return (
diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx
index 00d54fc1a..454e7a805 100644
--- a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx
+++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx
@@ -2,7 +2,6 @@ import { Suspense } from "react";
import { notFound } from "next/navigation";
import { Center, Loader } from "@mantine/core";
-import { db } from "@homarr/db";
import type { WidgetKind } from "@homarr/definitions";
import { widgetKinds } from "@homarr/widgets/manifest";
@@ -18,19 +17,10 @@ export default async function WidgetPreview(props: Props) {
notFound();
}
- const integrationData = await db.query.integrations.findMany({
- columns: {
- id: true,
- name: true,
- url: true,
- kind: true,
- },
- });
-
return (
}>
-
+
);
diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx
index c40b8a57a..bd6ef5fa3 100644
--- a/apps/nextjs/src/components/board/items/item-content.tsx
+++ b/apps/nextjs/src/components/board/items/item-content.tsx
@@ -1,5 +1,6 @@
-import type { MutableRefObject } from "react";
+import type { ComponentType, MutableRefObject } from "react";
import { Suspense, use, useRef } from "react";
+import type { CardProps } from "@mantine/core";
import { Badge, Card, Center, Loader } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
@@ -11,10 +12,10 @@ import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
-import { getWidgetComponent } from "@homarr/widgets/client";
import { WidgetError } from "@homarr/widgets/errors";
-import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
-import type { WidgetDefinition } from "@homarr/widgets/definition";
+import { getWidgetQueryKeys } from "@homarr/widgets/definition";
+import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition";
+import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import classes from "../sections/item.module.css";
@@ -22,7 +23,7 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
-import { removePersistedWidgetQueries } from "./widget-query-recovery";
+import { removeWidgetDataQueries } from "./widget-query-recovery";
interface BoardItemContentProps {
item: SectionItem;
@@ -34,10 +35,51 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
return undefined;
};
-const WidgetDefinitionLoadError = ({ error, resetErrorBoundary }: FallbackProps) => (
-
-
-
+type BoardItemCardProps = CardProps & {
+ item: SectionItem;
+ innerRef: (element: HTMLDivElement | null) => void;
+};
+
+const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
+ const board = useRequiredBoard();
+
+ return (
+
+ {children}
+
+ );
+};
+
+const WidgetDefinitionLoadError = ({
+ error,
+ resetErrorBoundary,
+ item,
+ innerRef,
+}: FallbackProps & Pick) => (
+
+
+
+
+
);
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
@@ -47,12 +89,17 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
return (
<>
-
+ }
+ >
-
-
+
+
+
+
+
}
>
{
);
};
-interface LoadedBoardItemContentProps extends Omit {
+interface LoadedBoardItemContentProps extends Omit {
innerRef: (element: HTMLDivElement | null) => void;
}
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
- const board = useRequiredBoard();
- const definition = use(loadWidgetDefinition(item.kind));
+ const { definition, Component } = use(loadWidgetResources(item.kind));
return (
-
+
(
@@ -127,9 +155,9 @@ const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: Loaded
>
)}
>
-
+
-
+
);
};
@@ -140,13 +168,13 @@ interface InnerContentProps {
height: number;
widgetStateRef: MutableRefObject | null>;
definition: WidgetDefinition;
+ Component: ComponentType>;
}
-const InnerContent = ({ item, definition, ...dimensions }: InnerContentProps) => {
+const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
- const Comp = getWidgetComponent(item.kind);
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
const newItem = { ...item, options };
const { updateItemOptions } = useItemActions();
@@ -155,13 +183,14 @@ const InnerContent = ({ item, definition, ...dimensions }: InnerContentProps) =>
const widgetSupportsIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
const queryClient = useQueryClient();
+ const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
return (
{({ reset }) => (
{
- removePersistedWidgetQueries(queryClient);
+ removeWidgetDataQueries(queryClient, widgetQueryKeys);
reset();
}}
fallbackRender={({ resetErrorBoundary, error }) => (
@@ -185,22 +214,24 @@ const InnerContent = ({ item, definition, ...dimensions }: InnerContentProps) =>
}
/>
-
- updateOptions({
- newOptions: {
- ...options,
- ...partialNewOptions.newOptions,
- },
- })
- }
- {...dimensions}
- />
+
+
+ updateOptions({
+ newOptions: {
+ ...options,
+ ...partialNewOptions.newOptions,
+ },
+ })
+ }
+ {...dimensions}
+ />
+
)}
diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx
index 813aa85ac..13fa8a16e 100644
--- a/apps/nextjs/src/components/board/items/item-menu.tsx
+++ b/apps/nextjs/src/components/board/items/item-menu.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
@@ -9,12 +9,12 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetDefinition } from "@homarr/widgets/definition";
-import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
-import { ItemMoveModal } from "./item-move-modal";
+import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
+import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
offset: number;
@@ -25,7 +25,8 @@ interface BoardItemMenuProps {
export const BoardItemMenu = (props: BoardItemMenuProps) => {
const { data: session } = useSession();
- if (!session) return null;
+ const [isEditMode] = useEditMode();
+ if (!session || !isEditMode) return null;
return ;
};
@@ -34,13 +35,17 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const t = useI18n();
- const { openModal } = useModalAction(WidgetEditModal);
- const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
+ const { openModal } = useModalAction(LazyWidgetEditModal);
+ const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
const { openConfirmModal } = useConfirmModal();
- const [isEditMode] = useEditMode();
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const hasSupportedIntegrations =
+ "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
- const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
+ const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
+ enabled: isMenuOpen && hasSupportedIntegrations,
+ });
const { gridstack } = useSectionContext().refs;
const settings = useSettings();
@@ -52,12 +57,11 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
}
}, [item, resetErrorBoundary]);
- if (!isEditMode || isPending) return null;
-
const openEditModal = () => {
openModal(
{
kind: item.kind,
+ definition,
value: {
advancedOptions: item.advancedOptions,
options: item.options,
@@ -107,7 +111,7 @@ const BoardItemMenuInner = ({ offset, item, definition, resetErrorBoundary }: Bo
};
return (
-
+
{tItem("menu.label.settings")}
- } onClick={openEditModal}>
+ }
+ onClick={openEditModal}
+ onFocus={preloadWidgetEditModal}
+ onPointerEnter={preloadWidgetEditModal}
+ disabled={hasSupportedIntegrations && isPending}
+ >
{tItem("action.edit")}
}
+ onFocus={preloadItemMoveModal}
+ onPointerEnter={preloadItemMoveModal}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx
index 38e362ede..276e1124e 100644
--- a/apps/nextjs/src/components/board/items/item-move-modal.tsx
+++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx
@@ -9,13 +9,13 @@ import { useI18n } from "@homarr/translation/client";
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
-interface InnerProps {
+export interface ItemMoveModalProps {
gridStack: GridStack;
item: Pick;
columnCount: number;
}
-export const ItemMoveModal = createModal(({ actions, innerProps }) => {
+export const ItemMoveModal = createModal(({ actions, innerProps }) => {
const t = useI18n();
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
const form = useZodForm(
diff --git a/apps/nextjs/src/components/board/items/item-select-data.spec.ts b/apps/nextjs/src/components/board/items/item-select-data.spec.ts
new file mode 100644
index 000000000..4d0e77860
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/item-select-data.spec.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
+
+describe("widget selection lock", () => {
+ it("rejects repeated selection synchronously until the active selection finishes", () => {
+ const lock = { current: false };
+
+ expect(tryLockSelection(lock)).toBe(true);
+ expect(tryLockSelection(lock)).toBe(false);
+
+ unlockSelection(lock);
+ expect(tryLockSelection(lock)).toBe(true);
+ });
+});
+
+describe("resolveMatchingIntegrationsAsync", () => {
+ it("waits for the shared integration query before a cold integration-backed selection", async () => {
+ const ensureDataAsync = vi.fn(async () => [
+ { id: "matching", kind: "sonarr" as const },
+ { id: "other", kind: "radarr" as const },
+ ]);
+
+ await expect(
+ resolveMatchingIntegrationsAsync({
+ hasIntegrationSupport: true,
+ supportedIntegrations: ["sonarr"],
+ currentData: undefined,
+ ensureDataAsync,
+ }),
+ ).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
+ expect(ensureDataAsync).toHaveBeenCalledOnce();
+ });
+
+ it("does not wait for integrations when the widget has no integration support", async () => {
+ const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
+
+ await expect(
+ resolveMatchingIntegrationsAsync({
+ hasIntegrationSupport: false,
+ supportedIntegrations: [],
+ currentData: undefined,
+ ensureDataAsync,
+ }),
+ ).resolves.toEqual([]);
+ expect(ensureDataAsync).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/nextjs/src/components/board/items/item-select-data.ts b/apps/nextjs/src/components/board/items/item-select-data.ts
new file mode 100644
index 000000000..f2b37f700
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/item-select-data.ts
@@ -0,0 +1,28 @@
+import type { IntegrationKind } from "@homarr/definitions";
+
+export const tryLockSelection = (lock: { current: boolean }) => {
+ if (lock.current) return false;
+ lock.current = true;
+ return true;
+};
+
+export const unlockSelection = (lock: { current: boolean }) => {
+ lock.current = false;
+};
+
+export const resolveMatchingIntegrationsAsync = async ({
+ hasIntegrationSupport,
+ supportedIntegrations,
+ currentData,
+ ensureDataAsync,
+}: {
+ hasIntegrationSupport: boolean;
+ supportedIntegrations: readonly IntegrationKind[];
+ currentData: TIntegration[] | undefined;
+ ensureDataAsync: () => Promise;
+}) => {
+ if (!hasIntegrationSupport) return [];
+
+ const integrations = currentData ?? (await ensureDataAsync());
+ return integrations.filter((integration) => supportedIntegrations.includes(integration.kind));
+};
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.module.css b/apps/nextjs/src/components/board/items/item-select-modal.module.css
new file mode 100644
index 000000000..ef8c56d8d
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/item-select-modal.module.css
@@ -0,0 +1,21 @@
+.action {
+ opacity: 0;
+ transition: opacity 150ms ease;
+}
+
+.card:hover .action,
+.card:focus-within .action {
+ opacity: 1;
+}
+
+@media (hover: none), (pointer: coarse) {
+ .action {
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .action {
+ transition: none;
+ }
+}
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx
index 7ceaa20ae..497a63e49 100644
--- a/apps/nextjs/src/components/board/items/item-select-modal.tsx
+++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx
@@ -1,29 +1,46 @@
-import { use, useMemo, useState } from "react";
+import { useMemo, useRef, useState } from "react";
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { IconApi } from "@tabler/icons-react";
+import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
-import { getIconUrl, getIntegrationName } from "@homarr/definitions";
+import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
+import { showErrorNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
-import { WidgetEditModal } from "@homarr/widgets/modals";
-import { loadAllWidgetDefinitions, reduceWidgetOptionsWithDefinition, widgetKinds } from "@homarr/widgets/manifest";
+import { widgetCatalogIcons } from "@homarr/widgets/catalog";
+import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { useItemActions } from "./item-actions";
+import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
+import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
+import classes from "./item-select-modal.module.css";
-export const ItemSelectModal = createModal(({ actions }) => {
- const widgetDefinitions = use(loadAllWidgetDefinitions());
+interface ItemSelectModalContentProps {
+ actions: { closeModal: () => void };
+ innerProps: void;
+ integrationData: RouterOutputs["integration"]["all"] | undefined;
+ customWidgetDefs: RouterOutputs["customWidget"]["all"] | undefined;
+ ensureIntegrationDataAsync: () => Promise;
+}
+
+const ItemSelectModalContent = ({
+ actions,
+ integrationData,
+ customWidgetDefs,
+ ensureIntegrationDataAsync,
+}: ItemSelectModalContentProps) => {
const [search, setSearch] = useState("");
+ const [loadingSelection, setLoadingSelection] = useState(null);
+ const selectionLock = useRef(false);
const t = useI18n();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
- const { openModal: openEditModal } = useModalAction(WidgetEditModal);
- const { data: integrationData } = clientApi.integration.all.useQuery();
- const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
+ const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
const settings = useSettings();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
@@ -33,27 +50,23 @@ export const ItemSelectModal = createModal(({ actions }) => {
widgetKinds
.filter((kind) => kind !== "customApi")
.map((kind) => {
- const definition = widgetDefinitions.get(kind);
- if (!definition) return null;
-
return {
kind,
- supportedIntegrations: (definition.supportedIntegrations ?? []).filter(
+ supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
(integration) => integration !== "mock",
),
- icon: definition.icon,
+ icon: widgetCatalogIcons[kind],
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
};
})
- .filter((item): item is NonNullable => item !== null)
.sort((itemA, itemB) => {
if (itemA.kind === "app") return -1;
if (itemB.kind === "app") return 1;
return itemA.name.localeCompare(itemB.name);
}),
- [t, widgetDefinitions],
+ [t],
);
const filteredItems = useMemo(() => {
@@ -72,56 +85,81 @@ export const ItemSelectModal = createModal(({ actions }) => {
[customWidgetDefs, search],
);
- const handleAddCustomWidget = (definitionId: string) => {
- const itemId = createId();
- const definition = widgetDefinitions.get("customApi");
- if (!definition) return;
-
- const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
- createItem({ id: itemId, kind: "customApi", integrationIds: [] });
- updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
- actions.closeModal();
+ const notifyDefinitionLoadError = (error: unknown) => {
+ showErrorNotification({
+ title: t("common.error"),
+ message: error instanceof Error ? error.message : String(error),
+ });
};
- const handleAdd = (kind: WidgetKind) => {
- const definition = widgetDefinitions.get(kind);
- if (!definition) return;
- const hasIntegrationSupport = "supportedIntegrations" in definition;
+ const handleAddCustomWidget = async (definitionId: string) => {
+ if (!tryLockSelection(selectionLock)) return;
+ setLoadingSelection(`custom:${definitionId}`);
+ try {
+ const definition = await loadWidgetDefinition("customApi");
+ const itemId = createId();
+ const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
+ createItem({ id: itemId, kind: "customApi", integrationIds: [] });
+ updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
+ actions.closeModal();
+ } catch (error) {
+ notifyDefinitionLoadError(error);
+ } finally {
+ unlockSelection(selectionLock);
+ setLoadingSelection(null);
+ }
+ };
- const matchingIntegrations = hasIntegrationSupport
- ? (integrationData ?? []).filter((integration) =>
- (definition.supportedIntegrations ?? []).includes(integration.kind),
- )
- : [];
+ const handleAdd = async (kind: WidgetKind) => {
+ if (!tryLockSelection(selectionLock)) return;
+ setLoadingSelection(kind);
+ preloadWidgetEditModal();
+ try {
+ const definition = await loadWidgetDefinition(kind);
+ const hasIntegrationSupport = "supportedIntegrations" in definition;
- const integrationIds = matchingIntegrations.map((i) => i.id);
- const itemId = createId();
- const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
+ const matchingIntegrations = await resolveMatchingIntegrationsAsync({
+ hasIntegrationSupport,
+ supportedIntegrations: definition.supportedIntegrations ?? [],
+ currentData: integrationData,
+ ensureDataAsync: ensureIntegrationDataAsync,
+ });
- createItem({ id: itemId, kind, integrationIds });
- actions.closeModal();
+ const integrationIds = matchingIntegrations.map((i) => i.id);
+ const itemId = createId();
+ const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
- openEditModal(
- {
- kind,
- value: {
- advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
- options: defaultOptions,
- integrationIds,
+ createItem({ id: itemId, kind, integrationIds });
+ actions.closeModal();
+
+ openEditModal(
+ {
+ kind,
+ definition,
+ value: {
+ advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
+ options: defaultOptions,
+ integrationIds,
+ },
+ onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
+ updateItemOptions({ itemId, newOptions: options });
+ updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
+ updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
+ },
+ integrationData: matchingIntegrations,
+ integrationSupport: hasIntegrationSupport,
+ settings,
},
- onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
- updateItemOptions({ itemId, newOptions: options });
- updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
- updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
+ {
+ title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
},
- integrationData: matchingIntegrations,
- integrationSupport: hasIntegrationSupport,
- settings,
- },
- {
- title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
- },
- );
+ );
+ } catch (error) {
+ notifyDefinitionLoadError(error);
+ } finally {
+ unlockSelection(selectionLock);
+ setLoadingSelection(null);
+ }
};
return (
@@ -130,9 +168,9 @@ export const ItemSelectModal = createModal(({ actions }) => {
onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`}
onSearchKeyDown={(event) => {
- if (event.key === "Enter" && filteredItems.length === 1) {
+ if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
const [item] = filteredItems;
- if (item) handleAdd(item.kind);
+ if (item) void handleAdd(item.kind);
}
}}
>
@@ -140,7 +178,13 @@ export const ItemSelectModal = createModal(({ actions }) => {
handleAdd(item.kind)}
+ onSelect={() => void handleAdd(item.kind)}
+ onIntent={() => {
+ void loadWidgetDefinition(item.kind).catch(() => undefined);
+ preloadWidgetEditModal();
+ }}
+ disabled={loadingSelection !== null}
+ loading={loadingSelection === item.kind}
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
/>
))}
@@ -156,12 +200,12 @@ export const ItemSelectModal = createModal(({ actions }) => {
{filteredCustomWidgets.map((def) => (
e.currentTarget.style.setProperty("--_hover-opacity", "1")}
- onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
+ style={{ overflow: "hidden" }}
+ onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
>
@@ -179,18 +223,24 @@ export const ItemSelectModal = createModal(({ actions }) => {
- handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
+ void handleAddCustomWidget(def.id)}
+ variant="light"
+ size="xs"
+ fullWidth
+ disabled={loadingSelection !== null}
+ loading={loadingSelection === `custom:${def.id}`}
+ >
{t("item.create.addToBoard")}
@@ -206,7 +256,24 @@ export const ItemSelectModal = createModal(({ actions }) => {
)}
);
-}).withOptions({
+};
+
+const ItemSelectModalFrame = (props: Pick) => {
+ const utils = clientApi.useUtils();
+ const { data: integrationData } = clientApi.integration.all.useQuery();
+ const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
+
+ return (
+ utils.integration.all.ensureData()}
+ />
+ );
+};
+
+export const ItemSelectModal = createModal((props) => ).withOptions({
defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect,
});
@@ -214,6 +281,9 @@ export const ItemSelectModal = createModal(({ actions }) => {
const WidgetItem = ({
item,
onSelect,
+ onIntent,
+ disabled,
+ loading,
hasMatchingIntegration,
}: {
item: {
@@ -224,23 +294,27 @@ const WidgetItem = ({
icon: TablerIcon;
};
onSelect: () => void;
+ onIntent: () => void;
+ disabled: boolean;
+ loading: boolean;
hasMatchingIntegration: boolean;
}) => {
const t = useI18n();
return (
e.currentTarget.style.setProperty("--_hover-opacity", "1")}
- onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
+ onFocus={onIntent}
+ onPointerEnter={onIntent}
+ aria-busy={loading || undefined}
>
@@ -257,18 +331,17 @@ const WidgetItem = ({
-
+
{t(`item.create.addToBoard`)}
diff --git a/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx b/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx
new file mode 100644
index 000000000..836017bc1
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/lazy-item-move-modal.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { Suspense, use } from "react";
+import { Center, Loader } from "@mantine/core";
+import { ErrorBoundary } from "react-error-boundary";
+
+import { createModal, modalSizeForm } from "@homarr/modals";
+import { WidgetError } from "@homarr/widgets/errors";
+
+import type { ItemMoveModalProps } from "./item-move-modal";
+
+let itemMoveModalPromise: Promise | undefined;
+
+const loadItemMoveModal = () => {
+ if (itemMoveModalPromise) return itemMoveModalPromise;
+
+ const currentPromise = import("./item-move-modal");
+ itemMoveModalPromise = currentPromise;
+ void currentPromise.catch(() => {
+ if (itemMoveModalPromise === currentPromise) itemMoveModalPromise = undefined;
+ });
+ return currentPromise;
+};
+
+export const preloadItemMoveModal = () => void loadItemMoveModal().catch(() => undefined);
+
+interface LazyItemMoveModalContentProps {
+ actions: { closeModal: () => void };
+ innerProps: ItemMoveModalProps;
+}
+
+const LazyItemMoveModalContent = (props: LazyItemMoveModalContentProps) => {
+ const { ItemMoveModal } = use(loadItemMoveModal());
+ const Component = ItemMoveModal.component;
+ return ;
+};
+
+export const LazyItemMoveModal = createModal((props) => (
+ (
+
+ {
+ itemMoveModalPromise = undefined;
+ resetErrorBoundary();
+ }}
+ />
+
+ )}
+ >
+
+
+
+ }
+ >
+
+
+
+)).withOptions({
+ defaultTitle(t) {
+ return t("item.moveResize.title");
+ },
+ size: modalSizeForm,
+});
diff --git a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx
new file mode 100644
index 000000000..fb7a26bcd
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { Suspense, use } from "react";
+import { Center, Loader } from "@mantine/core";
+import { ErrorBoundary } from "react-error-boundary";
+
+import type { WidgetKind } from "@homarr/definitions";
+import { createModal, modalSizeForm } from "@homarr/modals";
+import { WidgetError } from "@homarr/widgets/errors";
+import type { WidgetEditModalProps } from "@homarr/widgets/modals";
+
+let widgetEditModalPromise: Promise | undefined;
+
+export const loadWidgetEditModal = () => {
+ if (widgetEditModalPromise) return widgetEditModalPromise;
+
+ const currentPromise = import("@homarr/widgets/modals");
+ widgetEditModalPromise = currentPromise;
+ void currentPromise.catch(() => {
+ if (widgetEditModalPromise === currentPromise) widgetEditModalPromise = undefined;
+ });
+ return currentPromise;
+};
+
+export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
+
+interface LazyWidgetEditModalContentProps {
+ actions: { closeModal: () => void };
+ innerProps: WidgetEditModalProps;
+}
+
+const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
+ const { WidgetEditModal } = use(loadWidgetEditModal());
+ const Component = WidgetEditModal.component;
+ return ;
+};
+
+export const LazyWidgetEditModal = createModal>((props) => (
+ (
+
+ {
+ widgetEditModalPromise = undefined;
+ resetErrorBoundary();
+ }}
+ />
+
+ )}
+ >
+
+
+
+ }
+ >
+
+
+
+)).withOptions({
+ keepMounted: true,
+ defaultTitle(t) {
+ return t("item.edit.title");
+ },
+ size: modalSizeForm,
+});
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
index c1ecaa5a0..3b844874f 100644
--- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx
+++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
@@ -1,7 +1,7 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
-import { useCallback, useMemo } from "react";
+import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
@@ -13,7 +13,7 @@ import {
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
-import { useIsFetching, useQueryClient } from "@tanstack/react-query";
+import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -26,13 +26,14 @@ import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition";
+import { getWidgetQueryKeys } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
-import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
-import { ItemMoveModal } from "./item-move-modal";
+import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
+import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface WidgetContextMenuProps {
item: SectionItem;
@@ -43,31 +44,83 @@ interface WidgetContextMenuProps {
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
+ const settings = useSettings();
+
+ if (!session || !settings.enableRightClickOnWidgets) return <>{children}>;
+
+ return (
+
+ {children}
+
+ );
+};
+
+const WidgetContextMenuInner = ({
+ item,
+ definition,
+ widgetStateRef,
+ children,
+ settings,
+}: WidgetContextMenuProps & { settings: ReturnType }) => {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ return (
+
+ {children}
+
+ {isMenuOpen && (
+
+ )}
+
+
+ );
+};
+
+type WidgetContextMenuDropdownProps = Omit & {
+ settings: ReturnType;
+};
+
+const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
- const settings = useSettings();
- const isRightClickEnabled = settings.enableRightClickOnWidgets;
- const { openModal } = useModalAction(WidgetEditModal);
- const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
+ const hasSupportedIntegrations =
+ "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
+ const { openModal } = useModalAction(LazyWidgetEditModal);
+ const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
- const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
+ const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, {
+ enabled: hasSupportedIntegrations,
+ });
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
- const widgetQueryKey = useMemo(
- () => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]),
- [definition, item.kind],
- );
- const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
+ const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
+ const isWidgetFetching =
+ useIsFetching({
+ predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
+ }) > 0;
const handleRefetch = useCallback(() => {
- void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
- }, [queryClient, widgetQueryKey]);
+ void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" })));
+ }, [queryClient, widgetQueryKeys]);
const options = useMemo(
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
@@ -107,6 +160,7 @@ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }
openModal(
{
kind: item.kind,
+ definition,
value: {
advancedOptions: item.advancedOptions,
options: item.options,
@@ -168,135 +222,133 @@ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }
[options, item.id, updateItemOptions, persistBoard, board],
);
- if (!session) return <>{children}>;
- if (!isRightClickEnabled) return <>{children}>;
-
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
return (
-
- {children}
-
- {toggleOptions.length > 0 && (
- <>
- {tMenu("options")}
- {toggleOptions.map(([key]) => (
- handleToggle(key)(!options[key])}>
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
-
- {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
-
-
-
- ))}
- >
- )}
-
- {visibleWidgetActions.length > 0 && (
- <>
- {toggleOptions.length > 0 && }
- {tMenu("actions")}
- {visibleWidgetActions.map((action) => {
- const Icon = action.icon;
- return (
- : undefined}
- onClick={action.onClick}
- disabled={action.disabled}
- color={action.color}
- >
- {String(translateIfNecessary(t, action.label))}
-
- );
- })}
- >
- )}
-
- {isEditMode && (
- <>
- {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && }
- }
- onClick={() => {
- if (!gridstack.current) return;
- openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
- }}
- >
- {tItem("action.moveResize")}
-
- }
- onClick={() => duplicateItem({ itemId: item.id })}
- >
- {tItem("action.duplicate")}
-
- >
- )}
-
+ <>
+ {toggleOptions.length > 0 && (
<>
- {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && }
+ {tMenu("options")}
+ {toggleOptions.map(([key]) => (
+ handleToggle(key)(!options[key])}>
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+
+ {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
+
+
+
+ ))}
+ >
+ )}
+
+ {visibleWidgetActions.length > 0 && (
+ <>
+ {toggleOptions.length > 0 && }
+ {tMenu("actions")}
+ {visibleWidgetActions.map((action) => {
+ const Icon = action.icon;
+ return (
+ : undefined}
+ onClick={action.onClick}
+ disabled={action.disabled}
+ color={action.color}
+ >
+ {String(translateIfNecessary(t, action.label))}
+
+ );
+ })}
+ >
+ )}
+
+ {isEditMode && (
+ <>
+ {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && }
: }
- onClick={handleRefetch}
- disabled={isWidgetFetching}
+ closeMenuOnClick
+ leftSection={ }
+ onFocus={preloadItemMoveModal}
+ onPointerEnter={preloadItemMoveModal}
+ onClick={() => {
+ if (!gridstack.current) return;
+ openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
+ }}
>
-
- {tMenu("refresh")}
-
-
+ {tItem("action.moveResize")}
}
- onClick={openEditModal}
- disabled={isPending}
+ leftSection={ }
+ onClick={() => duplicateItem({ itemId: item.id })}
>
- {tMenu("settings")}
+ {tItem("action.duplicate")}
>
+ )}
- {isEditMode && (
- <>
-
- }
- onClick={() => {
- openConfirmModal({
- title: tItem("remove.title"),
- children: tItem("remove.message"),
- onConfirm: () => removeItem({ itemId: item.id }),
- });
- }}
- >
- {tItem("action.remove")}
-
- >
- )}
-
-
+ <>
+ {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && }
+ : }
+ onClick={handleRefetch}
+ disabled={isWidgetFetching}
+ >
+
+ {tMenu("refresh")}
+
+
+
+ }
+ onClick={openEditModal}
+ onFocus={preloadWidgetEditModal}
+ onPointerEnter={preloadWidgetEditModal}
+ disabled={hasSupportedIntegrations && isPending}
+ >
+ {tMenu("settings")}
+
+ >
+
+ {isEditMode && (
+ <>
+
+ }
+ onClick={() => {
+ openConfirmModal({
+ title: tItem("remove.title"),
+ children: tItem("remove.message"),
+ onConfirm: () => removeItem({ itemId: item.id }),
+ });
+ }}
+ >
+ {tItem("action.remove")}
+
+ >
+ )}
+ >
);
};
interface WidgetQueryStatusProps {
queryClient: QueryClient;
- queryKey: QueryKey;
+ queryKeys: QueryKey[];
isFetching: boolean;
t: TranslationFunction;
}
-const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
- const queries = queryClient.getQueryCache().findAll({ queryKey });
+const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
+ const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))];
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
const ageLabel = useTimeAgo(new Date(latest));
@@ -334,7 +386,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
- {t("item.menu.status.error")} · {ageLabel}
+ {t("item.menu.status.error")}
+ {latest > 0 && ` · ${ageLabel}`}
@@ -345,7 +398,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
- {t("item.menu.status.success")} · {ageLabel}
+ {t("item.menu.status.success")}
+ {latest > 0 && ` · ${ageLabel}`}
);
diff --git a/apps/nextjs/src/components/board/items/widget-hover-metadata.ts b/apps/nextjs/src/components/board/items/widget-hover-metadata.ts
deleted file mode 100644
index 22a75c9b3..000000000
--- a/apps/nextjs/src/components/board/items/widget-hover-metadata.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import { objectEntries } from "@homarr/common";
-import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
-import type { SettingsContextProps } from "@homarr/settings/creator";
-import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
-import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
-
-import type { SectionItem } from "~/app/[locale]/boards/_types";
-
-export type Translator = {
- (key: string): string;
-};
-
-export interface HoverMetadataItem {
- label: string;
- value: string;
-}
-
-const maxMetadataItems = 3;
-
-const skippedOptionTypes: Partial> = {
- app: true,
- anchorNote: true,
- dynamicSelect: true,
- sortableItemList: true,
- multiReleasesRepositories: true,
- umamiEventName: true,
- umamiEventNames: true,
- umamiWebsite: true,
-};
-
-const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
-
-type OptionDefinitionWithRules = WidgetOptionDefinition & {
- shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
- options?: readonly { value: unknown; label: unknown }[];
-};
-
-type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
-
-const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
- const match = definition.options?.find((option) => option.value === value);
- const label = match?.label;
- const fallbackLabel = () => String(value);
- const labelResolvers: Record string> = {
- function: () => (label as (translator: Translator) => string)(t),
- string: () => label as string,
- missing: fallbackLabel,
- };
- const labelType = typeof label;
- const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
- return resolveLabel();
-};
-
-const optionTypeFormatters: Partial> = {
- switch: (value, _, t) => {
- const switchLabels: Record = {
- true: t("common.action.show"),
- false: t("common.action.hide"),
- };
- return switchLabels[String(Boolean(value))] ?? null;
- },
- text: (value) => {
- const text = String(value).trim();
- return text.length > 0 ? text : null;
- },
- number: (value) => String(value),
- slider: (value) => String(value),
- select: (value, definition, t) => resolveSelectLabel(value, definition, t),
- multiSelect: (value) => {
- const items = value as unknown[];
- return items.length > 0 ? String(items.length) : null;
- },
- multiText: (value) => {
- const items = (value as string[]).filter((entry) => entry.trim().length > 0);
- return items.length > 0 ? items.join(", ") : null;
- },
- location: (value) => {
- const location = value as { name?: string };
- const name = location.name?.trim() ?? "";
- return name.length > 0 ? name : null;
- },
-};
-
-const formatOptionValue = (
- type: WidgetOptionType,
- value: unknown,
- definition: OptionDefinitionWithRules,
- t: Translator,
-) => {
- const formatter = optionTypeFormatters[type];
- return formatter?.(value, definition, t) ?? null;
-};
-
-const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
- const labelKey = `widget.${kind}.option.${key}.label`;
- const translated = t(labelKey);
- return translated === labelKey ? key : translated;
-};
-
-export const collectWidgetHoverMetadata = (
- item: SectionItem,
- settings: Pick,
- integrationKinds: IntegrationKind[],
- t: Translator,
-): HoverMetadataItem[] => {
- const { definition } = widgetImports[item.kind];
- const optionDefinitions = definition.createOptions(settings) as Record;
- const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
-
- return objectEntries(optionDefinitions)
- .filter(([key, optionDefinition]) => {
- const skip = skippedOptionTypes[optionDefinition.type];
- const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
- const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
- return !skip && !hidden && !unchanged;
- })
- .map(([key, optionDefinition]) => {
- const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
- return {
- label: getOptionLabel(item.kind, key, t),
- value: formattedValue ?? "",
- };
- })
- .filter((entry) => entry.value.length > 0)
- .slice(0, maxMetadataItems);
-};
-
-interface DisplayNameSource {
- matches: (item: SectionItem, options: Record) => boolean;
- resolve: (item: SectionItem, options: Record) => string;
-}
-
-const displayNameSources: DisplayNameSource[] = [
- {
- matches: (item) => Boolean(item.advancedOptions.title?.trim()),
- resolve: (item) => item.advancedOptions.title?.trim() ?? "",
- },
- {
- matches: (item, options) =>
- item.kind === "clock" &&
- Boolean(options.customTitleToggle) &&
- String(options.customTitle ?? "").trim().length > 0,
- resolve: (_, options) => String(options.customTitle).trim(),
- },
-];
-
-export const resolveWidgetDisplayName = (
- item: SectionItem,
- settings: Pick,
- t: Translator,
-) => {
- const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
- const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
- return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
-};
diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts
index b666df4e9..dabb464f3 100644
--- a/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts
+++ b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts
@@ -1,27 +1,36 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
-import { removePersistedWidgetQueries } from "./widget-query-recovery";
+import { removeWidgetDataQueries } from "./widget-query-recovery";
-describe("removePersistedWidgetQueries", () => {
- test("removes stale widget queries and allows fresh data to be fetched", async () => {
+describe("removeWidgetDataQueries", () => {
+ test("removes only the failed widget prefix and allows fresh data to be fetched", async () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
+ const sameWidgetQueryKey = [["widget", "mediaServer", "getLibraries"], { input: {} }];
+ const unrelatedWidgetQueryKey = [["widget", "calendar", "getEvents"], { input: {} }];
+ const appQueryKey = [["app", "byId"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
+ queryClient.setQueryData(sameWidgetQueryKey, "related");
+ queryClient.setQueryData(unrelatedWidgetQueryKey, "other-widget");
+ queryClient.setQueryData(appQueryKey, "app");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
- removePersistedWidgetQueries(queryClient);
+ removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
+ expect(queryClient.getQueryData(sameWidgetQueryKey)).toBeUndefined();
+ expect(queryClient.getQueryData(unrelatedWidgetQueryKey)).toBe("other-widget");
+ expect(queryClient.getQueryData(appQueryKey)).toBe("app");
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
await expect(
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
).resolves.toBe("fresh");
});
- test("removes persisted queries outside the widget router", () => {
+ test("uses an explicit supporting query prefix without purging other supporting data", () => {
const queryClient = new QueryClient();
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
const appQueryKey = [["app", "byId"], { input: {} }];
@@ -31,14 +40,14 @@ describe("removePersistedWidgetQueries", () => {
queryClient.setQueryData(appQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
- removePersistedWidgetQueries(queryClient);
+ removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]);
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
- expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
+ expect(queryClient.getQueryData(appQueryKey)).toBe("stale");
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
- test("removes persisted widget queries when the widget kind and query path differ", () => {
+ test("uses the definition query prefix when the widget kind and query path differ", () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
@@ -46,7 +55,7 @@ describe("removePersistedWidgetQueries", () => {
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
- removePersistedWidgetQueries(queryClient);
+ removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.ts
index 800614bf4..9bb0b81c5 100644
--- a/apps/nextjs/src/components/board/items/widget-query-recovery.ts
+++ b/apps/nextjs/src/components/board/items/widget-query-recovery.ts
@@ -1,7 +1,5 @@
-import type { QueryClient } from "@tanstack/react-query";
+import type { QueryClient, QueryKey } from "@tanstack/react-query";
-import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
-
-export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
- queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
+export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => {
+ queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
};
diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx
index ced30c796..aa3b5adc2 100644
--- a/apps/nextjs/src/components/board/sections/content.tsx
+++ b/apps/nextjs/src/components/board/sections/content.tsx
@@ -4,6 +4,8 @@ import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
+import { useSession } from "@homarr/auth/client";
+import { useEditMode } from "@homarr/boards/edit-mode";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
@@ -15,7 +17,11 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
- const { data: integrations } = clientApi.integration.all.useQuery();
+ const { data: session } = useSession();
+ const [isEditMode] = useEditMode();
+ const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
+ enabled: Boolean(session) && isEditMode,
+ });
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts
index a6802b9a6..03056c130 100644
--- a/apps/nextjs/src/components/integration/integration-grid-shared.ts
+++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts
@@ -5,27 +5,8 @@ import {
hiddenFromOnboarding,
integrationDefs,
integrationKinds,
+ getWidgetKindsForIntegration,
} from "@homarr/definitions";
-import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
-
-const buildWidgetsByIntegration = async () => {
- const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record<
- IntegrationKind,
- WidgetKind[]
- >;
- const definitions = await loadAllWidgetDefinitions();
-
- for (const [widgetKind, definition] of definitions) {
- const supported =
- "supportedIntegrations" in definition ? (definition.supportedIntegrations as IntegrationKind[]) : [];
- for (const kind of supported) {
- if (kind in map) {
- map[kind].push(widgetKind);
- }
- }
- }
- return map;
-};
export const categoryTranslationKeys: Record = {
dnsHole: "integration.category.dnsHole",
@@ -64,14 +45,13 @@ export interface IntegrationGridItem {
widgets: WidgetKind[];
}
-export const buildSortedIntegrations = async (
+export const buildSortedIntegrations = (
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
-): Promise => {
- const widgetsByIntegration = await buildWidgetsByIntegration();
+): IntegrationGridItem[] => {
return integrationKinds
.filter((kind) => {
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
- if (options.onboarding && widgetsByIntegration[kind].length === 0) return false;
+ if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false;
if (!options.enableMockIntegration && kind === "mock") return false;
return true;
})
@@ -79,7 +59,7 @@ export const buildSortedIntegrations = async (
kind,
name: getIntegrationName(kind),
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
- widgets: widgetsByIntegration[kind],
+ widgets: getWidgetKindsForIntegration(kind),
}))
.toSorted((left, right) => {
const leftIdx = featuredIntegrations.indexOf(left.kind);
diff --git a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx
index bece67741..cbd347ba1 100644
--- a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx
+++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx
@@ -1,9 +1,8 @@
"use client";
import { useCallback, useMemo, useState } from "react";
-import { Badge, Card, Center, Group, Loader, Stack, Text, Tooltip } from "@mantine/core";
+import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react";
-import { useQuery } from "@tanstack/react-query";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
@@ -43,14 +42,10 @@ export const IntegrationMultiSelectGrid = ({
[selectedKindsArray, onSelectionChange],
);
- const {
- data: integrations = [],
- isPending,
- isError,
- } = useQuery({
- queryKey: ["widget-integrations", { enableMockIntegration, onboarding }],
- queryFn: () => buildSortedIntegrations({ enableMockIntegration, onboarding }),
- });
+ const integrations = useMemo(
+ () => buildSortedIntegrations({ enableMockIntegration, onboarding }),
+ [enableMockIntegration, onboarding],
+ );
const sorted = useMemo(() => {
if (!detectedKinds || detectedKinds.size === 0) return integrations;
@@ -69,11 +64,6 @@ export const IntegrationMultiSelectGrid = ({
disableScroll={onboarding}
disableAutoFocus={onboarding}
>
- {isPending && (
-
-
-
- )}
{filtered.map((integration) => {
const isSelected = selectedKinds.has(integration.kind);
const isDetected = detectedKinds?.has(integration.kind) ?? false;
@@ -140,14 +130,7 @@ export const IntegrationMultiSelectGrid = ({
);
})}
- {isError && (
-
-
- {t("common.error")}
-
-
- )}
- {!isPending && !isError && filtered.length === 0 && (
+ {filtered.length === 0 && (
{t("common.noResults")}
diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx
index e6458df6e..f4905dd2a 100644
--- a/apps/nextjs/src/components/integration/integration-select-grid.tsx
+++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx
@@ -1,9 +1,8 @@
"use client";
import { useMemo, useState } from "react";
-import { Badge, Card, Center, Group, Loader, Stack, Text, Tooltip } from "@mantine/core";
+import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconPuzzle } from "@tabler/icons-react";
-import { useQuery } from "@tanstack/react-query";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
@@ -20,14 +19,7 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
const [search, setSearch] = useState("");
const t = useI18n();
- const {
- data: integrations = [],
- isPending,
- isError,
- } = useQuery({
- queryKey: ["widget-integrations", { enableMockIntegration }],
- queryFn: () => buildSortedIntegrations({ enableMockIntegration }),
- });
+ const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]);
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
@@ -36,11 +28,6 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
onSearchChange={setSearch}
placeholder={`${t("integration.page.list.search")}...`}
>
- {isPending && (
-
-
-
- )}
{filtered.map((integration) => (
))}
- {isError && (
-
-
- {t("common.error")}
-
-
- )}
- {!isPending && !isError && filtered.length === 0 && (
+ {filtered.length === 0 && (
{t("common.noResults")}
diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx
index e9d93f7fc..2c46608c0 100644
--- a/apps/nextjs/src/components/layout/header.tsx
+++ b/apps/nextjs/src/components/layout/header.tsx
@@ -1,7 +1,6 @@
import type { ReactNode } from "react";
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
-import { Spotlight } from "@homarr/spotlight";
import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger";
@@ -9,6 +8,7 @@ import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
+import { LazySpotlight } from "./header/lazy-spotlight";
interface Props {
logo?: ReactNode;
@@ -37,7 +37,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
-
+
);
};
diff --git a/apps/nextjs/src/components/layout/header/button.tsx b/apps/nextjs/src/components/layout/header/button.tsx
index e8d858b6a..27d48445d 100644
--- a/apps/nextjs/src/components/layout/header/button.tsx
+++ b/apps/nextjs/src/components/layout/header/button.tsx
@@ -14,6 +14,9 @@ type HeaderButtonProps = (
}
) & {
children: ReactNode;
+ onFocus?: () => void;
+ onPointerDown?: () => void;
+ onPointerEnter?: () => void;
} & Partial;
const headerButtonActionIconProps: ActionIconProps = {
diff --git a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx
new file mode 100644
index 000000000..958c0d537
--- /dev/null
+++ b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import dynamic from "next/dynamic";
+import { Center, Loader, Paper } from "@mantine/core";
+import { useHotkeys } from "@mantine/hooks";
+
+import { hotkeys } from "@homarr/definitions";
+import { hasPendingSpotlightOpen, openSpotlight, spotlightOpenEvent } from "@homarr/spotlight/open";
+
+const loadSpotlight = () => import("@homarr/spotlight/component");
+const SpotlightLoading = () => (
+
+
+
+
+
+);
+const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) => Component), {
+ ssr: false,
+ loading: SpotlightLoading,
+});
+
+export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined);
+
+export const LazySpotlight = () => {
+ const [isMounted, setIsMounted] = useState(false);
+ const [isPreloaded, setIsPreloaded] = useState(false);
+
+ useHotkeys(
+ isMounted
+ ? []
+ : [
+ [
+ hotkeys.openSpotlight,
+ () => {
+ setIsMounted(true);
+ openSpotlight();
+ },
+ ],
+ ],
+ );
+
+ useEffect(() => {
+ let isActive = true;
+ const mount = () => setIsMounted(true);
+ window.addEventListener(spotlightOpenEvent, mount);
+ if (hasPendingSpotlightOpen()) mount();
+
+ const preload = () => {
+ void loadSpotlight().then(
+ () => {
+ if (isActive) setIsPreloaded(true);
+ },
+ () => undefined,
+ );
+ };
+ let idleId: number | undefined;
+ const delayId = window.setTimeout(() => {
+ idleId = window.requestIdleCallback?.(preload, { timeout: 1_000 });
+ if (idleId === undefined) preload();
+ }, 1_000);
+ return () => {
+ isActive = false;
+ window.removeEventListener(spotlightOpenEvent, mount);
+ window.clearTimeout(delayId);
+ if (idleId !== undefined) window.cancelIdleCallback?.(idleId);
+ };
+ }, []);
+
+ return isMounted || isPreloaded ? (
+ <>
+ {isPreloaded && }
+ {isMounted && (
+ <>
+
+
+ >
+ )}
+ >
+ ) : null;
+};
diff --git a/apps/nextjs/src/components/layout/header/search.tsx b/apps/nextjs/src/components/layout/header/search.tsx
index 699b08bfa..8e42efd9a 100644
--- a/apps/nextjs/src/components/layout/header/search.tsx
+++ b/apps/nextjs/src/components/layout/header/search.tsx
@@ -3,11 +3,12 @@
import { TextInput, UnstyledButton } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
-import { openSpotlight } from "@homarr/spotlight";
+import { openSpotlight } from "@homarr/spotlight/open";
import { useI18n } from "@homarr/translation/client";
import { HeaderButton } from "./button";
import classes from "./search.module.css";
+import { preloadSpotlight } from "./lazy-spotlight";
export const DesktopSearchInput = () => {
const t = useI18n();
@@ -21,6 +22,9 @@ export const DesktopSearchInput = () => {
size="sm"
leftSection={ }
onClick={openSpotlight}
+ onFocus={preloadSpotlight}
+ onPointerDown={preloadSpotlight}
+ onPointerEnter={preloadSpotlight}
radius="xl"
>
{`${t("search.placeholder")}...`}
@@ -33,6 +37,9 @@ export const MobileSearchButton = () => {
diff --git a/apps/nextjs/src/components/layout/header/tour-target.spec.ts b/apps/nextjs/src/components/layout/header/tour-target.spec.ts
new file mode 100644
index 000000000..8c0ae7816
--- /dev/null
+++ b/apps/nextjs/src/components/layout/header/tour-target.spec.ts
@@ -0,0 +1,71 @@
+import type { ReactNode } from "react";
+import { act, createElement } from "react";
+import { createRoot } from "react-dom/client";
+import { MantineProvider } from "@mantine/core";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+
+import { TourTarget, TourTargetsProvider } from "./tour-target";
+
+vi.mock("@gfazioli/mantine-onboarding-tour", () => ({
+ OnboardingTour: {
+ Target: ({ children }: { children: ReactNode }) => children,
+ },
+}));
+
+const mountedRoots: Array> = [];
+
+beforeAll(() => {
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => undefined,
+ removeListener: () => undefined,
+ addEventListener: () => undefined,
+ removeEventListener: () => undefined,
+ dispatchEvent: () => false,
+ }),
+ });
+});
+
+afterEach(async () => {
+ await act(async () => {
+ for (const root of mountedRoots.splice(0)) root.unmount();
+ });
+});
+
+const renderTargetAsync = async (enabled: boolean) => {
+ const host = document.createElement("div");
+ const root = createRoot(host);
+ mountedRoots.push(root);
+
+ await act(async () => {
+ root.render(
+ createElement(
+ MantineProvider,
+ null,
+ createElement(TourTargetsProvider, {
+ enabled,
+ children: createElement(TourTarget, { id: "example", children: createElement("button", null, "Target") }),
+ }),
+ ),
+ );
+ });
+ return host;
+};
+
+describe("TourTarget", () => {
+ it("keeps a stable route-transition marker while a tour is active", async () => {
+ const host = await renderTargetAsync(true);
+ expect(host.querySelector('[data-tour-target="example"] button')?.textContent).toBe("Target");
+ });
+
+ it("adds no wrapper for users without an active tour", async () => {
+ const host = await renderTargetAsync(false);
+ expect(host.querySelector("[data-tour-target]")).toBeNull();
+ expect(host.querySelector("button")?.parentElement).toBe(host);
+ });
+});
diff --git a/apps/nextjs/src/components/layout/header/tour-target.tsx b/apps/nextjs/src/components/layout/header/tour-target.tsx
index b23572d1e..ea8ba726b 100644
--- a/apps/nextjs/src/components/layout/header/tour-target.tsx
+++ b/apps/nextjs/src/components/layout/header/tour-target.tsx
@@ -1,11 +1,41 @@
"use client";
-import type { ReactNode } from "react";
-import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
+import type { PropsWithChildren, ReactNode } from "react";
+import { createContext, Suspense, use, useContext } from "react";
import { Box } from "@mantine/core";
-export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
-
- {children}
-
+const TourTargetsEnabledContext = createContext(false);
+let tourLibraryPromise: Promise | undefined;
+
+const loadTourLibrary = () => {
+ if (tourLibraryPromise) return tourLibraryPromise;
+
+ const currentPromise = import("@gfazioli/mantine-onboarding-tour");
+ tourLibraryPromise = currentPromise;
+ void currentPromise.catch(() => {
+ if (tourLibraryPromise === currentPromise) tourLibraryPromise = undefined;
+ });
+ return currentPromise;
+};
+
+export const TourTargetsProvider = ({ enabled = true, children }: PropsWithChildren<{ enabled?: boolean }>) => (
+ {children}
);
+
+const LoadedTourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
+ const { OnboardingTour } = use(loadTourLibrary());
+ return {children} ;
+};
+
+export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
+ const isEnabled = useContext(TourTargetsEnabledContext);
+ if (!isEnabled) return children;
+
+ const target = {children} ;
+
+ return (
+
+ {target}
+
+ );
+};
diff --git a/apps/nextjs/src/components/layout/header/update.tsx b/apps/nextjs/src/components/layout/header/update.tsx
index 1989ecd22..81e68b277 100644
--- a/apps/nextjs/src/components/layout/header/update.tsx
+++ b/apps/nextjs/src/components/layout/header/update.tsx
@@ -1,7 +1,6 @@
"use client";
import type { PropsWithChildren } from "react";
-import { Suspense, use } from "react";
import { Indicator, Menu, Text } from "@mantine/core";
import { IconBellRinging } from "@tabler/icons-react";
@@ -9,67 +8,28 @@ import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client";
interface UpdateIndicatorProps extends PropsWithChildren {
- availableUpdatesPromise: Promise | undefined;
+ availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
disabled: boolean;
}
-export const UpdateIndicator = ({ children, availableUpdatesPromise, disabled }: UpdateIndicatorProps) => {
- if (disabled || availableUpdatesPromise === undefined) {
+export const UpdateIndicator = ({ children, availableUpdates, disabled }: UpdateIndicatorProps) => {
+ if (disabled || availableUpdates === undefined) {
return children;
}
return (
-
-
- {children}
-
-
- );
-};
-
-interface InnerUpdateIndicatorProps extends PropsWithChildren {
- availableUpdatesPromise: Promise;
- disabled: boolean;
-}
-
-const InnerUpdateIndicator = ({ children, disabled, availableUpdatesPromise }: InnerUpdateIndicatorProps) => {
- const availableUpdates = use(availableUpdatesPromise);
-
- return (
-
+
{children}
);
};
interface AvailableUpdatesMenuItemProps {
- availableUpdatesPromise: Promise | undefined;
+ availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
}
-export const AvailableUpdatesMenuItem = ({ availableUpdatesPromise }: AvailableUpdatesMenuItemProps) => {
- if (availableUpdatesPromise === undefined) {
- return null;
- }
-
- return (
-
-
-
- );
-};
-
-interface InnerAvailableUpdatesMenuItemProps {
- availableUpdatesPromise: Promise;
-}
-
-const InnerAvailableUpdatesMenuItem = ({ availableUpdatesPromise }: InnerAvailableUpdatesMenuItemProps) => {
+export const AvailableUpdatesMenuItem = ({ availableUpdates }: AvailableUpdatesMenuItemProps) => {
const t = useScopedI18n("common.userAvatar.menu");
- const availableUpdates = use(availableUpdatesPromise);
if (availableUpdates === undefined || availableUpdates.length === 0) {
return null;
}
diff --git a/apps/nextjs/src/components/layout/header/user-client.tsx b/apps/nextjs/src/components/layout/header/user-client.tsx
new file mode 100644
index 000000000..f6c66cbf7
--- /dev/null
+++ b/apps/nextjs/src/components/layout/header/user-client.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import type { ComponentProps, ReactNode } from "react";
+import { Suspense, use, useEffect, useState } from "react";
+import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
+import { useHotkeys } from "@mantine/hooks";
+import { ErrorBoundary } from "react-error-boundary";
+
+import { clientApi } from "@homarr/api/client";
+import { useSession } from "@homarr/auth/client";
+import { hotkeys } from "@homarr/definitions";
+
+import { UpdateIndicator } from "./update";
+
+type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
+type UserAvatarMenuProps = ComponentProps;
+
+let userAvatarMenuPromise: Promise | undefined;
+const loadUserAvatarMenu = () => {
+ if (userAvatarMenuPromise) return userAvatarMenuPromise;
+
+ const promise = import("~/components/user-avatar-menu");
+ userAvatarMenuPromise = promise;
+ void promise.catch(() => {
+ if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
+ });
+ return promise;
+};
+const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
+
+const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
+ const { UserAvatarMenu } = use(loadUserAvatarMenu());
+ return ;
+};
+
+interface UserButtonClientProps {
+ avatar: ReactNode;
+ isAdmin: boolean;
+ isDockerEnabled: boolean;
+}
+
+export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
+ const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
+ const [isMenuMounted, setIsMenuMounted] = useState(false);
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const session = useSession();
+ const { toggleColorScheme } = useMantineColorScheme();
+ useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
+ const isCurrentSessionAdmin =
+ isAdmin && session.status === "authenticated" && session.data.user.permissions.includes("admin");
+
+ useEffect(() => {
+ if (!isCurrentSessionAdmin) {
+ setCanCheckForUpdates(false);
+ return;
+ }
+
+ const timeout = window.setTimeout(() => setCanCheckForUpdates(true), 2_000);
+ return () => window.clearTimeout(timeout);
+ }, [isCurrentSessionAdmin]);
+
+ const { data: availableUpdates } = clientApi.updateChecker.getAvailableUpdates.useQuery(undefined, {
+ enabled: canCheckForUpdates,
+ refetchOnWindowFocus: false,
+ retry: false,
+ staleTime: 60 * 60 * 1_000,
+ });
+ const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
+ const openMenu = () => {
+ setIsMenuMounted(true);
+ setIsMenuOpen(true);
+ };
+ const renderButton = (onClick: () => void, isLoading = false) => (
+
+
+
+ {avatar}
+ {isLoading && (
+
+ )}
+
+
+
+ );
+ const button = renderButton(openMenu);
+ const loadingButton = renderButton(() => undefined, true);
+
+ if (!isMenuMounted) return button;
+
+ return (
+
+ renderButton(() => {
+ resetErrorBoundary();
+ openMenu();
+ })
+ }
+ >
+
+
+ {button}
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/components/layout/header/user.tsx b/apps/nextjs/src/components/layout/header/user.tsx
index af3d72121..e1dc9ad1f 100644
--- a/apps/nextjs/src/components/layout/header/user.tsx
+++ b/apps/nextjs/src/components/layout/header/user.tsx
@@ -1,28 +1,14 @@
-import { Suspense } from "react";
-import { UnstyledButton } from "@mantine/core";
-
-import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar";
-import { UserAvatarMenu } from "~/components/user-avatar-menu";
-import { UpdateIndicator } from "./update";
+import { UserButtonClient } from "./user-client";
export const UserButton = async () => {
const session = await auth();
- const isAdmin = session?.user.permissions.includes("admin");
+ const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
- const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
return (
-
-
- }>
-
-
-
-
-
-
+ } isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
);
};
diff --git a/apps/nextjs/src/components/layout/navigation-link.tsx b/apps/nextjs/src/components/layout/navigation-link.tsx
index 032dbc3bc..383692cfe 100644
--- a/apps/nextjs/src/components/layout/navigation-link.tsx
+++ b/apps/nextjs/src/components/layout/navigation-link.tsx
@@ -1,20 +1,19 @@
"use client";
-import type { ReactNode } from "react";
+import type { ReactElement, ReactNode } from "react";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
-import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { NavLink } from "@mantine/core";
import { Link } from "@homarr/ui";
+import { TourTarget } from "./header/tour-target";
+
export const CommonNavLink = (props: ClientNavigationLink) =>
"href" in props ? : ;
-const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
- if (!id) return <>{children}>;
- return {children} ;
-};
+const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
+ id ? {children} : children;
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
@@ -43,7 +42,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
active={isClient && pathMatches(pathname, props.href)}
/>
);
- return {link} ;
+ return withOptionalTourTarget(tourId, link);
};
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
@@ -56,7 +55,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
))}
);
- return {nav} ;
+ return withOptionalTourTarget(props["data-onboarding-tour-id"], nav);
};
interface CommonNavigationLinkProps {
diff --git a/apps/nextjs/src/components/onboarding/board-tour-gate.tsx b/apps/nextjs/src/components/onboarding/board-tour-gate.tsx
new file mode 100644
index 000000000..31365544f
--- /dev/null
+++ b/apps/nextjs/src/components/onboarding/board-tour-gate.tsx
@@ -0,0 +1,11 @@
+import type { PropsWithChildren } from "react";
+
+export const BoardTourGate = async ({ enabled, children }: PropsWithChildren<{ enabled: boolean }>) => {
+ if (!enabled) return children;
+
+ // Native server-side import keeps the completed-user client graph lean while
+ // giving first-time users one stable board tree instead of swapping a
+ // Suspense fallback after GridStack has mounted.
+ const { BoardTourProvider } = await import("./board-tour");
+ return {children} ;
+};
diff --git a/apps/nextjs/src/components/onboarding/board-tour.tsx b/apps/nextjs/src/components/onboarding/board-tour.tsx
index d7f5d2765..f51a8451f 100644
--- a/apps/nextjs/src/components/onboarding/board-tour.tsx
+++ b/apps/nextjs/src/components/onboarding/board-tour.tsx
@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { clientApi } from "@homarr/api/client";
@@ -10,6 +10,7 @@ import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
+import { TourTargetsProvider } from "~/components/layout/header/tour-target";
import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
@@ -17,21 +18,11 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
const t = useScopedI18n("onboardingTour.board");
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
- const utils = clientApi.useUtils();
- const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
- const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
- onSuccess() {
- void utils.user.getTourStatus.invalidate();
- },
- });
-
- const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
+ const [started, setStarted] = useState(true);
+ const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
const handleEnd = () => {
- utils.user.getTourStatus.setData(undefined, {
- completedManageTour: tourStatus?.completedManageTour ?? false,
- completedBoardTour: true,
- });
+ setStarted(false);
completeTour({ tour: "board" });
};
@@ -101,8 +92,10 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
}, [hasChangeAccess, t]);
return (
-
- {children}
-
+
+
+ {children}
+
+
);
};
diff --git a/apps/nextjs/src/components/onboarding/manage-tour-gate.tsx b/apps/nextjs/src/components/onboarding/manage-tour-gate.tsx
new file mode 100644
index 000000000..33f408b21
--- /dev/null
+++ b/apps/nextjs/src/components/onboarding/manage-tour-gate.tsx
@@ -0,0 +1,12 @@
+import type { PropsWithChildren } from "react";
+
+export const ManageTourGate = async ({
+ enabled,
+ isAdmin,
+ children,
+}: PropsWithChildren<{ enabled: boolean; isAdmin: boolean }>) => {
+ if (!enabled) return children;
+
+ const { ManageTourProvider } = await import("./manage-tour");
+ return {children} ;
+};
diff --git a/apps/nextjs/src/components/onboarding/manage-tour.tsx b/apps/nextjs/src/components/onboarding/manage-tour.tsx
index c242a1bb2..b6b00894f 100644
--- a/apps/nextjs/src/components/onboarding/manage-tour.tsx
+++ b/apps/nextjs/src/components/onboarding/manage-tour.tsx
@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { useMediaQuery } from "@mantine/hooks";
@@ -17,6 +17,7 @@ import { clientApi } from "@homarr/api/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
+import { TourTargetsProvider } from "~/components/layout/header/tour-target";
import { TourShell } from "./tour-shell";
import { TourStepContent } from "./tour-step-content";
@@ -46,13 +47,7 @@ const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
const t = useScopedI18n("onboardingTour.manage");
- const utils = clientApi.useUtils();
- const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
- const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
- onSuccess() {
- void utils.user.getTourStatus.invalidate();
- },
- });
+ const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
const isMobile = useMediaQuery("(max-width: 48em)");
const pathname = usePathname();
const router = useRouter();
@@ -62,14 +57,13 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
const [tourActive, setTourActive] = useState(false);
const [tourDismissed, setTourDismissed] = useState(false);
- const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
useEffect(() => {
if (tourDismissed) return;
- if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
+ if (isManageHome && !isMobile) {
setTourActive(true);
}
- }, [isManageHome, tourStatus, isMobile, tourDismissed]);
+ }, [isManageHome, isMobile, tourDismissed]);
useEffect(() => {
if (!isManageSection) {
@@ -77,25 +71,12 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
}
}, [isManageSection]);
- useEffect(() => {
- const wasCompleted = wasManageTourCompletedRef.current;
- const isCompleted = tourStatus?.completedManageTour;
- if (wasCompleted === true && isCompleted === false) {
- setTourDismissed(false);
- }
- wasManageTourCompletedRef.current = isCompleted;
- }, [tourStatus?.completedManageTour]);
-
const handleEnd = useCallback(() => {
setTourDismissed(true);
setTourActive(false);
- utils.user.getTourStatus.setData(undefined, {
- completedManageTour: true,
- completedBoardTour: tourStatus?.completedBoardTour ?? false,
- });
completeTour({ tour: "manage" });
router.push("/manage");
- }, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
+ }, [completeTour, router]);
const steps = useMemo(() => {
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
@@ -207,14 +188,16 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
}, [isAdmin, t]);
return (
-
- {children}
-
+
+
+ {children}
+
+
);
};
diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx
index cf6d013a2..2cfed0b5b 100644
--- a/apps/nextjs/src/components/user-avatar-menu.tsx
+++ b/apps/nextjs/src/components/user-avatar-menu.tsx
@@ -1,24 +1,13 @@
"use client";
import type { ReactNode } from "react";
-import { useCallback, useEffect } from "react";
-import { useRouter } from "next/navigation";
-import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
-import { useHotkeys, useTimeout } from "@mantine/hooks";
-import {
- IconBrandDocker,
- IconCheck,
- IconHome,
- IconLogin,
- IconLogout,
- IconSettings,
- IconTool,
-} from "@tabler/icons-react";
+import { useCallback } from "react";
+import { Menu } from "@mantine/core";
+import { IconBrandDocker, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
-import { hotkeys } from "@homarr/definitions";
-import { createModal, useModalAction } from "@homarr/modals";
+import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -30,42 +19,38 @@ import { AvailableUpdatesMenuItem } from "./layout/header/update";
interface UserAvatarMenuProps {
children: ReactNode;
- availableUpdatesPromise?: Promise;
+ availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean;
+ opened: boolean;
+ onOpenChange: (opened: boolean) => void;
}
-export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
+export const UserAvatarMenu = ({
+ children,
+ availableUpdates,
+ isDockerEnabled,
+ opened,
+ onOpenChange,
+}: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
- const { toggleColorScheme } = useMantineColorScheme();
- useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
-
const session = useSession();
- const router = useRouter();
const { logoutUrl } = useAuthContext();
- const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => {
+ const redirectUrl = logoutUrl ?? "/auth/login";
await signOut({
redirect: false,
});
- openModal({
- onTimeout: () => {
- if (logoutUrl) {
- window.location.assign(logoutUrl);
- return;
- }
- router.push("/auth/login");
- },
- });
- }, [logoutUrl, openModal, router]);
+ window.location.assign(redirectUrl);
+ }, [logoutUrl]);
return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
-
+
-
+
}>
{t("homeBoard")}
@@ -113,35 +98,3 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
);
};
-
-const LogoutModal = createModal<{ onTimeout: () => void }>(({ actions, innerProps }) => {
- const t = useScopedI18n("common.userAvatar.menu");
- const { start } = useTimeout(() => {
- actions.closeModal();
- innerProps.onTimeout();
- }, 1500);
-
- useEffect(() => {
- start();
- }, [start]);
-
- return (
-
-
-
-
- {t("loggedOut")}
-
-
-
- );
-}).withOptions({
- centered: true,
- withCloseButton: false,
- transitionProps: {
- transition: "pop",
- },
- size: 200,
- closeOnClickOutside: false,
- closeOnEscape: false,
-});
diff --git a/apps/nextjs/src/instrumentation-noop.ts b/apps/nextjs/src/instrumentation-noop.ts
index fa3966e61..fee327293 100644
--- a/apps/nextjs/src/instrumentation-noop.ts
+++ b/apps/nextjs/src/instrumentation-noop.ts
@@ -1,3 +1,4 @@
// Development runs background services as separate processes. The aliases in
// next.config.ts keep production-only instrumentation imports out of the graph.
export const instrumentationNoop = true;
+export const startupPromise = Promise.resolve();
diff --git a/apps/nextjs/src/instrumentation.spec.ts b/apps/nextjs/src/instrumentation.spec.ts
index dc5d6724f..e987ccbee 100644
--- a/apps/nextjs/src/instrumentation.spec.ts
+++ b/apps/nextjs/src/instrumentation.spec.ts
@@ -14,15 +14,21 @@ const registerAsync = async (nodeEnv: string, nextRuntime: string) => {
});
vi.doMock("@homarr/tasks", () => {
tasksStarted();
- return {};
+ return { startupPromise: Promise.resolve() };
});
vi.doMock("@homarr/websocket", () => {
websocketStarted();
- return {};
+ return { startupPromise: Promise.resolve() };
});
const { register } = await import("./instrumentation");
await register();
+ if (nodeEnv === "production" && nextRuntime === "nodejs") {
+ await vi.waitFor(() => {
+ expect(tasksStarted).toHaveBeenCalledOnce();
+ expect(websocketStarted).toHaveBeenCalledOnce();
+ });
+ }
return { loggerLoaded, tasksStarted, websocketStarted };
};
@@ -32,6 +38,7 @@ describe("Next instrumentation", () => {
});
afterEach(() => {
+ vi.restoreAllMocks();
vi.doUnmock("@homarr/core/infrastructure/logs");
vi.doUnmock("@homarr/tasks");
vi.doUnmock("@homarr/websocket");
@@ -62,21 +69,57 @@ describe("Next instrumentation", () => {
expect(websocketStarted).not.toHaveBeenCalled();
});
- test("isolates embedded service startup failures", async () => {
- const websocketStarted = vi.fn();
+ test("does not block Next readiness on slow cron run-on-start hooks", async () => {
+ const neverFinishes = new Promise(() => undefined);
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("NEXT_RUNTIME", "nodejs");
- vi.doMock("@homarr/tasks", () => {
- throw new Error("task startup failed");
- });
+ vi.doMock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => ({ error: vi.fn() }) }));
+ vi.doMock("@homarr/tasks", () => ({ startupPromise: neverFinishes }));
+ vi.doMock("@homarr/websocket", () => ({ startupPromise: Promise.resolve() }));
+
+ const { register } = await import("./instrumentation");
+
+ await expect(register()).resolves.toBeUndefined();
+ });
+
+ test("terminates a ready Node process when cron startup later fails", async () => {
+ const websocketStarted = vi.fn();
+ const errorLogged = vi.fn();
+ let rejectTasks!: (error: Error) => void;
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("NEXT_RUNTIME", "nodejs");
+ vi.doMock("@homarr/core/infrastructure/logs", () => ({
+ createLogger: () => ({ error: errorLogged }),
+ }));
+ vi.doMock("@homarr/tasks", () => ({
+ startupPromise: new Promise((_resolve, reject) => {
+ rejectTasks = reject;
+ }),
+ }));
vi.doMock("@homarr/websocket", () => {
websocketStarted();
- return {};
+ return { startupPromise: Promise.resolve() };
});
const { register } = await import("./instrumentation");
await expect(register()).resolves.toBeUndefined();
+ rejectTasks(new Error("task startup failed"));
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
expect(websocketStarted).toHaveBeenCalledOnce();
+ expect(errorLogged).toHaveBeenCalledOnce();
+ });
+
+ test("fails readiness when the embedded WebSocket listener cannot start", async () => {
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("NEXT_RUNTIME", "nodejs");
+ vi.doMock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => ({ error: vi.fn() }) }));
+ vi.doMock("@homarr/tasks", () => ({ startupPromise: Promise.resolve() }));
+ vi.doMock("@homarr/websocket", () => ({ startupPromise: Promise.reject(new Error("port unavailable")) }));
+
+ const { register } = await import("./instrumentation");
+
+ await expect(register()).rejects.toThrow("Failed to start embedded websocket service");
});
});
diff --git a/apps/nextjs/src/instrumentation.ts b/apps/nextjs/src/instrumentation.ts
index 8333ce6f8..e1755a1b4 100644
--- a/apps/nextjs/src/instrumentation.ts
+++ b/apps/nextjs/src/instrumentation.ts
@@ -4,17 +4,35 @@ export async function register() {
const { createLogger } = await import("@homarr/core/infrastructure/logs");
const logger = createLogger({ module: "instrumentation" });
- const services = [
- { name: "tasks", start: () => import("@homarr/tasks") },
- { name: "websocket", start: () => import("@homarr/websocket") },
- ];
- const results = await Promise.allSettled(services.map(({ start }) => start()));
- for (const [index, result] of results.entries()) {
- if (result.status === "rejected") {
- logger.error(
- new Error(`Failed to start embedded ${services[index]?.name ?? "unknown"} service`, { cause: result.reason }),
- );
+ const startTasksAsync = async () => {
+ try {
+ const tasks = await import("@homarr/tasks");
+ // Cron run-on-start hooks can perform slow external work. Keep dashboard
+ // readiness independent, but never leave the process permanently degraded.
+ void tasks.startupPromise.catch((cause: unknown) => {
+ logger.error(new Error("Failed to start embedded tasks service", { cause }));
+ process.exit(1);
+ });
+ } catch (cause) {
+ const error = new Error("Failed to load embedded tasks service", { cause });
+ logger.error(error);
+ throw error;
}
- }
+ };
+
+ const startWebsocketAsync = async () => {
+ try {
+ const websocket = await import("@homarr/websocket");
+ // Listening is a bounded local readiness condition and prevents Next from
+ // becoming healthy with subscriptions unavailable.
+ await websocket.startupPromise;
+ } catch (cause) {
+ const error = new Error("Failed to start embedded websocket service", { cause });
+ logger.error(error);
+ throw error;
+ }
+ };
+
+ await Promise.all([startTasksAsync(), startWebsocketAsync()]);
}
diff --git a/apps/nextjs/src/proxy.spec.ts b/apps/nextjs/src/proxy.spec.ts
index d6f1d306f..2b9101bde 100644
--- a/apps/nextjs/src/proxy.spec.ts
+++ b/apps/nextjs/src/proxy.spec.ts
@@ -81,4 +81,42 @@ describe("locale and onboarding proxy", () => {
expect(mocks.getDefaultLocale).not.toHaveBeenCalled();
expect(mocks.createI18nMiddleware).toHaveBeenCalledWith("en");
});
+
+ it("deduplicates concurrent cold-start database reads", async () => {
+ let finishOnboarding!: (step: string) => void;
+ let finishLocale!: (locale: string) => void;
+ mocks.getOnboardingStep.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishOnboarding = resolve;
+ }),
+ );
+ mocks.getDefaultLocale.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishLocale = resolve;
+ }),
+ );
+ const { proxy } = await loadProxy();
+ const responses = Array.from({ length: 50 }, () => proxy(createRequest("/en/dashboard", "not-a-locale")));
+
+ await vi.waitFor(() => expect(mocks.getOnboardingStep).toHaveBeenCalledOnce());
+ finishOnboarding("finish");
+ await vi.waitFor(() => expect(mocks.getDefaultLocale).toHaveBeenCalledOnce());
+ finishLocale("de");
+
+ await expect(Promise.all(responses)).resolves.toHaveLength(50);
+ expect(mocks.getOnboardingStep).toHaveBeenCalledOnce();
+ expect(mocks.getDefaultLocale).toHaveBeenCalledOnce();
+ });
+
+ it("clears failed in-flight reads so the next request can recover", async () => {
+ mocks.getOnboardingStep.mockRejectedValueOnce(new Error("temporary database failure")).mockResolvedValue("finish");
+ const { proxy } = await loadProxy();
+
+ await expect(proxy(createRequest("/en/dashboard", "en"))).rejects.toThrow("temporary database failure");
+ await expect(proxy(createRequest("/en/dashboard", "en"))).resolves.toMatchObject({ status: 200 });
+
+ expect(mocks.getOnboardingStep).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/apps/nextjs/src/proxy.ts b/apps/nextjs/src/proxy.ts
index 3178f158a..1bf12ba49 100644
--- a/apps/nextjs/src/proxy.ts
+++ b/apps/nextjs/src/proxy.ts
@@ -8,13 +8,43 @@ import { supportedLanguages } from "@homarr/translation/languages";
import { createI18nMiddleware } from "@homarr/translation/middleware";
let isOnboardingFinished = false;
+let onboardingStepPromise: Promise | null = null;
+let defaultLocalePromise: Promise | null = null;
+
+const getOnboardingStepDedupedAsync = () => {
+ onboardingStepPromise ??= getOnboardingStepForProxyAsync().then(
+ (step) => {
+ onboardingStepPromise = null;
+ return step;
+ },
+ (error: unknown) => {
+ onboardingStepPromise = null;
+ throw error;
+ },
+ );
+ return onboardingStepPromise;
+};
+
+const getDefaultLocaleDedupedAsync = () => {
+ defaultLocalePromise ??= getDefaultLocaleForProxyAsync().then(
+ (locale) => {
+ defaultLocalePromise = null;
+ return locale;
+ },
+ (error: unknown) => {
+ defaultLocalePromise = null;
+ throw error;
+ },
+ );
+ return defaultLocalePromise;
+};
export async function proxy(request: NextRequest) {
// Redirect to onboarding if it's not finished yet
const pathname = request.nextUrl.pathname;
if (!isOnboardingFinished && !pathname.endsWith("/init")) {
- const currentOnboardingStep = await getOnboardingStepForProxyAsync();
+ const currentOnboardingStep = await getOnboardingStepDedupedAsync();
if (currentOnboardingStep !== "finish") {
return NextResponse.redirect(new URL("/init", request.url));
}
@@ -26,7 +56,7 @@ export async function proxy(request: NextRequest) {
const currentLocale = request.cookies.get(localeCookieKey)?.value;
let defaultLocale: SupportedLanguage = "en";
if (!currentLocale || !supportedLanguages.includes(currentLocale as SupportedLanguage)) {
- const configuredLocale = await getDefaultLocaleForProxyAsync();
+ const configuredLocale = await getDefaultLocaleDedupedAsync();
if (supportedLanguages.includes(configuredLocale as SupportedLanguage)) {
defaultLocale = configuredLocale as SupportedLanguage;
}
diff --git a/apps/nextjs/src/theme/color-scheme.ts b/apps/nextjs/src/theme/color-scheme.ts
index 284b636b4..230a0367e 100644
--- a/apps/nextjs/src/theme/color-scheme.ts
+++ b/apps/nextjs/src/theme/color-scheme.ts
@@ -1,8 +1,7 @@
import { cache } from "react";
import { cookies } from "next/headers";
-import { db } from "@homarr/db";
-import { getServerSettingByKeyAsync } from "@homarr/db/queries";
+import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions";
@@ -13,6 +12,6 @@ export const getCurrentColorSchemeAsync = cache(async () => {
return cookieValue as ColorScheme;
}
- const appearanceSettings = await getServerSettingByKeyAsync(db, "appearance");
- return appearanceSettings.defaultColorScheme;
+ const serverSettings = await getRscServerSettingsAsync();
+ return serverSettings.appearance.defaultColorScheme;
});
diff --git a/apps/tasks/src/main.ts b/apps/tasks/src/main.ts
index 55416e689..514694b77 100644
--- a/apps/tasks/src/main.ts
+++ b/apps/tasks/src/main.ts
@@ -5,7 +5,7 @@ import { jobGroup } from "@homarr/cron-jobs";
import { onStartAsync } from "./on-start";
-void (async () => {
+export const startupPromise = (async () => {
await onStartAsync();
await jobGroup.initializeAsync();
await jobGroup.startAllAsync();
diff --git a/apps/websocket/src/main.ts b/apps/websocket/src/main.ts
index 45dfe50a2..9e0032201 100644
--- a/apps/websocket/src/main.ts
+++ b/apps/websocket/src/main.ts
@@ -12,6 +12,25 @@ const logger = createLogger({ module: "websocketMain" });
const wss = new WebSocketServer({
port: 3001,
});
+wss.on("error", (error) => {
+ logger.error(new Error("WebSocket server error", { cause: error }));
+});
+
+export const startupPromise = new Promise((resolve, reject) => {
+ const onListening = () => {
+ wss.off("error", onError);
+ logger.info("✅ WebSocket Server listening on ws://localhost:3001");
+ resolve();
+ };
+ const onError = (error: Error) => {
+ wss.off("listening", onListening);
+ reject(error);
+ };
+
+ wss.once("listening", onListening);
+ wss.once("error", onError);
+});
+
const handler = applyWSSHandler({
wss,
router: appRouter,
@@ -57,7 +76,6 @@ wss.on("connection", (websocket, incomingMessage) => {
logger.info(`âž– Connection (${wss.clients.size}) ${code} ${reason.toString()}`);
});
});
-logger.info("✅ WebSocket Server listening on ws://localhost:3001");
process.on("SIGTERM", () => {
logger.info("SIGTERM");
diff --git a/package.json b/package.json
index 2017883f6..2388af22f 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,11 @@
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
+ "benchmark:build:docker": "node --experimental-strip-types scripts/benchmarks/docker-build.mts",
+ "benchmark:compare": "node --experimental-strip-types scripts/benchmarks/benchmark-compare.mts",
+ "benchmark:board-graph": "node --experimental-strip-types scripts/benchmarks/board-static-graph.mts",
+ "benchmark:docker": "node --experimental-strip-types scripts/benchmarks/docker-runtime.mts",
+ "benchmark:request-memory": "node --expose-gc --import tsx scripts/benchmarks/request-handler-memory.mts",
"clean": "git clean -xdf node_modules",
"clean:workspaces": "turbo clean",
"ci:act:all": "bash scripts/ci/act.sh all",
@@ -42,6 +47,7 @@
"jsonpath-plus": "catalog:"
},
"devDependencies": {
+ "@babel/parser": "catalog:",
"@playwright/test": "catalog:",
"@semantic-release/changelog": "catalog:",
"@semantic-release/commit-analyzer": "catalog:",
@@ -62,6 +68,7 @@
"oxlint": "catalog:",
"semantic-release": "catalog:",
"testcontainers": "catalog:",
+ "tsx": "catalog:",
"turbo": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
diff --git a/packages/api/package.json b/packages/api/package.json
index 9f0d72f8d..92f68ea36 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -11,8 +11,9 @@
"./client": "./src/client.ts",
"./board-server": "./src/board-server.ts",
"./query-cache": "./src/query-cache.ts",
+ "./server-settings-server": "./src/server-settings-server.ts",
+ "./user-server": "./src/user-server.ts",
"./server": "./src/server.ts",
- "./widget-prefetch-server": "./src/widget-prefetch-server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts",
diff --git a/packages/api/src/board-server.ts b/packages/api/src/board-server.ts
index 102b2cd98..aabd3a364 100644
--- a/packages/api/src/board-server.ts
+++ b/packages/api/src/board-server.ts
@@ -1,14 +1,16 @@
+import { cache } from "react";
+
import { createRscTrpcContext } from "./rsc-context";
import { boardRouter } from "./router/board";
const createBoardCallerAsync = async () => boardRouter.createCaller(await createRscTrpcContext());
-export const getBoardByNameAsync = async (name: string) => {
+export const getBoardByNameAsync = cache(async (name: string) => {
const caller = await createBoardCallerAsync();
return await caller.getBoardByName({ name });
-};
+});
-export const getHomeBoardAsync = async () => {
+export const getHomeBoardAsync = cache(async () => {
const caller = await createBoardCallerAsync();
return await caller.getHomeBoard();
-};
+});
diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts
index a8cdb9641..50f96e1c2 100644
--- a/packages/api/src/query-cache.ts
+++ b/packages/api/src/query-cache.ts
@@ -1,22 +1,54 @@
import type { QueryKey } from "@tanstack/react-query";
-export const queryCacheDefaultStaleTimeMs = 0;
+// RSC-prefetched data must survive the hydration pass without an immediate
+// duplicate fetch. Widget-specific intervals still control live refreshes.
+export const queryCacheDefaultStaleTimeMs = 30_000;
export const queryCacheDefaultRefetchIntervalMs = 30_000;
-export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
-export const queryCacheMaxValueBytes = 1024 * 1024;
-export const queryCacheStoragePrefix = "homarr-widget-query";
-export const queryCacheBuster = "v2-superjson";
+export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
+export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
-let activeQueryCacheBoardId: string | null = null;
+const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
+const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
-export const setActiveQueryCacheBoardId = (boardId: string | null) => {
- activeQueryCacheBoardId = boardId;
+export const isWidgetDataTrpcPath = (path: string) => {
+ if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path);
+ return widgetDataQueryPaths.has(path);
};
-export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
-
-const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
-const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
+// These supporting queries can be numerous on a dashboard. Give them a
+// predictable refresh policy instead of refetching all of them after every tab
+// focus/reconnect event. Metadata can refresh after a bounded stale window;
+// Docker state remains live on a single shared timer.
+export const dashboardSupportingQueryPolicies = [
+ {
+ queryKey: [["app", "byId"]],
+ refetchInterval: false,
+ staleTime: queryCacheMetadataStaleTimeMs,
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ },
+ {
+ queryKey: [["app", "byIds"]],
+ refetchInterval: false,
+ staleTime: queryCacheMetadataStaleTimeMs,
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ },
+ {
+ queryKey: [["integration", "byIds"]],
+ refetchInterval: false,
+ staleTime: queryCacheMetadataStaleTimeMs,
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ },
+ {
+ queryKey: [["docker", "getContainers"]],
+ refetchInterval: queryCacheDefaultRefetchIntervalMs,
+ staleTime: queryCacheDefaultStaleTimeMs,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ },
+] as const;
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
const first = queryKey[0];
@@ -26,11 +58,9 @@ const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
return parts.length > 0 ? parts : null;
};
-export const isPersistableWidgetQueryKey = (queryKey: QueryKey) => {
+export const isWidgetDataQueryKey = (queryKey: QueryKey) => {
const path = getTrpcPathFromQueryKey(queryKey);
if (!path) return false;
- if (path[0] === "widget") return !excludedWidgetPaths.has(path.join("."));
-
- return persistableQueryPaths.has(path.join("."));
+ return isWidgetDataTrpcPath(path.join("."));
};
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index e210d5cb4..691e868b3 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -24,7 +24,6 @@ export const appRouter = createTRPCRouter({
cronJobs: lazy(() => import("./router/cron-jobs").then((mod) => mod.cronJobsRouter)),
apiKeys: lazy(() => import("./router/apiKeys").then((mod) => mod.apiKeysRouter)),
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
- queryCache: lazy(() => import("./router/query-cache").then((mod) => mod.queryCacheRouter)),
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index 9cb34d1bd..88a1c42be 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -1757,9 +1757,12 @@ const getElementsForLayout = (board: Awaited, userId: string | null) => {
- const groupsOfCurrentUser = await db.query.groupMembers.findMany({
- where: eq(groupMembers.userId, userId ?? ""),
- });
+ const groupPermissionWhere = userId
+ ? inArray(
+ boardGroupPermissions.groupId,
+ db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, userId)),
+ )
+ : eq(boardGroupPermissions.groupId, "");
const board = await db.query.boards.findFirst({
where,
with: {
@@ -1782,8 +1785,8 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
items: {
with: {
integrations: {
- with: {
- integration: true,
+ columns: {
+ integrationId: true,
},
},
layouts: true,
@@ -1797,7 +1800,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
},
},
groupPermissions: {
- where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
+ where: groupPermissionWhere,
},
},
});
@@ -1845,7 +1848,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
- integrationIds: itemIntegrations.map((item) => item.integration.id),
+ integrationIds: itemIntegrations.map((item) => item.integrationId),
advancedOptions: superjson.parse(item.advancedOptions),
options: superjson.parse>(item.options),
}),
diff --git a/packages/api/src/router/board/board-access.ts b/packages/api/src/router/board/board-access.ts
index 1e4351cba..1eec666a6 100644
--- a/packages/api/src/router/board/board-access.ts
+++ b/packages/api/src/router/board/board-access.ts
@@ -19,9 +19,12 @@ export const throwIfActionForbiddenAsync = async (
permission: BoardPermission,
) => {
const { db, session } = ctx;
- const groupsOfCurrentUser = await db.query.groupMembers.findMany({
- where: eq(groupMembers.userId, session?.user.id ?? ""),
- });
+ const groupPermissionWhere = session
+ ? inArray(
+ boardGroupPermissions.groupId,
+ db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, session.user.id)),
+ )
+ : eq(boardGroupPermissions.groupId, "");
const board = await db.query.boards.findFirst({
where: boardWhere,
columns: {
@@ -34,7 +37,7 @@ export const throwIfActionForbiddenAsync = async (
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
- where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
+ where: groupPermissionWhere,
},
},
});
diff --git a/packages/api/src/router/query-cache.ts b/packages/api/src/router/query-cache.ts
deleted file mode 100644
index 25c9f3808..000000000
--- a/packages/api/src/router/query-cache.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { z } from "zod/v4";
-
-import { eq } from "@homarr/db";
-import { boards } from "@homarr/db/schema";
-import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
-
-import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
-import { createTRPCRouter, internalProcedure } from "../trpc";
-import { throwIfActionForbiddenAsync } from "./board/board-access";
-
-const queryCacheBoardInput = z.object({
- boardId: z.string().min(1),
-});
-
-const queryCacheSetInput = queryCacheBoardInput.extend({
- value: z.string().max(queryCacheMaxValueBytes * 2),
-});
-
-const validateBoardAccessAsync = async (ctx: Parameters[0], boardId: string) => {
- await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
-};
-
-export const queryCacheRouter = createTRPCRouter({
- setItem: internalProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
- await validateBoardAccessAsync(ctx, input.boardId);
- const userId = ctx.session?.user.id ?? "anonymous";
- return {
- stored: await setQueryCacheAsync(
- userId,
- input.boardId,
- input.value,
- queryCacheDefaultGcTimeMs,
- queryCacheMaxValueBytes,
- ),
- };
- }),
- removeItem: internalProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
- await validateBoardAccessAsync(ctx, input.boardId);
- const userId = ctx.session?.user.id ?? "anonymous";
- await removeQueryCacheAsync(userId, input.boardId);
- }),
-});
diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts
index 5e43efe3e..6cb198395 100644
--- a/packages/api/src/router/widgets/rssFeed.ts
+++ b/packages/api/src/router/widgets/rssFeed.ts
@@ -2,6 +2,7 @@ import SuperJSON from "superjson";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
+import { env } from "@homarr/common/env";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { items, users } from "@homarr/db/schema";
@@ -20,6 +21,8 @@ const feedsInput = z.object({
export const rssFeedRouter = createTRPCRouter({
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
+ if (env.NO_EXTERNAL_CONNECTION) return [];
+
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
? input.urls
: await restrictUrlsAsync(ctx.db, input.urls);
diff --git a/packages/api/src/router/widgets/weather.ts b/packages/api/src/router/widgets/weather.ts
index 87ea6b48a..98416ee3c 100644
--- a/packages/api/src/router/widgets/weather.ts
+++ b/packages/api/src/router/widgets/weather.ts
@@ -1,5 +1,6 @@
import { z } from "zod/v4";
+import { env } from "@homarr/common/env";
import { weatherRequestHandler } from "@homarr/request-handler/weather";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -11,6 +12,8 @@ const atLocationInput = z.object({
export const weatherRouter = createTRPCRouter({
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
+ if (env.NO_EXTERNAL_CONNECTION) return null;
+
const handler = weatherRequestHandler.handler(input);
return await handler.getDataAsync().then((result) => result.data);
}),
diff --git a/packages/api/src/server-settings-server.ts b/packages/api/src/server-settings-server.ts
new file mode 100644
index 000000000..29755dfac
--- /dev/null
+++ b/packages/api/src/server-settings-server.ts
@@ -0,0 +1,8 @@
+import { cache } from "react";
+
+import { db } from "@homarr/db";
+import { getServerSettingsAsync } from "@homarr/db/queries";
+
+// One settings snapshot per RSC request, shared by the root layout, theme, and
+// widget prefetching. React invalidates this cache between requests.
+export const getRscServerSettingsAsync = cache(async () => await getServerSettingsAsync(db));
diff --git a/packages/api/src/test/query-cache.spec.ts b/packages/api/src/test/query-cache.spec.ts
index 68d28eb15..e5928bf71 100644
--- a/packages/api/src/test/query-cache.spec.ts
+++ b/packages/api/src/test/query-cache.spec.ts
@@ -1,65 +1,20 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
-import type { Session } from "@homarr/auth";
-import { createId } from "@homarr/common";
-import type { Database } from "@homarr/db";
-import { boards, users } from "@homarr/db/schema";
-import { createDb } from "@homarr/db/test";
import {
- isPersistableWidgetQueryKey,
+ dashboardSupportingQueryPolicies,
+ isWidgetDataQueryKey,
+ isWidgetDataTrpcPath,
+ queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
- queryCacheMaxValueBytes,
+ queryCacheMetadataStaleTimeMs,
} from "@homarr/api/query-cache";
-import { queryCacheRouter } from "../router/query-cache";
-
-const redisMock = vi.hoisted(() => {
- const values = new Map();
-
- return {
- values,
- setQueryCacheAsync: vi.fn(
- async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => {
- if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false;
- values.set(`${_userId}:${_boardId}`, value);
- return true;
- },
- ),
- removeQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string) => {
- values.delete(`${_userId}:${_boardId}`);
- }),
- };
-});
-
-vi.mock("@homarr/redis", () => ({
- setQueryCacheAsync: redisMock.setQueryCacheAsync,
- removeQueryCacheAsync: redisMock.removeQueryCacheAsync,
-}));
-
-vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
-
afterEach(() => {
vi.useRealTimers();
- redisMock.values.clear();
- redisMock.setQueryCacheAsync.mockClear();
- redisMock.removeQueryCacheAsync.mockClear();
});
-const createSession = (userId = createId()): Session => ({
- user: {
- id: userId,
- permissions: [],
- colorScheme: "light",
- },
- expires: new Date().toISOString(),
-});
-
-const insertUserAsync = async (db: Database, id: string) => {
- await db.insert(users).values({ id });
-};
-
-describe("isPersistableWidgetQueryKey", () => {
+describe("isWidgetDataQueryKey", () => {
test.each([
[[["widget", "calendar", "findAllEvents"], { type: "query" }], true],
[[["app", "byId"], { type: "query" }], true],
@@ -72,77 +27,22 @@ describe("isPersistableWidgetQueryKey", () => {
[[["app", "selectable"], { type: "query" }], false],
[[["board", "getBoardByName"], { type: "query" }], false],
[["widget"], false],
- ])("matches persist filter for %j", (queryKey, expected) => {
- expect(isPersistableWidgetQueryKey(queryKey)).toBe(expected);
+ ])("matches widget data queries for %j", (queryKey, expected) => {
+ expect(isWidgetDataQueryKey(queryKey)).toBe(expected);
});
});
-describe("queryCacheRouter", () => {
- test("stores and removes data for a board viewer", async () => {
- const db = createDb();
- const session = createSession();
- await insertUserAsync(db, session.user.id);
- await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
-
- const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
-
- await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).resolves.toEqual({
- stored: true,
- });
- expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
- session.user.id,
- "board-1",
- "cached-value",
- expect.any(Number),
- queryCacheMaxValueBytes,
- );
-
- await caller.removeItem({ boardId: "board-1" });
- expect(redisMock.removeQueryCacheAsync).toHaveBeenCalledWith(session.user.id, "board-1");
- });
-
- test("does not touch Redis when board access is denied", async () => {
- const db = createDb();
- await insertUserAsync(db, "owner");
- await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
-
- redisMock.setQueryCacheAsync.mockClear();
- const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
-
- await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).rejects.toThrow("Board not found");
- expect(redisMock.setQueryCacheAsync).not.toHaveBeenCalled();
- });
-
- test("scopes anonymous public-board cache entries by anonymous user", async () => {
- const db = createDb();
- await insertUserAsync(db, "owner");
- await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
-
- const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
-
- await caller.setItem({ boardId: "board-1", value: "cached-value" });
-
- expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
- "anonymous",
- "board-1",
- "cached-value",
- expect.any(Number),
- queryCacheMaxValueBytes,
- );
- });
-
- test("rejects oversized values", async () => {
- const db = createDb();
- const session = createSession("user-1");
- await insertUserAsync(db, session.user.id);
- await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
-
- const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
- const value = "x".repeat(queryCacheMaxValueBytes + 1);
-
- await expect(caller.setItem({ boardId: "board-1", value })).resolves.toEqual({
- stored: false,
- });
+describe("isWidgetDataTrpcPath", () => {
+ test.each([
+ ["widget.calendar.findAllEvents", true],
+ ["app.byIds", true],
+ ["integration.byIds", true],
+ ["docker.getContainers", true],
+ ["widget.app.ping", false],
+ ["widget.beszel.getSystemStats", false],
+ ["board.getBoardByName", false],
+ ])("classifies dashboard data path %s", (path, expected) => {
+ expect(isWidgetDataTrpcPath(path)).toBe(expected);
});
});
@@ -176,3 +76,38 @@ describe("query cache stale handling", () => {
expect(queryFn).toHaveBeenCalledOnce();
});
});
+
+describe("dashboard supporting query policies", () => {
+ test("bounds metadata staleness without adding per-widget timers", () => {
+ expect(dashboardSupportingQueryPolicies).toEqual([
+ {
+ queryKey: [["app", "byId"]],
+ refetchInterval: false,
+ staleTime: queryCacheMetadataStaleTimeMs,
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ },
+ {
+ queryKey: [["app", "byIds"]],
+ refetchInterval: false,
+ staleTime: queryCacheMetadataStaleTimeMs,
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ },
+ {
+ queryKey: [["integration", "byIds"]],
+ refetchInterval: false,
+ staleTime: queryCacheMetadataStaleTimeMs,
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ },
+ {
+ queryKey: [["docker", "getContainers"]],
+ refetchInterval: queryCacheDefaultRefetchIntervalMs,
+ staleTime: queryCacheDefaultStaleTimeMs,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ },
+ ]);
+ });
+});
diff --git a/packages/api/src/user-server.ts b/packages/api/src/user-server.ts
new file mode 100644
index 000000000..e0455af77
--- /dev/null
+++ b/packages/api/src/user-server.ts
@@ -0,0 +1,24 @@
+import { cache } from "react";
+
+import { db, eq } from "@homarr/db";
+import { users } from "@homarr/db/schema";
+
+// Keep the authenticated user lookup shared across nested RSC layouts. React
+// clears this memo between requests, so authorization changes cannot leak.
+export const getRscUserSettingsAsync = cache(async (userId: string) =>
+ db.query.users.findFirst({
+ columns: {
+ homeBoardId: true,
+ mobileHomeBoardId: true,
+ firstDayOfWeek: true,
+ pingIconsEnabled: true,
+ enableRightClickOnWidgets: true,
+ defaultSearchEngineId: true,
+ openSearchInNewTab: true,
+ ddgBangs: true,
+ completedManageTour: true,
+ completedBoardTour: true,
+ },
+ where: eq(users.id, userId),
+ }),
+);
diff --git a/packages/api/src/widget-prefetch-server.ts b/packages/api/src/widget-prefetch-server.ts
deleted file mode 100644
index dd05f81e3..000000000
--- a/packages/api/src/widget-prefetch-server.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { createRscTrpcContext } from "./rsc-context";
-import { downloadsRouter } from "./router/widgets/downloads";
-
-export const getDownloadJobsAndStatusesAsync = async (input: {
- integrationIds: string[];
- limitPerIntegration: number;
-}) => {
- // Keep this leaf caller narrow: the shared server caller imports the complete app router into every widget-prefetch build.
- const caller = downloadsRouter.createCaller(await createRscTrpcContext());
- return await caller.getJobsAndStatuses(input);
-};
diff --git a/packages/auth/callbacks.ts b/packages/auth/callbacks.ts
index 51c9c83e5..4a12cef2f 100644
--- a/packages/auth/callbacks.ts
+++ b/packages/auth/callbacks.ts
@@ -3,24 +3,18 @@ import type { NextAuthConfig } from "next-auth";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
-import { eq, inArray } from "@homarr/db";
+import { eq } from "@homarr/db";
import { groupMembers, groupPermissions, users } from "@homarr/db/schema";
import { getPermissionsWithChildren } from "@homarr/definitions";
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
- const dbGroupMembers = await db.query.groupMembers.findMany({
- where: eq(groupMembers.userId, userId),
- });
- const groupIds = dbGroupMembers.map((groupMember) => groupMember.groupId);
-
- if (groupIds.length === 0) return [];
-
const dbGroupPermissions = await db
.selectDistinct({
permission: groupPermissions.permission,
})
.from(groupPermissions)
- .where(inArray(groupPermissions.groupId, groupIds));
+ .innerJoin(groupMembers, eq(groupPermissions.groupId, groupMembers.groupId))
+ .where(eq(groupMembers.userId, userId));
const permissionKeys = dbGroupPermissions.map(({ permission }) => permission);
return getPermissionsWithChildren(permissionKeys);
@@ -43,12 +37,15 @@ export const createSessionAsync = async (
export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session"> => {
return async ({ session, user }) => {
- const additionalProperties = await db.query.users.findFirst({
- where: eq(users.id, user.id),
- columns: {
- colorScheme: true,
- },
- });
+ const [additionalProperties, permissions] = await Promise.all([
+ db.query.users.findFirst({
+ where: eq(users.id, user.id),
+ columns: {
+ colorScheme: true,
+ },
+ }),
+ getCurrentUserPermissionsAsync(db, user.id),
+ ]);
return {
...session,
@@ -57,7 +54,7 @@ export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session
...additionalProperties,
id: user.id,
name: user.name,
- permissions: await getCurrentUserPermissionsAsync(db, user.id),
+ permissions,
},
};
};
diff --git a/packages/auth/permissions/integration-provider.tsx b/packages/auth/permissions/integration-provider.tsx
index f63fd3c3d..3a2946502 100644
--- a/packages/auth/permissions/integration-provider.tsx
+++ b/packages/auth/permissions/integration-provider.tsx
@@ -3,14 +3,9 @@
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
-import type { IntegrationKind } from "@homarr/definitions";
-
interface IntegrationContextProps {
integrations: {
id: string;
- name: string;
- url: string;
- kind: IntegrationKind;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;
diff --git a/packages/auth/permissions/integrations-with-permissions.ts b/packages/auth/permissions/integrations-with-permissions.ts
index 76cae6c93..a49ec4d76 100644
--- a/packages/auth/permissions/integrations-with-permissions.ts
+++ b/packages/auth/permissions/integrations-with-permissions.ts
@@ -6,31 +6,28 @@ import { groupMembers, integrationGroupPermissions, integrationUserPermissions }
import { constructIntegrationPermissions } from "./integration-permissions";
export const getIntegrationsWithPermissionsAsync = async (session: Session | null) => {
- const groupsOfCurrentUser = await db.query.groupMembers.findMany({
- where: eq(groupMembers.userId, session?.user.id ?? ""),
- });
- const integrations = await db.query.integrations.findMany({
+ const groupPermissionWhere = session
+ ? inArray(
+ integrationGroupPermissions.groupId,
+ db.select({ groupId: groupMembers.groupId }).from(groupMembers).where(eq(groupMembers.userId, session.user.id)),
+ )
+ : eq(integrationGroupPermissions.groupId, "");
+ const integrationRows = await db.query.integrations.findMany({
columns: {
id: true,
- name: true,
- url: true,
- kind: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
- where: inArray(
- integrationGroupPermissions.groupId,
- groupsOfCurrentUser.map((group) => group.groupId),
- ),
+ where: groupPermissionWhere,
},
},
});
- return integrations.map(({ userPermissions, groupPermissions, ...integration }) => ({
- ...integration,
+ return integrationRows.map(({ id, userPermissions, groupPermissions }) => ({
+ id,
permissions: constructIntegrationPermissions({ userPermissions, groupPermissions }, session),
}));
};
diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx
index b482fbe0c..a74503b97 100644
--- a/packages/boards/src/context.tsx
+++ b/packages/boards/src/context.tsx
@@ -6,7 +6,6 @@ import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
-import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
import { updateBoardName } from "./updater";
@@ -36,16 +35,6 @@ export const BoardProvider = ({
};
}, [pathname, utils, initialBoard.name]);
- useEffect(() => {
- setActiveQueryCacheBoardId(data.id);
-
- return () => {
- if (getActiveQueryCacheBoardId() === data.id) {
- setActiveQueryCacheBoardId(null);
- }
- };
- }, [data.id]);
-
return (
kind !== "patchmon"),
firewall: getIntegrationKindsByCategory("firewall"),
notifications: getIntegrationKindsByCategory("notifications"),
- mediaReleases: ["emby", "jellyfin", "plex"],
+ mediaReleases: ["mock", "emby", "jellyfin", "plex"],
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
coolify: ["coolify"],
@@ -40,7 +40,13 @@ export const widgetIntegrationSupport: Partial {
};
};
-const queryCacheKey = (userId: string, boardId: string) => `qc:${userId}:${boardId}`;
-
-export const setQueryCacheAsync = async (
- userId: string,
- boardId: string,
- value: string,
- ttlMs: number,
- maxValueBytes: number,
-) => {
- if (Buffer.byteLength(value, "utf8") > maxValueBytes) {
- logger.warn("Query cache value exceeded maximum size", {
- key: queryCacheKey(userId, boardId),
- valueBytes: Buffer.byteLength(value, "utf8"),
- maxValueBytes,
- });
- return false;
- }
- await getSetClient.set(queryCacheKey(userId, boardId), value, "PX", ttlMs);
- return true;
-};
-
-export const getQueryCacheAsync = async (userId: string, boardId: string) =>
- await getSetClient.get(queryCacheKey(userId, boardId));
-
-export const removeQueryCacheAsync = async (userId: string, boardId: string) => {
- await getSetClient.del(queryCacheKey(userId, boardId));
-};
-
export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise => {
const client = getSetClient as typeof getSetClient | null;
if (!client) return;
diff --git a/packages/request-handler/src/lib/integration-request-handler.ts b/packages/request-handler/src/lib/integration-request-handler.ts
index cf6f49cea..e07ba3881 100644
--- a/packages/request-handler/src/lib/integration-request-handler.ts
+++ b/packages/request-handler/src/lib/integration-request-handler.ts
@@ -14,6 +14,7 @@ interface Options, input: TInput) => Promise;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
+ staleIfErrorTtlMs?: number;
}
export const createIntegrationRequestHandler = <
@@ -23,24 +24,18 @@ export const createIntegrationRequestHandler = <
>(
options: Options,
) => {
- const integrationMap = new Map>();
- const inner = createRequestHandler({
- requestAsync: async (input) => {
- const integration = integrationMap.get(input.integrationId);
- if (!integration) {
- throw new Error(`Integration ${input.integrationId} not found in cache`);
- }
- return options.requestAsync(integration, input.options);
- },
+ const inner = createRequestHandler; options: TInput }>({
+ requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions),
+ getCacheKey: ({ integration, options: itemOptions }) =>
+ JSON.stringify({ integrationId: integration.id, itemOptions }),
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
+ staleIfErrorTtlMs: options.staleIfErrorTtlMs,
});
return {
invalidateCache: inner.invalidateCache,
- handler: (integration: IntegrationOfKind, itemOptions: TInput) => {
- integrationMap.set(integration.id, integration);
- return inner.handler({ integrationId: integration.id, options: itemOptions });
- },
+ handler: (integration: IntegrationOfKind, itemOptions: TInput) =>
+ inner.handler({ integration, options: itemOptions }),
};
};
diff --git a/packages/request-handler/src/lib/request-handler.spec.ts b/packages/request-handler/src/lib/request-handler.spec.ts
new file mode 100644
index 000000000..85ee34339
--- /dev/null
+++ b/packages/request-handler/src/lib/request-handler.spec.ts
@@ -0,0 +1,224 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { Integration, IntegrationSecret } from "@homarr/db/schema";
+
+import { createIntegrationRequestHandler } from "./integration-request-handler";
+import { createRequestHandler } from "./request-handler";
+import { createWidgetRequestHandler } from "./widget-request-handler";
+
+const deferred = () => {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
+const createIntegration = (secret: string) =>
+ ({
+ id: "integration-id",
+ kind: "sonarr",
+ decryptedSecrets: [{ kind: "apiKey", value: secret }],
+ }) as Omit & {
+ kind: "sonarr";
+ decryptedSecrets: Pick[];
+ externalUrl: string | null;
+ };
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("createRequestHandler", () => {
+ it("deduplicates in-flight work and caches completed data", async () => {
+ let calls = 0;
+ const request = deferred();
+ const handler = createRequestHandler({
+ requestAsync: async () => {
+ calls += 1;
+ return await request.promise;
+ },
+ });
+
+ const first = handler.handler({ id: "same" }).getDataAsync();
+ const second = handler.handler({ id: "same" }).getDataAsync();
+ expect(calls).toBe(1);
+
+ request.resolve("value");
+ await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
+ await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "value" });
+ expect(calls).toBe(1);
+ });
+
+ it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => {
+ let calls = 0;
+ const request = deferred<{ call: number }>();
+ const handler = createRequestHandler({
+ cacheTtlMs: 0,
+ requestAsync: async () => {
+ calls += 1;
+ if (calls === 1) return await request.promise;
+ return { call: calls };
+ },
+ });
+
+ const first = handler.handler({ id: "same" }).getDataAsync();
+ const second = handler.handler({ id: "same" }).getDataAsync();
+ expect(calls).toBe(1);
+
+ request.resolve({ call: 1 });
+ await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: { call: 1 } }, { data: { call: 1 } }]);
+ await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 2 } });
+ expect(calls).toBe(2);
+ });
+
+ it("releases positive-ttl entries after expiry without another request", async () => {
+ vi.useFakeTimers();
+ let calls = 0;
+ const handler = createRequestHandler({
+ cacheTtlMs: 25,
+ requestAsync: async () => ({ call: ++calls }),
+ });
+
+ await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 1 } });
+ await vi.advanceTimersByTimeAsync(25);
+ await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 2 } });
+
+ expect(calls).toBe(2);
+ });
+
+ it("cancels scheduled expiry work when the cache is invalidated", async () => {
+ vi.useFakeTimers();
+ const handler = createRequestHandler({ cacheTtlMs: 25, requestAsync: async () => "value" });
+ await handler.handler({ id: "same" }).getDataAsync();
+
+ expect(vi.getTimerCount()).toBe(1);
+ handler.invalidateCache();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("bounds stale-on-error fallback and releases it while idle", async () => {
+ vi.useFakeTimers();
+ let calls = 0;
+ const handler = createRequestHandler({
+ cacheTtlMs: 25,
+ fallbackToStaleOnError: true,
+ staleIfErrorTtlMs: 50,
+ requestAsync: async () => {
+ calls += 1;
+ if (calls === 1) return "last-known-good";
+ throw new Error("upstream unavailable");
+ },
+ });
+
+ await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({
+ data: "last-known-good",
+ });
+ await vi.advanceTimersByTimeAsync(25);
+ await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({
+ data: "last-known-good",
+ });
+
+ await vi.advanceTimersByTimeAsync(51);
+ await expect(handler.handler({ id: "same" }).getDataAsync()).rejects.toThrow("upstream unavailable");
+ expect(calls).toBe(3);
+ });
+
+ it("reschedules staggered stale entries by their retirement deadline without spinning", async () => {
+ vi.useFakeTimers();
+ const handler = createRequestHandler({
+ cacheTtlMs: 10,
+ fallbackToStaleOnError: true,
+ staleIfErrorTtlMs: 100,
+ requestAsync: async ({ id }: { id: string }) => id,
+ });
+
+ await handler.handler({ id: "first" }).getDataAsync();
+ await vi.advanceTimersByTimeAsync(20);
+ await handler.handler({ id: "second" }).getDataAsync();
+ expect(vi.getTimerCount()).toBe(1);
+
+ await vi.advanceTimersByTimeAsync(90);
+ expect(vi.getTimerCount()).toBe(1);
+ await vi.advanceTimersByTimeAsync(20);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("evicts an empty-string cache key at the size limit", async () => {
+ let calls = 0;
+ const handler = createRequestHandler({
+ cacheTtlMs: 60_000,
+ getCacheKey: ({ id }: { id: string }) => id,
+ requestAsync: async () => ++calls,
+ });
+
+ await handler.handler({ id: "" }).getDataAsync();
+ for (let index = 0; index < 1000; index += 1) {
+ await handler.handler({ id: String(index) }).getDataAsync();
+ }
+ await handler.handler({ id: "" }).getDataAsync();
+
+ expect(calls).toBe(1002);
+ });
+
+ it("does not let a pre-invalidation request repopulate or clear newer work", async () => {
+ const staleRequest = deferred();
+ const freshRequest = deferred();
+ const requests = [staleRequest, freshRequest];
+ let calls = 0;
+ const handler = createRequestHandler({
+ requestAsync: async () => {
+ const request = requests[calls++];
+ if (!request) throw new Error("Unexpected request");
+ return await request.promise;
+ },
+ });
+
+ const stale = handler.handler({ id: "same" }).getDataAsync();
+ handler.invalidateCache();
+ const fresh = handler.handler({ id: "same" }).getDataAsync();
+
+ staleRequest.resolve("stale");
+ await expect(stale).resolves.toMatchObject({ data: "stale" });
+ const joinsFresh = handler.handler({ id: "same" }).getDataAsync();
+ expect(calls).toBe(2);
+
+ freshRequest.resolve("fresh");
+ await expect(Promise.all([fresh, joinsFresh])).resolves.toMatchObject([{ data: "fresh" }, { data: "fresh" }]);
+ await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "fresh" });
+ expect(calls).toBe(2);
+ });
+});
+
+describe("createIntegrationRequestHandler", () => {
+ it("keys requests by integration id and options without retaining secret values", async () => {
+ let calls = 0;
+ const request = deferred();
+ const handler = createIntegrationRequestHandler({
+ requestAsync: async () => {
+ calls += 1;
+ return await request.promise;
+ },
+ });
+ const first = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
+ const second = handler.handler(createIntegration("rotated-secret"), { page: 1 }).getDataAsync();
+ expect(calls).toBe(1);
+
+ request.resolve("value");
+ await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
+ });
+});
+
+describe("createWidgetRequestHandler", () => {
+ it("shares its cache across calls while keeping distinct inputs separate", async () => {
+ let calls = 0;
+ const handler = createWidgetRequestHandler({ requestAsync: async ({ id }: { id: string }) => `${id}-${++calls}` });
+
+ await expect(handler.handler({ id: "one" }).getDataAsync()).resolves.toMatchObject({ data: "one-1" });
+ await expect(handler.handler({ id: "one" }).getDataAsync()).resolves.toMatchObject({ data: "one-1" });
+ await expect(handler.handler({ id: "two" }).getDataAsync()).resolves.toMatchObject({ data: "two-2" });
+ expect(calls).toBe(2);
+ });
+});
diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts
index 4dbdb5b11..a447c377b 100644
--- a/packages/request-handler/src/lib/request-handler.ts
+++ b/packages/request-handler/src/lib/request-handler.ts
@@ -1,18 +1,21 @@
interface Options> {
requestAsync: (input: TInput) => Promise;
+ getCacheKey?: (input: TInput) => string;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
+ staleIfErrorTtlMs?: number;
}
-type CacheEntry = { data: TData; timestamp: Date; expiresAt: number };
+type CacheEntry = { data: TData; timestamp: Date; expiresAt: number; staleUntil: number };
const MAX_CACHE_SIZE = 1000;
const DEFAULT_TTL_MS = 10_000;
+const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000;
const evictExpired = (cache: Map>) => {
const now = Date.now();
for (const [key, entry] of cache) {
- if (now >= entry.expiresAt) cache.delete(key);
+ if (now >= entry.staleUntil) cache.delete(key);
}
};
@@ -21,44 +24,98 @@ export const createRequestHandler = {
const cache = new Map>();
const inflight = new Map>>();
+ let expiryTimer: ReturnType | undefined;
+ let expiryTimerAt: number | undefined;
+ let generation = 0;
+
+ const scheduleExpiry = (candidateExpiryAt?: number) => {
+ if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return;
+ if (expiryTimer) clearTimeout(expiryTimer);
+ expiryTimer = undefined;
+ expiryTimerAt = undefined;
+ if (cache.size === 0) return;
+
+ const nextExpiryAt = candidateExpiryAt ?? Math.min(...[...cache.values()].map(({ staleUntil }) => staleUntil));
+ expiryTimerAt = nextExpiryAt;
+ expiryTimer = setTimeout(
+ () => {
+ expiryTimer = undefined;
+ expiryTimerAt = undefined;
+ evictExpired(cache);
+ scheduleExpiry();
+ },
+ Math.max(0, nextExpiryAt - Date.now()),
+ );
+ expiryTimer.unref?.();
+ };
return {
invalidateCache: () => {
+ generation += 1;
cache.clear();
inflight.clear();
+ if (expiryTimer) clearTimeout(expiryTimer);
+ expiryTimer = undefined;
+ expiryTimerAt = undefined;
},
handler: (input: TInput) => ({
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
- const key = JSON.stringify(input);
+ const key = options.getCacheKey?.(input) ?? JSON.stringify(input);
- const cached = cache.get(key);
- if (cached && Date.now() < cached.expiresAt) {
+ let cached = cache.get(key);
+ const now = Date.now();
+ if (cached && now < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
+ if (cached && now >= cached.staleUntil) {
+ cache.delete(key);
+ scheduleExpiry();
+ cached = undefined;
+ }
const existing = inflight.get(key);
if (existing) return existing;
- const promise = options
+ const requestGeneration = generation;
+ let promise: Promise>;
+ promise = options
.requestAsync(input)
.then((data) => {
- if (cache.size >= MAX_CACHE_SIZE) evictExpired(cache);
- if (cache.size >= MAX_CACHE_SIZE) {
- const oldest = cache.keys().next().value;
- if (oldest) cache.delete(oldest);
+ const expiresAt = Date.now() + ttl;
+ const staleIfErrorTtlMs = options.fallbackToStaleOnError
+ ? Math.max(0, options.staleIfErrorTtlMs ?? DEFAULT_STALE_IF_ERROR_TTL_MS)
+ : 0;
+ const entry: CacheEntry = {
+ data,
+ timestamp: new Date(),
+ expiresAt,
+ staleUntil: expiresAt + staleIfErrorTtlMs,
+ };
+ if (ttl > 0 && generation === requestGeneration) {
+ evictExpired(cache);
+ if (cache.size >= MAX_CACHE_SIZE) {
+ const oldest = cache.keys().next().value;
+ if (oldest !== undefined) cache.delete(oldest);
+ }
+ cache.set(key, entry);
+ scheduleExpiry(entry.staleUntil);
}
- const entry: CacheEntry = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
- cache.set(key, entry);
- inflight.delete(key);
return entry;
})
.catch((err) => {
- inflight.delete(key);
- if (options.fallbackToStaleOnError && cached) {
+ if (
+ options.fallbackToStaleOnError &&
+ cached &&
+ Date.now() < cached.staleUntil &&
+ generation === requestGeneration
+ ) {
return cached;
}
throw err;
+ })
+ .finally(() => {
+ if (inflight.get(key) === promise) inflight.delete(key);
});
inflight.set(key, promise);
diff --git a/packages/request-handler/src/lib/widget-request-handler.ts b/packages/request-handler/src/lib/widget-request-handler.ts
index 8649791af..b5273c87f 100644
--- a/packages/request-handler/src/lib/widget-request-handler.ts
+++ b/packages/request-handler/src/lib/widget-request-handler.ts
@@ -6,9 +6,7 @@ interface Options> {
export const createWidgetRequestHandler = >(
options: Options,
-) => ({
- handler: (widgetOptions: TInput) =>
- createRequestHandler({
- requestAsync: options.requestAsync,
- }).handler(widgetOptions),
-});
+) => {
+ const inner = createRequestHandler({ requestAsync: options.requestAsync });
+ return inner;
+};
diff --git a/packages/request-handler/src/test/update-checker-cache.spec.ts b/packages/request-handler/src/test/update-checker-cache.spec.ts
index b88038f59..b5c17e654 100644
--- a/packages/request-handler/src/test/update-checker-cache.spec.ts
+++ b/packages/request-handler/src/test/update-checker-cache.spec.ts
@@ -20,13 +20,28 @@ const mocks = vi.hoisted(() => {
values,
locks,
removeFreshResult() {
- values.delete("update-checker:fresh:v1");
+ for (const key of values.keys()) {
+ if (key.startsWith("update-checker:fresh:")) values.delete(key);
+ }
},
};
});
vi.mock("@homarr/common/env", () => ({ env: mocks.env }));
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ fetchWithTrustedCertificatesAsync: vi.fn(),
+}));
+
+vi.mock("@homarr/core/infrastructure/logs", () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ }),
+}));
+
vi.mock("@homarr/redis", () => ({
createGetSetChannel: (name: string) => ({
getAsync: async () => {
@@ -181,6 +196,20 @@ describe("persisted update checker cache", () => {
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
+ test("ignores update results created for an older application version", async () => {
+ mocks.values.set("update-checker:fresh:v1", {
+ value: { availableUpdates: [], attemptedAt: 1, checkedAt: 1 },
+ expiresAt: null,
+ });
+ mocks.listReleases.mockResolvedValue(successfulResponse);
+
+ const handler = await loadHandlerAsync();
+ const result = await handler.getDataAsync();
+
+ expect(result.data.availableUpdates).toHaveLength(1);
+ expect(mocks.listReleases).toHaveBeenCalledOnce();
+ });
+
test("serves stale data and retries only after 24 hours following a provider failure", async () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
diff --git a/packages/request-handler/src/update-checker.ts b/packages/request-handler/src/update-checker.ts
index 8ba5389f6..9bc2085cd 100644
--- a/packages/request-handler/src/update-checker.ts
+++ b/packages/request-handler/src/update-checker.ts
@@ -22,9 +22,14 @@ type UpdateCheckCacheEntry = {
checkedAt: number | null;
};
-const freshUpdateCheckChannel = createGetSetChannel("update-checker:fresh:v1");
-const staleUpdateCheckChannel = createGetSetChannel("update-checker:stale:v1");
-const updateCheckLock = createLockChannel("update-checker:lock:v1");
+const updateCheckCacheVersion = `v2:${packageJson.version}`;
+const freshUpdateCheckChannel = createGetSetChannel(
+ `update-checker:fresh:${updateCheckCacheVersion}`,
+);
+const staleUpdateCheckChannel = createGetSetChannel(
+ `update-checker:stale:${updateCheckCacheVersion}`,
+);
+const updateCheckLock = createLockChannel(`update-checker:lock:${updateCheckCacheVersion}`);
const waitAsync = async (durationMs: number) =>
await new Promise((resolve) => {
@@ -53,8 +58,10 @@ const getCachedAvailableUpdatesAsync = async (): Promise
const cached = await freshUpdateCheckChannel.getAsync();
if (cached) return cached;
+ const staleBeforeRefresh = await staleUpdateCheckChannel.getAsync();
const lockToken = await updateCheckLock.acquireAsync(updateCheckLockTtlSeconds);
if (!lockToken) {
+ if (staleBeforeRefresh) return staleBeforeRefresh;
const concurrentResult = await waitForConcurrentUpdateCheckAsync();
if (concurrentResult) return concurrentResult;
throw new Error("Timed out waiting for the update check");
diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json
index b9c16aef8..7781ba78a 100644
--- a/packages/spotlight/package.json
+++ b/packages/spotlight/package.json
@@ -13,6 +13,8 @@
},
"exports": {
".": "./index.ts",
+ "./component": "./src/component.ts",
+ "./open": "./src/open.ts",
"./styles.css": "./src/styles.css"
},
"scripts": {
diff --git a/packages/spotlight/src/component.ts b/packages/spotlight/src/component.ts
new file mode 100644
index 000000000..2f9b5eeef
--- /dev/null
+++ b/packages/spotlight/src/component.ts
@@ -0,0 +1,3 @@
+"use client";
+
+export { Spotlight } from "./components/spotlight";
diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx
index 4a2680b73..3e285da9a 100644
--- a/packages/spotlight/src/components/spotlight.tsx
+++ b/packages/spotlight/src/components/spotlight.tsx
@@ -11,18 +11,18 @@ import { hotkeys } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
-import type { OpenMediaRequestSearchOptions } from "../index";
+import {
+ consumePendingMediaRequestSearch,
+ consumePendingSpotlightOpen,
+ mediaRequestSearchEvent,
+ spotlightOpenEvent,
+} from "../open";
+import type { OpenMediaRequestSearchOptions } from "../open";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes";
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
-import {
- mediaRequestSearchEvent,
- mediaRequestSearchScopeAtom,
- selectAction,
- spotlightActions,
- spotlightStore,
-} from "../spotlight-store";
+import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
@@ -64,6 +64,17 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
+ useEffect(() => {
+ const handleOpen = () => {
+ consumePendingSpotlightOpen();
+ spotlightActions.open();
+ };
+
+ window.addEventListener(spotlightOpenEvent, handleOpen);
+ if (consumePendingSpotlightOpen()) spotlightActions.open();
+ return () => window.removeEventListener(spotlightOpenEvent, handleOpen);
+ }, []);
+
const clearChildrenStack = useCallback(() => {
setChildrenStack([]);
}, []);
@@ -84,17 +95,24 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
}, [setQuery]);
useEffect(() => {
- const handleMediaRequestSearch = (event: Event) => {
- const { integrationIds, query } = (event as CustomEvent).detail ?? {};
+ const applyMediaRequestSearch = ({ integrationIds, query: searchQuery }: OpenMediaRequestSearchOptions) => {
setMediaRequestSearchScope({ integrationIds });
setMode("media");
clearChildrenStack();
- setQuery(query ?? "");
+ setQuery(searchQuery ?? "");
spotlightActions.open();
setTimeout(() => selectAction(0, spotlightStore));
};
+ const handleMediaRequestSearch = (event: Event) => {
+ consumePendingMediaRequestSearch();
+ const { integrationIds, query: searchQuery } = (event as CustomEvent).detail ?? {};
+ applyMediaRequestSearch({ integrationIds, query: searchQuery });
+ };
+
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
+ const pendingSearch = consumePendingMediaRequestSearch();
+ if (pendingSearch) applyMediaRequestSearch(pendingSearch);
return () => {
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
};
diff --git a/packages/spotlight/src/index.ts b/packages/spotlight/src/index.ts
index 3433b7125..706480f7c 100644
--- a/packages/spotlight/src/index.ts
+++ b/packages/spotlight/src/index.ts
@@ -1,28 +1,8 @@
"use client";
-import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store";
-
-export { Spotlight } from "./components/spotlight";
-export { openSpotlight, openMediaRequestSearch };
+export { openSpotlight, openMediaRequestSearch, mediaRequestSearchEvent } from "./open";
export {
SpotlightProvider,
useRegisterSpotlightContextResults,
useRegisterSpotlightContextActions,
} from "./modes/home/context";
-
-const openSpotlight = spotlightActions.open;
-
-export interface OpenMediaRequestSearchOptions {
- integrationIds?: string[];
- query?: string;
-}
-
-const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
- if (typeof window !== "undefined") {
- window.dispatchEvent(new CustomEvent(mediaRequestSearchEvent, { detail: options }));
- }
-
- spotlightActions.open();
-};
-
-export { mediaRequestSearchEvent };
diff --git a/packages/spotlight/src/open.spec.ts b/packages/spotlight/src/open.spec.ts
new file mode 100644
index 000000000..9b9c07f83
--- /dev/null
+++ b/packages/spotlight/src/open.spec.ts
@@ -0,0 +1,54 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ consumePendingMediaRequestSearch,
+ consumePendingSpotlightOpen,
+ hasPendingSpotlightOpen,
+ mediaRequestSearchEvent,
+ openMediaRequestSearch,
+ openSpotlight,
+ spotlightOpenEvent,
+} from "./open";
+
+describe("lazy Spotlight open bridge", () => {
+ beforeEach(() => {
+ consumePendingSpotlightOpen();
+ consumePendingMediaRequestSearch();
+ });
+
+ it("keeps a pre-mount open request until the lazy component consumes it", () => {
+ const listener = vi.fn();
+ window.addEventListener(spotlightOpenEvent, listener);
+
+ openSpotlight();
+
+ expect(listener).toHaveBeenCalledOnce();
+ expect(consumePendingSpotlightOpen()).toBe(true);
+ expect(consumePendingSpotlightOpen()).toBe(false);
+ window.removeEventListener(spotlightOpenEvent, listener);
+ });
+
+ it("lets a listener installed after the request discover the pending open", () => {
+ openSpotlight();
+
+ expect(hasPendingSpotlightOpen()).toBe(true);
+ expect(consumePendingSpotlightOpen()).toBe(true);
+ expect(hasPendingSpotlightOpen()).toBe(false);
+ });
+
+ it("retains media options while dispatching both mount and media events", () => {
+ const openListener = vi.fn();
+ const mediaListener = vi.fn();
+ window.addEventListener(spotlightOpenEvent, openListener);
+ window.addEventListener(mediaRequestSearchEvent, mediaListener);
+
+ openMediaRequestSearch({ integrationIds: ["integration-a"], query: "movie" });
+
+ expect(openListener).toHaveBeenCalledOnce();
+ expect(mediaListener).toHaveBeenCalledOnce();
+ expect(consumePendingSpotlightOpen()).toBe(true);
+ expect(consumePendingMediaRequestSearch()).toEqual({ integrationIds: ["integration-a"], query: "movie" });
+ window.removeEventListener(spotlightOpenEvent, openListener);
+ window.removeEventListener(mediaRequestSearchEvent, mediaListener);
+ });
+});
diff --git a/packages/spotlight/src/open.ts b/packages/spotlight/src/open.ts
new file mode 100644
index 000000000..53aa5173f
--- /dev/null
+++ b/packages/spotlight/src/open.ts
@@ -0,0 +1,44 @@
+"use client";
+
+export const spotlightOpenEvent = "homarr:spotlight:open";
+export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
+
+export interface OpenMediaRequestSearchOptions {
+ integrationIds?: string[];
+ query?: string;
+}
+
+let pendingMediaRequestSearch: OpenMediaRequestSearchOptions | null = null;
+let pendingSpotlightOpen = false;
+
+const requestSpotlightMount = () => {
+ if (typeof window !== "undefined") window.dispatchEvent(new Event(spotlightOpenEvent));
+};
+
+export const openSpotlight = () => {
+ pendingSpotlightOpen = true;
+ requestSpotlightMount();
+};
+
+export const consumePendingSpotlightOpen = () => {
+ const pending = pendingSpotlightOpen;
+ pendingSpotlightOpen = false;
+ return pending;
+};
+
+export const hasPendingSpotlightOpen = () => pendingSpotlightOpen;
+
+export const consumePendingMediaRequestSearch = () => {
+ const pending = pendingMediaRequestSearch;
+ pendingMediaRequestSearch = null;
+ return pending;
+};
+
+export const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
+ pendingMediaRequestSearch = options;
+ openSpotlight();
+
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new CustomEvent(mediaRequestSearchEvent, { detail: options }));
+ }
+};
diff --git a/packages/spotlight/src/spotlight-store.ts b/packages/spotlight/src/spotlight-store.ts
index a144cdc94..f1fc2cedc 100644
--- a/packages/spotlight/src/spotlight-store.ts
+++ b/packages/spotlight/src/spotlight-store.ts
@@ -12,7 +12,6 @@ export interface MediaRequestSearchScope {
}
export const mediaRequestSearchScopeAtom = atom({});
-export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
export const setSelectedAction = (index: number, store: SpotlightStore) => {
store.updateState((state) => ({ ...state, selected: index }));
diff --git a/packages/widgets/package.json b/packages/widgets/package.json
index e47c48938..d3e516202 100644
--- a/packages/widgets/package.json
+++ b/packages/widgets/package.json
@@ -16,7 +16,7 @@
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
- "./client": "./src/client.tsx",
+ "./catalog": "./src/catalog.ts",
"./definition": "./src/definition.ts",
"./errors": "./src/errors/component.tsx",
"./manifest": "./src/manifest.ts",
diff --git a/packages/widgets/src/anchor-note/index.ts b/packages/widgets/src/anchor-note/index.ts
index a54aada3f..896570b65 100644
--- a/packages/widgets/src/anchor-note/index.ts
+++ b/packages/widgets/src/anchor-note/index.ts
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
+ queryKey: [["widget", "anchorNotes"]],
supportedIntegrations: ["anchor"],
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/app/index.ts b/packages/widgets/src/app/index.ts
index 62f01287d..7755ac105 100644
--- a/packages/widgets/src/app/index.ts
+++ b/packages/widgets/src/app/index.ts
@@ -15,6 +15,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
+ queryKeys: [[["app", "byId"]], [["widget", "app"]]],
createOptions(settings) {
return optionsBuilder.from(
(factory) => ({
diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts
index 5e3799a90..f7dd6dee7 100644
--- a/packages/widgets/src/beszel-alerts/index.ts
+++ b/packages/widgets/src/beszel-alerts/index.ts
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
+ queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx
index c57b909fd..81a38e970 100644
--- a/packages/widgets/src/bookmarks/index.tsx
+++ b/packages/widgets/src/bookmarks/index.tsx
@@ -10,6 +10,7 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
+ queryKeys: [[["app", "byIds"]]],
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts
new file mode 100644
index 000000000..7951dbf7d
--- /dev/null
+++ b/packages/widgets/src/catalog.ts
@@ -0,0 +1,110 @@
+import {
+ IconActivityHeartbeat,
+ IconAd,
+ IconApi,
+ IconApps,
+ IconArchive,
+ IconBadgeCc,
+ IconBatteryCharging,
+ IconBell,
+ IconBinaryTree,
+ IconBookmark,
+ IconBrandDocker,
+ IconBrandMinecraft,
+ IconBrowser,
+ IconBuildingBank,
+ IconBusStop,
+ IconCalendar,
+ IconChartAreaLine,
+ IconChartBar,
+ IconClock,
+ IconCloud,
+ IconDeviceCctv,
+ IconDeviceGamepad,
+ IconDownload,
+ IconFileText,
+ IconGraphFilled,
+ IconHeadphones,
+ IconHeartRateMonitor,
+ IconHeartbeat,
+ IconLayoutGrid,
+ IconMessage,
+ IconMovie,
+ IconNotes,
+ IconPhoto,
+ IconReportSearch,
+ IconRocket,
+ IconRoute,
+ IconRss,
+ IconServer2,
+ IconShieldCheck,
+ IconShieldLock,
+ IconSpeedboat,
+ IconTable,
+ IconTicket,
+ IconTopologyFull,
+ IconTransform,
+ IconVideo,
+ IconWall,
+ IconZoomQuestion,
+} from "@tabler/icons-react";
+
+import type { WidgetKind } from "@homarr/definitions";
+import type { TablerIcon } from "@homarr/ui";
+
+export const widgetCatalogIcons: Record = {
+ clock: IconClock,
+ weather: IconCloud,
+ app: IconApps,
+ iframe: IconBrowser,
+ video: IconDeviceCctv,
+ notebook: IconNotes,
+ anchorNote: IconNotes,
+ dnsHoleSummary: IconAd,
+ dnsHoleControls: IconDeviceGamepad,
+ "smartHome-entityState": IconBinaryTree,
+ "smartHome-executeAutomation": IconBinaryTree,
+ stockPrice: IconBuildingBank,
+ mediaServer: IconVideo,
+ calendar: IconCalendar,
+ downloads: IconDownload,
+ "mediaRequests-requestList": IconZoomQuestion,
+ "mediaRequests-requestStats": IconChartBar,
+ mediaTranscoding: IconTransform,
+ mediaMissing: IconMovie,
+ minecraftServerStatus: IconBrandMinecraft,
+ networkControllerSummary: IconTopologyFull,
+ networkControllerStatus: IconTopologyFull,
+ rssFeed: IconRss,
+ bookmarks: IconBookmark,
+ indexerManager: IconReportSearch,
+ healthMonitoring: IconHeartRateMonitor,
+ releases: IconRocket,
+ mediaReleases: IconTicket,
+ dockerContainers: IconBrandDocker,
+ firewall: IconWall,
+ notifications: IconMessage,
+ systemResources: IconGraphFilled,
+ coolify: IconCloud,
+ systemDisks: IconServer2,
+ timetable: IconBusStop,
+ "immich-serverStats": IconGraphFilled,
+ "immich-albumCarousel": IconPhoto,
+ paperlessNgx: IconFileText,
+ patchmon: IconShieldCheck,
+ bazarr: IconBadgeCc,
+ tracearr: IconActivityHeartbeat,
+ speedtestTracker: IconSpeedboat,
+ uptimeKuma: IconHeartbeat,
+ audioStats: IconHeadphones,
+ umami: IconChartBar,
+ vpn: IconShieldLock,
+ archiveTeamWarrior: IconArchive,
+ ups: IconBatteryCharging,
+ beszelSystemTable: IconTable,
+ beszelSystemGrid: IconLayoutGrid,
+ beszelAlerts: IconBell,
+ beszelSystemStats: IconChartAreaLine,
+ traefik: IconRoute,
+ customApi: IconApi,
+};
diff --git a/packages/widgets/src/client.tsx b/packages/widgets/src/client.tsx
deleted file mode 100644
index dd3922f55..000000000
--- a/packages/widgets/src/client.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import type { ComponentType } from "react";
-import dynamic from "next/dynamic";
-import { Center, Loader as UiLoader } from "@mantine/core";
-
-import type { WidgetKind } from "@homarr/definitions";
-
-import type { WidgetComponentProps } from "./definition";
-import { loadWidgetModule, widgetKinds } from "./manifest";
-
-const createWidgetComponent = (kind: WidgetKind) =>
- dynamic>(
- async () => {
- const { componentLoader } = await loadWidgetModule(kind);
- return await componentLoader();
- },
- {
- loading: () => (
-
-
-
- ),
- },
- );
-
-// Creating every dynamic wrapper at module scope lets Next register stable
-// client boundaries and preload metadata. Widget implementation modules remain
-// behind their manifest loaders.
-const widgetComponents = Object.fromEntries(widgetKinds.map((kind) => [kind, createWidgetComponent(kind)])) as Record<
- WidgetKind,
- ComponentType>
->;
-
-export const getWidgetComponent = (kind: TKind) => {
- return widgetComponents[kind] as ComponentType>;
-};
diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts
index 20b22d6a0..3fe152b73 100644
--- a/packages/widgets/src/clock/index.ts
+++ b/packages/widgets/src/clock/index.ts
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
+ queryKey: [["widget", "weather"]],
createOptions() {
return optionsBuilder.from(
(factory) => ({
diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts
index 3ba53987e..12482f511 100644
--- a/packages/widgets/src/definition.ts
+++ b/packages/widgets/src/definition.ts
@@ -70,6 +70,7 @@ export const createWidgetDefinition = , "kind">) => WidgetContextMenuAction[];
}
+export const getWidgetQueryKeys = (definition: WidgetDefinition, kind: WidgetKind): QueryKey[] =>
+ definition.queryKeys ?? [definition.queryKey ?? [["widget", kind]]];
+
export interface WidgetProps {
options: inferOptionsFromCreator>;
integrationIds: string[];
diff --git a/packages/widgets/src/downloads/prefetch.ts b/packages/widgets/src/downloads/prefetch.ts
deleted file mode 100644
index c56f79488..000000000
--- a/packages/widgets/src/downloads/prefetch.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { getDownloadJobsAndStatusesAsync } from "@homarr/api/widget-prefetch-server";
-import { createLogger } from "@homarr/core/infrastructure/logs";
-
-import type { Prefetch } from "../definition";
-import { createTrpcQueryKey } from "../trpc-query-key";
-
-const logger = createLogger({ module: "downloadsWidgetPrefetch" });
-
-const prefetchAllAsync: Prefetch<"downloads"> = async (queryClient, items) => {
- await Promise.all(
- items.map(async (item) => {
- if (item.integrationIds.length === 0) return;
-
- const input = {
- integrationIds: item.integrationIds,
- limitPerIntegration: Number(item.options.limitPerIntegration) || 50,
- };
-
- try {
- const data = await getDownloadJobsAndStatusesAsync(input);
- queryClient.setQueryData(createTrpcQueryKey("widget.downloads.getJobsAndStatuses", input), data);
- logger.info("Prefetched downloads widget data", { integrations: item.integrationIds.length });
- } catch (error) {
- logger.warn("Failed to prefetch downloads widget data", { error });
- }
- }),
- );
-};
-
-export default prefetchAllAsync;
diff --git a/packages/widgets/src/errors/base-component.tsx b/packages/widgets/src/errors/base-component.tsx
index 8d656f0c4..69392923d 100644
--- a/packages/widgets/src/errors/base-component.tsx
+++ b/packages/widgets/src/errors/base-component.tsx
@@ -17,7 +17,7 @@ export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
const t = useI18n();
return (
-
+
{translateIfNecessary(t, props.message)}
diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts
index ff8eb87ab..a8dedc5c3 100644
--- a/packages/widgets/src/health-monitoring/index.ts
+++ b/packages/widgets/src/health-monitoring/index.ts
@@ -8,6 +8,7 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
+ queryKeys: [[["widget", "healthMonitoring"]], [["integration", "byIds"]]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
diff --git a/packages/widgets/src/manifest.spec.ts b/packages/widgets/src/manifest.spec.ts
index d0486c2bb..f80f4349c 100644
--- a/packages/widgets/src/manifest.spec.ts
+++ b/packages/widgets/src/manifest.spec.ts
@@ -1,17 +1,20 @@
import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
-import { widgetKinds as definedWidgetKinds } from "@homarr/definitions";
+import { widgetIntegrationSupport, widgetKinds as definedWidgetKinds } from "@homarr/definitions";
import {
createRetryableLoader,
loadAllWidgetDefinitions,
+ loadWidgetComponent,
loadWidgetDefinition,
loadWidgetModule,
+ loadWidgetResources,
reduceWidgetOptionsWithDefinition,
widgetKinds,
} from "./manifest";
import { widgetQueryRefetchIntervals } from "./refetch-intervals";
+import { widgetCatalogIcons } from "./catalog";
describe("widget manifest promise stability", () => {
it("returns the same module promise for every render", () => {
@@ -22,6 +25,11 @@ describe("widget manifest promise stability", () => {
expect(loadWidgetDefinition("clock")).toBe(loadWidgetDefinition("clock"));
});
+ it("returns stable component and combined resource promises", () => {
+ expect(loadWidgetComponent("clock")).toBe(loadWidgetComponent("clock"));
+ expect(loadWidgetResources("clock")).toBe(loadWidgetResources("clock"));
+ });
+
it("returns the same all-definitions promise for every modal render", () => {
expect(loadAllWidgetDefinitions()).toBe(loadAllWidgetDefinitions());
});
@@ -56,9 +64,14 @@ describe("widget manifest promise stability", () => {
await Promise.all(
widgetKinds.map(async (kind) => {
const module = await loadWidgetModule(kind);
- const component = await module.componentLoader();
+ const canonicalComponent = await module.componentLoader();
+ const component = await loadWidgetComponent(kind);
+ const resources = await loadWidgetResources(kind);
expect(definitions.get(kind)).toBe(module.definition);
+ expect(component.default).toBe(canonicalComponent.default);
expect(component.default).toBeDefined();
+ expect(resources.definition).toBe(module.definition);
+ expect(resources.Component).toBe(component.default);
}),
);
}, 30_000);
@@ -75,6 +88,42 @@ describe("widget manifest promise stability", () => {
}
});
+ it("keeps the lightweight integration support map aligned with widget definitions", async () => {
+ const definitions = await loadAllWidgetDefinitions();
+
+ for (const kind of widgetKinds) {
+ const definition = definitions.get(kind);
+ const supportedIntegrations =
+ definition && "supportedIntegrations" in definition ? (definition.supportedIntegrations ?? []) : [];
+ expect([...(widgetIntegrationSupport[kind] ?? [])].toSorted()).toEqual([...supportedIntegrations].toSorted());
+ }
+ });
+
+ it("keeps the lightweight catalog icons aligned with widget definitions", async () => {
+ const definitions = await loadAllWidgetDefinitions();
+
+ for (const kind of widgetKinds) {
+ expect(widgetCatalogIcons[kind]).toBe(definitions.get(kind)?.icon);
+ }
+ });
+
+ it("declares the real query prefix for widget kinds that share a router", async () => {
+ const definitions = await loadAllWidgetDefinitions();
+ const expectedQueryKeys = new Map([
+ ["anchorNote", [["widget", "anchorNotes"]]],
+ ["beszelAlerts", [["widget", "beszel"]]],
+ ["clock", [["widget", "weather"]]],
+ ["mediaMissing", [["widget", "mediaOrganizer"]]],
+ ["mediaRequests-requestList", [["widget", "mediaRequests"]]],
+ ["mediaRequests-requestStats", [["widget", "mediaRequests"]]],
+ ["smartHome-entityState", [["widget", "smartHome"]]],
+ ] as const);
+
+ for (const [kind, queryKey] of expectedQueryKeys) {
+ expect(definitions.get(kind)?.queryKey).toEqual(queryKey);
+ }
+ });
+
it("keeps the client polling policy aligned with widget definitions", async () => {
const definitions = await loadAllWidgetDefinitions();
type PollingPolicy = { queryKey: QueryKey; intervalSeconds: number | null };
diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts
index d8883869a..4bb2cdf2d 100644
--- a/packages/widgets/src/manifest.ts
+++ b/packages/widgets/src/manifest.ts
@@ -12,6 +12,15 @@ type WidgetModule = {
componentLoader: () => Promise<{ default: ComponentType> }>;
};
+type WidgetComponentModule = {
+ default: ComponentType>;
+};
+
+export type WidgetResources = {
+ definition: WidgetDefinition;
+ Component: ComponentType>;
+};
+
const moduleLoaders: Record Promise> = {
clock: () => import("./clock") as Promise,
weather: () => import("./weather") as Promise,
@@ -69,6 +78,67 @@ const moduleLoaders: Record Promise> = {
customApi: () => import("./custom-api") as Promise,
};
+// Keep these imports explicit so Turbopack can create one discoverable chunk per
+// widget implementation. Loading this registry does not load the implementations.
+const componentLoaders: Record Promise> = {
+ clock: () => import("./clock/component") as Promise,
+ weather: () => import("./weather/component") as Promise,
+ app: () => import("./app/component") as Promise,
+ archiveTeamWarrior: () => import("./archive-team-warrior/component") as Promise,
+ anchorNote: () => import("./anchor-note/component") as Promise,
+ notebook: () => import("./notebook/component") as Promise,
+ iframe: () => import("./iframe/component") as Promise,
+ video: () => import("./video/component") as Promise,
+ dnsHoleSummary: () => import("./dns-hole/summary/component") as Promise,
+ dnsHoleControls: () => import("./dns-hole/controls/component") as Promise,
+ "smartHome-entityState": () => import("./smart-home/entity-state/component") as Promise,
+ "smartHome-executeAutomation": () =>
+ import("./smart-home/execute-automation/component") as Promise,
+ stockPrice: () => import("./stocks/component") as Promise,
+ mediaServer: () => import("./media-server/component") as Promise,
+ calendar: () => import("./calendar/component") as Promise,
+ downloads: () => import("./downloads/component") as Promise,
+ "mediaRequests-requestList": () => import("./media-requests/list/component") as Promise,
+ "mediaRequests-requestStats": () => import("./media-requests/stats/component") as Promise,
+ mediaMissing: () => import("./media-missing/component") as Promise,
+ networkControllerSummary: () => import("./network-controller/summary/component") as Promise,
+ networkControllerStatus: () =>
+ import("./network-controller/network-status/component") as Promise,
+ rssFeed: () => import("./rssFeed/component") as Promise,
+ bookmarks: () => import("./bookmarks/component") as Promise,
+ bazarr: () => import("./bazarr/component") as Promise,
+ indexerManager: () => import("./indexer-manager/component") as Promise,
+ healthMonitoring: () => import("./health-monitoring/component") as Promise,
+ mediaTranscoding: () => import("./media-transcoding/component") as Promise,
+ minecraftServerStatus: () => import("./minecraft/server-status/component") as Promise,
+ dockerContainers: () => import("./docker/component") as Promise,
+ releases: () => import("./releases/component") as Promise,
+ firewall: () => import("./firewall/component") as Promise,
+ notifications: () => import("./notifications/component") as Promise,
+ mediaReleases: () => import("./media-releases/component") as Promise,
+ systemResources: () => import("./system-resources/component") as Promise,
+ coolify: () => import("./coolify/component") as Promise,
+ systemDisks: () => import("./system-disks/component") as Promise,
+ timetable: () => import("./timetable/component") as Promise,
+ "immich-serverStats": () => import("./immich/server-stats/component") as Promise,
+ "immich-albumCarousel": () => import("./immich/album-carousel/component") as Promise,
+ paperlessNgx: () => import("./paperless-ngx/component") as Promise,
+ patchmon: () => import("./patchmon/component") as Promise,
+ tracearr: () => import("./tracearr/component") as Promise,
+ speedtestTracker: () => import("./speedtest-tracker/component") as Promise,
+ uptimeKuma: () => import("./uptime-kuma/component") as Promise,
+ audioStats: () => import("./audio-stats/component") as Promise,
+ umami: () => import("./umami/component") as Promise,
+ vpn: () => import("./vpn/component") as Promise,
+ ups: () => import("./ups/component") as Promise,
+ beszelSystemTable: () => import("./beszel-system-table/component") as Promise,
+ beszelSystemGrid: () => import("./beszel-system-grid/component") as Promise,
+ beszelAlerts: () => import("./beszel-alerts/component") as Promise,
+ beszelSystemStats: () => import("./beszel-system-stats/component") as Promise,
+ traefik: () => import("./traefik/component") as Promise,
+ customApi: () => import("./custom-api/component") as Promise,
+};
+
const definitionPromises = new Map>();
export const widgetKinds = Object.keys(moduleLoaders) as WidgetKind[];
@@ -96,6 +166,25 @@ export const loadWidgetModule = createRetryableLoader(
new Map(Object.entries(moduleLoaders) as [WidgetKind, () => Promise][]),
);
+export const loadWidgetComponent = createRetryableLoader(
+ new Map(Object.entries(componentLoaders) as [WidgetKind, () => Promise][]),
+);
+
+export const loadWidgetResources = createRetryableLoader(
+ new Map(
+ (Object.keys(moduleLoaders) as WidgetKind[]).map((kind) => [
+ kind,
+ async () => {
+ const [{ definition }, { default: Component }] = await Promise.all([
+ loadWidgetModule(kind),
+ loadWidgetComponent(kind),
+ ]);
+ return { definition, Component } satisfies WidgetResources;
+ },
+ ]),
+ ),
+);
+
export const loadWidgetDefinition = (kind: WidgetKind) => {
const existing = definitionPromises.get(kind);
if (existing) return existing;
diff --git a/packages/widgets/src/media-missing/index.ts b/packages/widgets/src/media-missing/index.ts
index eaa44be6d..fd325f801 100644
--- a/packages/widgets/src/media-missing/index.ts
+++ b/packages/widgets/src/media-missing/index.ts
@@ -9,6 +9,7 @@ const pageSizeOptions = ["10", "20", "30", "50"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
icon: IconMovie,
+ queryKey: [["widget", "mediaOrganizer"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showMissing: factory.switch({ defaultValue: true }),
diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts
index 1f65cd0c3..bcc5dd4fd 100644
--- a/packages/widgets/src/media-requests/list/index.ts
+++ b/packages/widgets/src/media-requests/list/index.ts
@@ -11,6 +11,7 @@ const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion,
+ queryKey: [["widget", "mediaRequests"]],
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts
index d49cb8495..5efbd8909 100644
--- a/packages/widgets/src/media-requests/stats/index.ts
+++ b/packages/widgets/src/media-requests/stats/index.ts
@@ -7,6 +7,7 @@ import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
+ queryKey: [["widget", "mediaRequests"]],
createOptions() {
return {};
},
diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx
index 3edb6ffe5..a312f92eb 100644
--- a/packages/widgets/src/modals/widget-edit-modal.tsx
+++ b/packages/widgets/src/modals/widget-edit-modal.tsx
@@ -14,11 +14,11 @@ import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client";
import { zodErrorMap } from "@homarr/validation/form/i18n";
-import { widgetImports } from "..";
import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form";
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
import type { OptionsBuilderResult } from "../options";
+import type { WidgetDefinition } from "../definition";
import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
@@ -32,8 +32,9 @@ export interface WidgetEditModalState {
advancedOptions: BoardItemAdvancedOptions;
}
-interface ModalProps {
+export interface WidgetEditModalProps {
kind: TSort;
+ definition: WidgetDefinition;
value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState) => void;
integrationData: IntegrationSelectOption[];
@@ -43,7 +44,7 @@ interface ModalProps {
appId?: string;
}
-export const WidgetEditModal = createModal>(({ actions, innerProps }) => {
+export const WidgetEditModal = createModal>(({ actions, innerProps }) => {
const t = useI18n();
const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState(innerProps.value.advancedOptions);
@@ -53,7 +54,7 @@ export const WidgetEditModal = createModal>(({ actions, i
z.config({
customError: zodErrorMap(t),
});
- const { definition } = widgetImports[innerProps.kind];
+ const { definition } = innerProps;
const options = definition.createOptions(innerProps.settings) as Record;
const optionsSuperRefine = (options as Record)[OPTIONS_SUPER_REFINE] as
| ((data: Record, ctx: z.RefinementCtx) => void)
@@ -124,10 +125,7 @@ export const WidgetEditModal = createModal>(({ actions, i
- 1
- }
+ canSelectMultiple={((definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) > 1}
{...form.getInputProps("integrationIds")}
/>
)}
diff --git a/packages/widgets/src/prefetch.ts b/packages/widgets/src/prefetch.ts
index 79ca915c1..36e2f3415 100644
--- a/packages/widgets/src/prefetch.ts
+++ b/packages/widgets/src/prefetch.ts
@@ -1,26 +1,20 @@
-import { cache } from "react";
import type { QueryClient } from "@tanstack/react-query";
-import { db } from "@homarr/db";
-import { getServerSettingsAsync } from "@homarr/db/queries";
+import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import type { WidgetKind } from "@homarr/definitions";
import { createSettings } from "@homarr/settings/creator";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "./manifest";
import prefetchForApps from "./app/prefetch";
import prefetchForBookmarks from "./bookmarks/prefetch";
-import prefetchForDownloads from "./downloads/prefetch";
import type { Prefetch, WidgetOptionsRecordOf } from "./definition";
import type { inferOptionsFromCreator } from "./options";
-const cachedGetServerSettingsAsync = cache(getServerSettingsAsync);
-
const prefetchCallbacks: Partial<{
[TKind in WidgetKind]: Prefetch;
}> = {
bookmarks: prefetchForBookmarks,
app: prefetchForApps,
- downloads: prefetchForDownloads,
};
export const prefetchForKindAsync = async (
@@ -36,8 +30,7 @@ export const prefetchForKindAsync = async (
return;
}
- const serverSettings = await cachedGetServerSettingsAsync(db);
- const definition = await loadWidgetDefinition(kind);
+ const [serverSettings, definition] = await Promise.all([getRscServerSettingsAsync(), loadWidgetDefinition(kind)]);
const itemsWithDefaultOptions = items.map((item) => ({
...item,
diff --git a/packages/widgets/src/smart-home/entity-state/index.ts b/packages/widgets/src/smart-home/entity-state/index.ts
index a4dd32921..143f2f232 100644
--- a/packages/widgets/src/smart-home/entity-state/index.ts
+++ b/packages/widgets/src/smart-home/entity-state/index.ts
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
icon: IconBinaryTree,
+ queryKey: [["widget", "smartHome"]],
createOptions() {
return optionsBuilder.from((factory) => ({
entityId: factory.text({
diff --git a/packages/widgets/src/test/translation.spec.ts b/packages/widgets/src/test/translation.spec.ts
index 8af82dc25..7f65261e0 100644
--- a/packages/widgets/src/test/translation.spec.ts
+++ b/packages/widgets/src/test/translation.spec.ts
@@ -1,15 +1,16 @@
import { describe, expect, it } from "vitest";
-import { objectEntries } from "@homarr/common";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { createLanguageMapping } from "@homarr/translation";
-import { widgetImports } from "..";
+import { loadAllWidgetDefinitions } from "../manifest";
-describe("Widget properties with description should have matching translations", async () => {
- const enTranslation = await createLanguageMapping().en();
- objectEntries(widgetImports).forEach(([key, value]) => {
- Object.entries(value.definition.createOptions({} as SettingsContextProps)).forEach(([optionKey, optionValue_]) => {
+const widgetDefinitions = await loadAllWidgetDefinitions();
+const enTranslation = await createLanguageMapping().en();
+
+describe("Widget properties with description should have matching translations", () => {
+ for (const [key, definition] of widgetDefinitions) {
+ Object.entries(definition.createOptions({} as SettingsContextProps)).forEach(([optionKey, optionValue_]) => {
const optionValue = optionValue_ as { withDescription: boolean };
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
@@ -21,13 +22,12 @@ describe("Widget properties with description should have matching translations",
expect("description" in value).toBe(optionValue.withDescription);
});
});
- });
+ }
});
-describe("Widget properties should have matching name translations", async () => {
- const enTranslation = await createLanguageMapping().en();
- objectEntries(widgetImports).forEach(([key, value]) => {
- Object.keys(value.definition.createOptions({} as SettingsContextProps)).forEach((optionKey) => {
+describe("Widget properties should have matching name translations", () => {
+ for (const [key, definition] of widgetDefinitions) {
+ Object.keys(definition.createOptions({} as SettingsContextProps)).forEach((optionKey) => {
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
@@ -38,5 +38,5 @@ describe("Widget properties should have matching name translations", async () =>
expect("label" in value).toBe(true);
});
});
- });
+ }
});
diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx
index 0329597b1..6b11453b2 100644
--- a/packages/widgets/src/weather/component.tsx
+++ b/packages/widgets/src/weather/component.tsx
@@ -43,7 +43,7 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr
}
interface WeatherProps extends Pick, "options"> {
- weather: RouterOutputs["widget"]["weather"]["atLocation"];
+ weather: NonNullable;
}
const DailyWeather = ({ options, weather }: WeatherProps) => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f8ba04d90..35de59617 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,6 +12,9 @@ catalogs:
'@auth/drizzle-adapter':
specifier: ^1.11.2
version: 1.11.2
+ '@babel/parser':
+ specifier: ^7.29.7
+ version: 7.29.7
'@ctrl/deluge':
specifier: ^8.0.0
version: 8.0.0
@@ -126,9 +129,9 @@ catalogs:
'@tabler/icons-react':
specifier: ^3.44.0
version: 3.44.0
- '@tanstack/query-async-storage-persister':
- specifier: ^5.101.2
- version: 5.101.2
+ '@tanstack/react-devtools':
+ specifier: ^0.10.8
+ version: 0.10.9
'@tanstack/react-query':
specifier: ^5.101.2
version: 5.101.2
@@ -138,9 +141,6 @@ catalogs:
'@tanstack/react-query-next-experimental':
specifier: ^5.101.2
version: 5.101.2
- '@tanstack/react-query-persist-client':
- specifier: ^5.101.2
- version: 5.101.2
'@testcontainers/mysql':
specifier: ^12.0.4
version: 12.0.4
@@ -592,6 +592,9 @@ importers:
specifier: 'catalog:'
version: 10.4.0
devDependencies:
+ '@babel/parser':
+ specifier: 'catalog:'
+ version: 7.29.7
'@playwright/test':
specifier: 'catalog:'
version: 1.61.1
@@ -621,7 +624,7 @@ importers:
version: 2.10.4(@types/node@24.13.3)
'@vitejs/plugin-react':
specifier: 'catalog:'
- version: 6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
+ version: 6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0))
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.10(vitest@4.1.10)
@@ -652,6 +655,9 @@ importers:
testcontainers:
specifier: 'catalog:'
version: 12.0.4(supports-color@10.2.2)
+ tsx:
+ specifier: 'catalog:'
+ version: 4.20.4
turbo:
specifier: 'catalog:'
version: 2.10.4
@@ -660,13 +666,13 @@ importers:
version: 7.0.2
vite:
specifier: 'catalog:'
- version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
+ version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0)
vite-tsconfig-paths:
specifier: 'catalog:'
- version: 6.1.1(supports-color@10.2.2)(typescript@7.0.2)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
+ version: 6.1.1(supports-color@10.2.2)(typescript@7.0.2)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0))
vitest:
specifier: 'catalog:'
- version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
+ version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0))
apps/docs:
dependencies:
@@ -917,9 +923,9 @@ importers:
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
- '@tanstack/query-async-storage-persister':
+ '@tanstack/react-devtools':
specifier: 'catalog:'
- version: 5.101.2
+ version: 0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.101.2(react@19.2.7)
@@ -929,9 +935,6 @@ importers:
'@tanstack/react-query-next-experimental':
specifier: 'catalog:'
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(next@16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
- '@tanstack/react-query-persist-client':
- specifier: 'catalog:'
- version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)
'@trpc/client':
specifier: 'catalog:'
version: 11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2)
@@ -1039,7 +1042,7 @@ importers:
version: 2.2.6
trpc-to-mcp:
specifier: 'catalog:'
- version: 1.3.2(f31d4de9480279dfeec293f2a3399f83)
+ version: 1.3.2(7c45d4095abf7cef4938a6fc2cc62863)
use-deep-compare-effect:
specifier: 'catalog:'
version: 1.8.1(react@19.2.7)
@@ -1346,7 +1349,7 @@ importers:
version: 2.2.6
trpc-to-mcp:
specifier: 'catalog:'
- version: 1.3.2(f6788371aeb2324a2a38b86e888105f6)
+ version: 1.3.2(09f4f932022b61bbb1db30a9d75546c7)
trpc-to-openapi:
specifier: 'catalog:'
version: 3.3.0(patch_hash=2ca3c16af0fcca0c736697ad4fe553a14f794524fa9ce0d5c3e8ee4aea76090c)(@trpc/server@11.18.0(typescript@7.0.2))(zod-openapi@5.4.6(zod@4.4.3))(zod@4.4.3)
@@ -6895,6 +6898,36 @@ packages:
'@so-ric/colorspace@1.1.6':
resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==}
+ '@solid-primitives/event-listener@2.4.6':
+ resolution: {integrity: sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
+ '@solid-primitives/keyboard@1.3.7':
+ resolution: {integrity: sha512-558RPNYnXx4nGh537DSqAn4xMrC8iFipl/5+xzgzWoTNFst4RnUN3BOLmtDjJ0UGGoQXVMALYR3bNOHM0xnt1Q==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
+ '@solid-primitives/resize-observer@2.2.0':
+ resolution: {integrity: sha512-9Fuu/EWBeGj+atGHRJp70HKhdfalmpjwxY8a32NZixdLNmfCJ45AfhLQNr6uOzETbbiMx4iCKlTrJ8KZCHC2Ww==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
+ '@solid-primitives/rootless@1.5.4':
+ resolution: {integrity: sha512-TOIZa1VUfVJ+9nkCcRajw3U4t9vBOP1HxX1WHNTbXq32mXwlqTvUnC4CRIilohcryBkT9u2ZkhUDSHRTaGp55g==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
+ '@solid-primitives/static-store@0.1.4':
+ resolution: {integrity: sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
+ '@solid-primitives/utils@6.4.1':
+ resolution: {integrity: sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg==}
+ peerDependencies:
+ solid-js: ^1.6.12
+
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -7281,21 +7314,50 @@ packages:
'@tailwindcss/postcss@4.3.2':
resolution: {integrity: sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==}
+ '@tanstack/devtools-client@0.0.8':
+ resolution: {integrity: sha512-cG3iZkGWCwN330bLBKa8+9r4Of2AXNoz2zUqcsy/4XsD3105ghVBx78cGyvJj9fSclNomPxoqAnDGXXhg1WLvA==}
+ engines: {node: '>=18'}
+
+ '@tanstack/devtools-event-bus@0.4.2':
+ resolution: {integrity: sha512-2LHzhwBFlKHCcklsQrGe8TeyjHd4XAF8nuCO6wHmva5fePUkJUULbu6CsCNAlGlCi0KkEsMXZSvRdR4HgMq4yA==}
+ engines: {node: '>=18'}
+
+ '@tanstack/devtools-event-client@0.5.0':
+ resolution: {integrity: sha512-H+OH3zC6Vhu/K0NaVfQKknEKawc/+2PT+D3SB3Ox0V8SiMlTo0abbmH2rH0721R2aNYbjdMXA1oENOd8E2UVoA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ '@tanstack/devtools-ui@0.6.0':
+ resolution: {integrity: sha512-CVaM6rT6Nl5ijo83vJYFa2SjofvpuOl/uOvbYGhBrRgUhhelNHhx8zZX+hnZCHmIr0/lzM65hsocnZ72592Rvg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ solid-js: '>=1.9.7'
+
+ '@tanstack/devtools@0.13.0':
+ resolution: {integrity: sha512-p/nOH9bS/OO/u3402zPjoGu+Mz6Fzi/iRqJuYghuuYRUY32kZt+C0/d+pP/bi6/2JTi1FdT6oEXI2lWlA5tXxw==}
+ engines: {node: '>=18'}
+ hasBin: true
+ peerDependencies:
+ solid-js: '>=1.9.7'
+
'@tanstack/match-sorter-utils@8.19.4':
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
engines: {node: '>=12'}
- '@tanstack/query-async-storage-persister@5.101.2':
- resolution: {integrity: sha512-jf7zvVKZ5q2j/xuhbTIBUpw5xt6cw/ioOFQquxK7fRY0AmMQP8A0JVMOgYoeQLA4PKdeT7RxsuLAF0Lft9oJ8Q==}
-
'@tanstack/query-core@5.101.2':
resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==}
'@tanstack/query-devtools@5.101.2':
resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==}
- '@tanstack/query-persist-client-core@5.101.2':
- resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==}
+ '@tanstack/react-devtools@0.10.9':
+ resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ '@types/react-dom': '>=16.8'
+ react: '>=16.8'
+ react-dom: '>=16.8'
'@tanstack/react-query-devtools@5.101.2':
resolution: {integrity: sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==}
@@ -7310,12 +7372,6 @@ packages:
next: ^13 || ^14 || ^15 || ^16
react: ^18 || ^19
- '@tanstack/react-query-persist-client@5.101.2':
- resolution: {integrity: sha512-19UpaRtf0lvB2QAJ2MoixxtGf3osMOd508g99EsttUj4+3eLs+ulnNgYjB7AkQMHrRlcbVXqpVNqWkB4a7g8Zw==}
- peerDependencies:
- '@tanstack/react-query': ^5.101.2
- react: ^18 || ^19
-
'@tanstack/react-query@5.101.2':
resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==}
peerDependencies:
@@ -10642,6 +10698,11 @@ packages:
globrex@0.1.2:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
+ goober@2.1.19:
+ resolution: {integrity: sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==}
+ peerDependencies:
+ csstype: ^3.0.10
+
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -14017,6 +14078,16 @@ packages:
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
+ seroval-plugins@1.5.6:
+ resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+
+ seroval@1.5.6:
+ resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==}
+ engines: {node: '>=10'}
+
serve-handler@6.1.7:
resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==}
@@ -14157,6 +14228,9 @@ packages:
resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
+ solid-js@1.9.14:
+ resolution: {integrity: sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ==}
+
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
@@ -20770,6 +20844,40 @@ snapshots:
color: 5.0.2
text-hex: 1.0.0
+ '@solid-primitives/event-listener@2.4.6(solid-js@1.9.14)':
+ dependencies:
+ '@solid-primitives/utils': 6.4.1(solid-js@1.9.14)
+ solid-js: 1.9.14
+
+ '@solid-primitives/keyboard@1.3.7(solid-js@1.9.14)':
+ dependencies:
+ '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.14)
+ '@solid-primitives/rootless': 1.5.4(solid-js@1.9.14)
+ '@solid-primitives/utils': 6.4.1(solid-js@1.9.14)
+ solid-js: 1.9.14
+
+ '@solid-primitives/resize-observer@2.2.0(solid-js@1.9.14)':
+ dependencies:
+ '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.14)
+ '@solid-primitives/rootless': 1.5.4(solid-js@1.9.14)
+ '@solid-primitives/static-store': 0.1.4(solid-js@1.9.14)
+ '@solid-primitives/utils': 6.4.1(solid-js@1.9.14)
+ solid-js: 1.9.14
+
+ '@solid-primitives/rootless@1.5.4(solid-js@1.9.14)':
+ dependencies:
+ '@solid-primitives/utils': 6.4.1(solid-js@1.9.14)
+ solid-js: 1.9.14
+
+ '@solid-primitives/static-store@0.1.4(solid-js@1.9.14)':
+ dependencies:
+ '@solid-primitives/utils': 6.4.1(solid-js@1.9.14)
+ solid-js: 1.9.14
+
+ '@solid-primitives/utils@6.4.1(solid-js@1.9.14)':
+ dependencies:
+ solid-js: 1.9.14
+
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
@@ -21070,22 +21178,64 @@ snapshots:
postcss: 8.5.15
tailwindcss: 4.3.2
+ '@tanstack/devtools-client@0.0.8':
+ dependencies:
+ '@tanstack/devtools-event-client': 0.5.0
+
+ '@tanstack/devtools-event-bus@0.4.2':
+ dependencies:
+ ws: 8.21.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@tanstack/devtools-event-client@0.5.0': {}
+
+ '@tanstack/devtools-ui@0.6.0(csstype@3.2.3)(solid-js@1.9.14)':
+ dependencies:
+ clsx: 2.1.1
+ dayjs: 1.11.21
+ goober: 2.1.19(csstype@3.2.3)
+ solid-js: 1.9.14
+ transitivePeerDependencies:
+ - csstype
+
+ '@tanstack/devtools@0.13.0(csstype@3.2.3)(solid-js@1.9.14)':
+ dependencies:
+ '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.14)
+ '@solid-primitives/keyboard': 1.3.7(solid-js@1.9.14)
+ '@solid-primitives/resize-observer': 2.2.0(solid-js@1.9.14)
+ '@tanstack/devtools-client': 0.0.8
+ '@tanstack/devtools-event-bus': 0.4.2
+ '@tanstack/devtools-ui': 0.6.0(csstype@3.2.3)(solid-js@1.9.14)
+ clsx: 2.1.1
+ goober: 2.1.19(csstype@3.2.3)
+ solid-js: 1.9.14
+ transitivePeerDependencies:
+ - bufferutil
+ - csstype
+ - utf-8-validate
+
'@tanstack/match-sorter-utils@8.19.4':
dependencies:
remove-accents: 0.5.0
- '@tanstack/query-async-storage-persister@5.101.2':
- dependencies:
- '@tanstack/query-core': 5.101.2
- '@tanstack/query-persist-client-core': 5.101.2
-
'@tanstack/query-core@5.101.2': {}
'@tanstack/query-devtools@5.101.2': {}
- '@tanstack/query-persist-client-core@5.101.2':
+ '@tanstack/react-devtools@0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)':
dependencies:
- '@tanstack/query-core': 5.101.2
+ '@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14)
+ '@types/react': 19.2.17
+ '@types/react-dom': 19.2.3(@types/react@19.2.17)
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ transitivePeerDependencies:
+ - bufferutil
+ - csstype
+ - solid-js
+ - utf-8-validate
'@tanstack/react-query-devtools@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)':
dependencies:
@@ -21099,12 +21249,6 @@ snapshots:
next: 16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: 19.2.7
- '@tanstack/react-query-persist-client@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)':
- dependencies:
- '@tanstack/query-persist-client-core': 5.101.2
- '@tanstack/react-query': 5.101.2(react@19.2.7)
- react: 19.2.7
-
'@tanstack/react-query@5.101.2(react@19.2.7)':
dependencies:
'@tanstack/query-core': 5.101.2
@@ -21964,10 +22108,10 @@ snapshots:
global: 4.4.0
is-function: 1.0.2
- '@vitejs/plugin-react@6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))':
+ '@vitejs/plugin-react@6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
- vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0)
optionalDependencies:
babel-plugin-react-compiler: 1.0.0
@@ -21983,7 +22127,7 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
- vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
+ vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0))
'@vitest/expect@4.1.10':
dependencies:
@@ -21994,6 +22138,15 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
+ '@vitest/mocker@4.1.10(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0))':
+ dependencies:
+ '@vitest/spy': 4.1.10
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ msw: 2.14.6(@types/node@24.13.3)(typescript@7.0.2)
+ vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0)
+
'@vitest/mocker@4.1.10(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.10
@@ -22002,6 +22155,7 @@ snapshots:
optionalDependencies:
msw: 2.14.6(@types/node@24.13.3)(typescript@7.0.2)
vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
+ optional: true
'@vitest/pretty-format@4.1.10':
dependencies:
@@ -22030,7 +22184,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
- vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
+ vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0))
'@vitest/utils@4.1.10':
dependencies:
@@ -22582,7 +22736,7 @@ snapshots:
before-after-hook@4.0.0: {}
- better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0(supports-color@10.2.2))(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)(vue@3.5.35(typescript@7.0.2)):
+ better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0(supports-color@10.2.2))(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10)(vue@3.5.35(typescript@7.0.2)):
dependencies:
'@better-auth/core': 1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0(supports-color@10.2.2))(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))
@@ -22610,6 +22764,7 @@ snapshots:
pg: 8.22.0
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
+ solid-js: 1.9.14
vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
vue: 3.5.35(typescript@7.0.2)
transitivePeerDependencies:
@@ -24549,7 +24704,7 @@ snapshots:
'@petamoriken/float16': 3.9.3
debug: 4.4.3(supports-color@10.2.2)
env-paths: 3.0.0
- semver: 7.7.4
+ semver: 7.8.5
shell-quote: 1.8.4
which: 4.0.0
transitivePeerDependencies:
@@ -24679,6 +24834,10 @@ snapshots:
globrex@0.1.2: {}
+ goober@2.1.19(csstype@3.2.3):
+ dependencies:
+ csstype: 3.2.3
+
gopd@1.2.0: {}
got@12.6.1:
@@ -28699,6 +28858,12 @@ snapshots:
dependencies:
randombytes: 2.1.0
+ seroval-plugins@1.5.6(seroval@1.5.6):
+ dependencies:
+ seroval: 1.5.6
+
+ seroval@1.5.6: {}
+
serve-handler@6.1.7:
dependencies:
bytes: 3.0.0
@@ -28907,6 +29072,12 @@ snapshots:
ip-address: 9.0.5
smart-buffer: 4.2.0
+ solid-js@1.9.14:
+ dependencies:
+ csstype: 3.2.3
+ seroval: 1.5.6
+ seroval-plugins: 1.5.6(seroval@1.5.6)
+
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
@@ -29427,23 +29598,23 @@ snapshots:
trough@2.2.0: {}
- trpc-to-mcp@1.3.2(f31d4de9480279dfeec293f2a3399f83):
+ trpc-to-mcp@1.3.2(09f4f932022b61bbb1db30a9d75546c7):
dependencies:
'@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@4.4.3)
'@trpc/server': 11.18.0(typescript@7.0.2)
- better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0(supports-color@10.2.2))(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)(vue@3.5.35(typescript@7.0.2))
- mcp-handler: 1.1.0(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(next@16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))
+ better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0(supports-color@10.2.2))(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10)(vue@3.5.35(typescript@7.0.2))
+ mcp-handler: 1.1.0(@modelcontextprotocol/sdk@1.26.0(supports-color@10.2.2)(zod@4.4.3))(next@16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))
zod: 4.4.3
transitivePeerDependencies:
- '@cfworker/json-schema'
- supports-color
- trpc-to-mcp@1.3.2(f6788371aeb2324a2a38b86e888105f6):
+ trpc-to-mcp@1.3.2(7c45d4095abf7cef4938a6fc2cc62863):
dependencies:
'@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@4.4.3)
'@trpc/server': 11.18.0(typescript@7.0.2)
- better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0(supports-color@10.2.2))(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)(vue@3.5.35(typescript@7.0.2))
- mcp-handler: 1.1.0(@modelcontextprotocol/sdk@1.26.0(supports-color@10.2.2)(zod@4.4.3))(next@16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))
+ better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0(supports-color@10.2.2))(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)(vitest@4.1.10)(vue@3.5.35(typescript@7.0.2))
+ mcp-handler: 1.1.0(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(next@16.3.0-preview.9(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))
zod: 4.4.3
transitivePeerDependencies:
- '@cfworker/json-schema'
@@ -29849,16 +30020,34 @@ snapshots:
dependencies:
global: 4.4.0
- vite-tsconfig-paths@6.1.1(supports-color@10.2.2)(typescript@7.0.2)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
+ vite-tsconfig-paths@6.1.1(supports-color@10.2.2)(typescript@7.0.2)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
globrex: 0.1.2
tsconfck: 3.1.3(typescript@7.0.2)
- vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
- typescript
+ vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0):
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.5
+ postcss: 8.5.16
+ rolldown: 1.1.4
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 24.13.3
+ esbuild: 0.28.1
+ fsevents: 2.3.3
+ jiti: 2.7.0
+ sass: 1.101.0
+ sugarss: 5.0.0(postcss@8.5.16)
+ terser: 5.46.1
+ tsx: 4.20.4
+ yaml: 2.9.0
+
vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
@@ -29876,6 +30065,38 @@ snapshots:
terser: 5.46.1
tsx: 4.21.0
yaml: 2.9.0
+ optional: true
+
+ vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0)):
+ dependencies:
+ '@vitest/expect': 4.1.10
+ '@vitest/mocker': 4.1.10(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0))
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/runner': 4.1.10
+ '@vitest/snapshot': 4.1.10
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
+ es-module-lexer: 2.1.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.4
+ std-env: 4.0.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.2
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.0
+ vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.20.4)(yaml@2.9.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@opentelemetry/api': 1.9.0
+ '@types/node': 24.13.3
+ '@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
+ '@vitest/ui': 4.1.10(vitest@4.1.10)
+ jsdom: 29.1.1(@noble/hashes@2.0.1)
+ transitivePeerDependencies:
+ - msw
vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@7.0.2))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
@@ -29907,6 +30128,7 @@ snapshots:
jsdom: 29.1.1(@noble/hashes@2.0.1)
transitivePeerDependencies:
- msw
+ optional: true
vue-component-type-helpers@3.3.4: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 49d8f0dc8..77cb6bd12 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -3,6 +3,7 @@ packages:
- packages/*
- tooling/*
catalog:
+ "@babel/parser": ^7.29.7
"@auth/core": ^0.41.2
"@auth/drizzle-adapter": ^1.11.2
"@ctrl/deluge": ^8.0.0
@@ -44,11 +45,10 @@ catalog:
"@semantic-release/release-notes-generator": ^14.1.1
"@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.44.0
- "@tanstack/query-async-storage-persister": ^5.101.2
+ "@tanstack/react-devtools": ^0.10.8
"@tanstack/react-query": ^5.101.2
"@tanstack/react-query-devtools": ^5.101.2
"@tanstack/react-query-next-experimental": ^5.101.2
- "@tanstack/react-query-persist-client": ^5.101.2
"@testcontainers/mysql": ^12.0.4
"@testcontainers/postgresql": ^12.0.4
"@testcontainers/redis": ^12.0.4
diff --git a/scripts/benchmarks/benchmark-compare.mts b/scripts/benchmarks/benchmark-compare.mts
new file mode 100644
index 000000000..2b77fd2b0
--- /dev/null
+++ b/scripts/benchmarks/benchmark-compare.mts
@@ -0,0 +1,40 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+import type { BenchmarkPairInput, BuildResult, RuntimeResult } from "./benchmark-pair-lib.mts";
+import { compareBenchmarkPair } from "./benchmark-pair-lib.mts";
+
+const values = new Map();
+for (let index = 2; index < process.argv.length; index += 2) {
+ const flag = process.argv[index];
+ const value = process.argv[index + 1];
+ if (!flag?.startsWith("--") || !value) throw new Error(`Invalid argument at ${String(flag)}`);
+ values.set(flag, [...(values.get(flag) ?? []), value]);
+}
+
+const requireMany = (flag: string) => {
+ const result = values.get(flag) ?? [];
+ if (result.length === 0) throw new Error(`${flag} is required`);
+ return result;
+};
+const requireOne = (flag: string) => {
+ const result = requireMany(flag);
+ if (result.length !== 1) throw new Error(`${flag} must be provided exactly once`);
+ return result[0] as string;
+};
+const readJson = async (file: string) => JSON.parse(await readFile(path.resolve(file), "utf8")) as T;
+
+const input: BenchmarkPairInput = {
+ baselineWarmup: await readJson(requireOne("--baseline-warmup")),
+ candidateWarmup: await readJson(requireOne("--candidate-warmup")),
+ baselineBuilds: await Promise.all(requireMany("--baseline-build").map(readJson)),
+ candidateBuilds: await Promise.all(requireMany("--candidate-build").map(readJson)),
+ baselineRuntime: await readJson(requireOne("--baseline-runtime")),
+ candidateRuntime: await readJson(requireOne("--candidate-runtime")),
+};
+const result = compareBenchmarkPair(input);
+const outputPath = path.resolve(requireOne("--output"));
+await mkdir(path.dirname(outputPath), { recursive: true });
+await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
+console.log(JSON.stringify({ outputPath, ...result }, null, 2));
+if (!result.claimEligible) process.exitCode = 1;
diff --git a/scripts/benchmarks/benchmark-pair-lib.mts b/scripts/benchmarks/benchmark-pair-lib.mts
new file mode 100644
index 000000000..4ee7885aa
--- /dev/null
+++ b/scripts/benchmarks/benchmark-pair-lib.mts
@@ -0,0 +1,713 @@
+import { summarize } from "./dev-benchmark-lib.mts";
+
+type BuildResult = {
+ schemaVersion: number;
+ createdAt: string;
+ run: { startedAt: string; finishedAt: string };
+ claimEligible: boolean;
+ claimIneligibleReasons: string[];
+ mode: string;
+ builder: string | null;
+ runLabel: string | null;
+ declaredRevision: string | null;
+ series: { id: string | null; cacheState: string | null; repetition: number; totalRepetitions: number };
+ source: {
+ headSha: string;
+ measuredRevision: string;
+ fingerprint: string;
+ baseFingerprint: string;
+ syntheticSourceChange: { file: string; id: string } | null;
+ };
+ host: {
+ architecture: string;
+ dockerServer: { architecture: string; operatingSystem: string; targetPlatform: string; version: string | null };
+ platform: string;
+ };
+ image: {
+ architecture: string;
+ digest: string | null;
+ id: string;
+ revision: string | null;
+ sourceFingerprint: string | null;
+ targetPlatform: string;
+ };
+ builderCache: {
+ expected: Array<{ logicalId: string; target: string; mustIncreaseUsage: boolean }>;
+ before: BuildCacheSnapshot | null;
+ after: BuildCacheSnapshot | null;
+ afterStability: { attempts: number; pollIntervalMs: number; stableForMs: number; timeoutMs: number } | null;
+ };
+ build: { nextCompileMs: number | null; wallTimeMs: number };
+};
+
+type BuildCacheSnapshot = {
+ cacheMountCount: number;
+ cacheMounts: Array<{
+ logicalId: string | null;
+ recordId: string;
+ target: string | null;
+ usageCount: number | null;
+ }>;
+ recordCount: number;
+ recordsFingerprint: string;
+};
+
+type RuntimePageSample = {
+ boardMountedMs: number;
+ fcpMs: number;
+ lcpFinalizedByInputMs: number;
+ lcpMs: number;
+ ttfbMs: number;
+ widgetImplementationsMountedMs: number | null;
+ widgetDataSettledMs: number;
+};
+
+type RuntimeResult = {
+ schemaVersion: number;
+ claimEligible: boolean;
+ claimIneligibleReasons: string[];
+ claimScope: string[];
+ metricEligibility: Record;
+ image: {
+ architecture: string;
+ digest: string | null;
+ id: string;
+ revision: string | null;
+ sourceFingerprint: string | null;
+ };
+ host: {
+ architecture: string;
+ node: { version: string; v8Version: string };
+ platform: string;
+ release: string;
+ };
+ provenance: {
+ browser: {
+ engine: string;
+ executablePath: string;
+ headless: boolean;
+ httpCachePolicy: string;
+ launchArguments?: string[];
+ version: string;
+ viewport: unknown;
+ };
+ container: {
+ cpuLimit: number;
+ memoryLimitBytes: number;
+ network: { internal: boolean };
+ node: { architecture: string; platform: string; version: string; v8Version: string };
+ };
+ docker: { engine: unknown; version: unknown };
+ ingressProxy: { excludedFromMemorySamples: boolean; implementation: string };
+ };
+ browserNetworkIsolation: {
+ active: boolean;
+ canaries: {
+ http: { attemptCount: number; responseHeader: string | null; status: number | null };
+ webSockets: Array<{
+ attemptCount: number;
+ browserEventCount: number;
+ closeCount: number;
+ connectTarget: string;
+ errorEvent: boolean;
+ frameReceivedCount: number;
+ frameSentCount: number;
+ opened: boolean;
+ socketErrorCount: number;
+ timedOut: boolean;
+ url: string;
+ }>;
+ };
+ externalBrowserWebSockets: unknown[];
+ unexpectedExternalResponses: unknown[];
+ };
+ serverNetworkIsolation: {
+ active: boolean;
+ canary: { directIp: { blocked: boolean }; domain: { blocked: boolean } };
+ network: { internal: boolean };
+ };
+ checkpoints: Array<{ capturedAt: string; name: string }>;
+ workload: {
+ settleMs: number;
+ sampleIntervalMs: number;
+ settleSampleCount: number;
+ lcpObservationMs: number;
+ lcpStabilityMs: number;
+ widgetDataQuietMs: number;
+ spotlightIdleMs: number;
+ spotlightIdlePolicy: string;
+ interactionIterations: number;
+ widgetCount: number;
+ implementationMarkerPolicy: string;
+ widgetImplementationMarkerCount: number;
+ widgetErrorCount: number;
+ startToReadyMs: number;
+ coldSpotlightFeedbackMs: number | null;
+ coldSpotlightReadyMs: number | null;
+ postIdleWarmInteractionMs: number | null;
+ routeTimings: Array<{ path: string }>;
+ pageLoadSamples: RuntimePageSample[];
+ };
+ summary: {
+ startupBytes: number | null;
+ workloadBytes: number | null;
+ settledBytes: number | null;
+ peakBytes: number | null;
+ };
+};
+
+export type BenchmarkPairInput = {
+ baselineWarmup: BuildResult;
+ candidateWarmup: BuildResult;
+ baselineBuilds: BuildResult[];
+ candidateBuilds: BuildResult[];
+ baselineRuntime: RuntimeResult;
+ candidateRuntime: RuntimeResult;
+};
+
+const stable = (value: unknown) => JSON.stringify(value);
+const unique = (values: T[]) => [...new Set(values)];
+
+const improvement = (baseline: number | null, candidate: number | null) => ({
+ baseline,
+ candidate,
+ delta: baseline === null || candidate === null ? null : candidate - baseline,
+ improvementPercent:
+ baseline === null || candidate === null || baseline === 0 ? null : ((baseline - candidate) / baseline) * 100,
+});
+
+const validateRunWindow = (label: string, build: BuildResult, reasons: string[]) => {
+ const startedAt = Date.parse(build.run.startedAt);
+ const finishedAt = Date.parse(build.run.finishedAt);
+ if (!Number.isFinite(startedAt) || !Number.isFinite(finishedAt) || finishedAt < startedAt) {
+ reasons.push(`${label} build run window is invalid`);
+ }
+ if (build.createdAt !== build.run.finishedAt) reasons.push(`${label} build completion timestamp is inconsistent`);
+};
+
+const validateMeasuredBuildCache = (label: string, build: BuildResult, reasons: string[]) => {
+ const { after, before, expected } = build.builderCache;
+ if (expected.length === 0) {
+ reasons.push(`${label} build declares no expected cache mounts`);
+ return;
+ }
+ if (!before || !after) {
+ reasons.push(`${label} build cache continuity snapshots are missing`);
+ return;
+ }
+ for (const expectedMount of expected) {
+ const matches = (snapshot: BuildCacheSnapshot) =>
+ snapshot.cacheMounts.filter(
+ ({ logicalId, target }) => logicalId === expectedMount.logicalId && target === expectedMount.target,
+ );
+ const beforeMatches = matches(before);
+ const afterMatches = matches(after);
+ if (beforeMatches.length !== 1 || afterMatches.length !== 1) {
+ reasons.push(`${label} build cache mount ${expectedMount.logicalId} cannot be traced uniquely`);
+ continue;
+ }
+ const beforeMount = beforeMatches[0];
+ const afterMount = afterMatches[0];
+ if (!beforeMount || !afterMount || beforeMount.recordId !== afterMount.recordId) {
+ reasons.push(`${label} build cache mount ${expectedMount.logicalId} was replaced`);
+ continue;
+ }
+ if (
+ expectedMount.mustIncreaseUsage &&
+ (beforeMount.usageCount === null ||
+ afterMount.usageCount === null ||
+ afterMount.usageCount <= beforeMount.usageCount)
+ ) {
+ reasons.push(`${label} build cache mount ${expectedMount.logicalId} usage did not increase`);
+ }
+ }
+};
+
+const validateBuildSide = (label: string, builds: BuildResult[], reasons: string[]) => {
+ if (builds.length === 0) {
+ reasons.push(`${label} build series is empty`);
+ return;
+ }
+
+ const first = builds[0];
+ if (!first) return;
+ for (const build of builds) {
+ if (build.schemaVersion !== 4) reasons.push(`${label} build schema is not 4`);
+ validateRunWindow(`${label} repetition ${build.series.repetition}`, build, reasons);
+ if (!build.claimEligible || build.claimIneligibleReasons.length > 0) {
+ reasons.push(`${label} build repetition ${build.series.repetition} is not claim eligible`);
+ }
+ if (build.mode !== "source-change") reasons.push(`${label} build is not a source-change rebuild`);
+ if (build.runLabel !== label) reasons.push(`${label} build run label is not ${label}`);
+ if (build.series.cacheState !== "warm") reasons.push(`${label} build cache is not warm`);
+ if (!build.source.syntheticSourceChange) reasons.push(`${label} build has no synthetic source change`);
+ if (build.source.fingerprint === build.source.baseFingerprint) {
+ reasons.push(`${label} build synthetic source fingerprint did not change`);
+ }
+ if (build.image.sourceFingerprint !== build.source.fingerprint) {
+ reasons.push(`${label} build image fingerprint does not match its immutable source snapshot`);
+ }
+ if (!Number.isFinite(build.build.nextCompileMs) || (build.build.nextCompileMs ?? 0) <= 0) {
+ reasons.push(`${label} build has no valid Next compile timing`);
+ }
+ if (!Number.isFinite(build.build.wallTimeMs) || build.build.wallTimeMs <= 0) {
+ reasons.push(`${label} build has no valid wall timing`);
+ }
+ validateMeasuredBuildCache(`${label} repetition ${build.series.repetition}`, build, reasons);
+ if (!build.builderCache.afterStability || build.builderCache.afterStability.stableForMs < 10_000) {
+ reasons.push(`${label} build cache snapshot did not settle for 10000ms`);
+ }
+ if (!Number.isFinite(Date.parse(build.createdAt))) reasons.push(`${label} build timestamp is invalid`);
+ }
+
+ const expectedRepetitions = Array.from({ length: first.series.totalRepetitions }, (_, index) => index + 1);
+ const actualRepetitions = builds.map(({ series }) => series.repetition).toSorted((left, right) => left - right);
+ if (first.series.totalRepetitions < 2 || stable(actualRepetitions) !== stable(expectedRepetitions)) {
+ reasons.push(`${label} build repetitions are incomplete`);
+ }
+
+ const stableFields: Array<[string, unknown[]]> = [
+ ["builder", builds.map(({ builder }) => builder)],
+ ["series id", builds.map(({ series }) => series.id)],
+ ["total repetitions", builds.map(({ series }) => series.totalRepetitions)],
+ ["declared revision", builds.map(({ declaredRevision }) => declaredRevision)],
+ ["head SHA", builds.map(({ source }) => source.headSha)],
+ ["measured revision", builds.map(({ source }) => source.measuredRevision)],
+ ["base fingerprint", builds.map(({ source }) => source.baseFingerprint)],
+ ["host", builds.map(({ host }) => stable(host))],
+ ["image platform", builds.map(({ image }) => `${image.architecture}:${image.targetPlatform}`)],
+ ["expected cache mounts", builds.map(({ builderCache }) => stable(builderCache.expected))],
+ ];
+ for (const [field, values] of stableFields) {
+ if (unique(values).length !== 1) reasons.push(`${label} build ${field} changes within the series`);
+ }
+ if (!first.builder) reasons.push(`${label} build builder is missing`);
+ if (!first.series.id) reasons.push(`${label} build series id is missing`);
+};
+
+const validateWarmup = (label: string, warmup: BuildResult, builds: BuildResult[], reasons: string[]) => {
+ if (warmup.schemaVersion !== 4) reasons.push(`${label} warmup schema is not 4`);
+ validateRunWindow(`${label} warmup`, warmup, reasons);
+ if (warmup.mode !== "cached") reasons.push(`${label} warmup mode is not cached`);
+ if (warmup.runLabel !== label) reasons.push(`${label} warmup run label is not ${label}`);
+ if (warmup.series.cacheState !== "cold") reasons.push(`${label} warmup cache state is not cold`);
+ if (warmup.source.syntheticSourceChange !== null) reasons.push(`${label} warmup contains a synthetic source change`);
+ const expectedWarmupReason = "cached mode does not force an application rebuild";
+ if (
+ warmup.claimEligible ||
+ !warmup.claimIneligibleReasons.includes(expectedWarmupReason) ||
+ warmup.claimIneligibleReasons.some((reason) => reason !== expectedWarmupReason)
+ ) {
+ reasons.push(`${label} warmup has unexpected claim eligibility reasons`);
+ }
+ if (!warmup.builderCache.before || warmup.builderCache.before.recordCount !== 0) {
+ reasons.push(`${label} warmup builder did not begin empty`);
+ }
+ if (!warmup.builderCache.after || warmup.builderCache.after.recordCount === 0) {
+ reasons.push(`${label} warmup did not populate the builder cache`);
+ }
+ if (!warmup.builderCache.afterStability || warmup.builderCache.afterStability.stableForMs < 10_000) {
+ reasons.push(`${label} warmup cache snapshot did not settle for 10000ms`);
+ }
+ for (const expected of warmup.builderCache.expected) {
+ const matches =
+ warmup.builderCache.after?.cacheMounts.filter(
+ ({ logicalId, target }) => logicalId === expected.logicalId && target === expected.target,
+ ) ?? [];
+ if (matches.length !== 1) {
+ reasons.push(`${label} warmup did not populate cache mount ${expected.logicalId} at ${expected.target}`);
+ }
+ }
+ if (warmup.image.sourceFingerprint !== warmup.source.baseFingerprint) {
+ reasons.push(`${label} warmup image fingerprint does not match its source`);
+ }
+ if (warmup.source.fingerprint !== warmup.source.baseFingerprint) {
+ reasons.push(`${label} warmup immutable source fingerprint does not match its base source`);
+ }
+ if (warmup.image.revision !== warmup.source.measuredRevision) {
+ reasons.push(`${label} warmup image revision does not match its source`);
+ }
+
+ const first = builds[0];
+ if (!first) return;
+ const stableFields: Array<[string, unknown, unknown]> = [
+ ["builder", warmup.builder, first.builder],
+ ["series id", warmup.series.id, first.series.id],
+ ["declared revision", warmup.declaredRevision, first.declaredRevision],
+ ["head SHA", warmup.source.headSha, first.source.headSha],
+ ["measured revision", warmup.source.measuredRevision, first.source.measuredRevision],
+ ["base fingerprint", warmup.source.baseFingerprint, first.source.baseFingerprint],
+ ["host", stable(warmup.host), stable(first.host)],
+ [
+ "image platform",
+ `${warmup.image.architecture}:${warmup.image.targetPlatform}`,
+ `${first.image.architecture}:${first.image.targetPlatform}`,
+ ],
+ ["expected cache mounts", stable(warmup.builderCache.expected), stable(first.builderCache.expected)],
+ ];
+ for (const [field, warmupValue, measuredValue] of stableFields) {
+ if (warmupValue !== measuredValue) reasons.push(`${label} warmup ${field} does not match the measured series`);
+ }
+
+ let previous = warmup;
+ for (const build of builds.toSorted((left, right) => left.series.repetition - right.series.repetition)) {
+ if (!previous.builderCache.after || !build.builderCache.before) {
+ reasons.push(`${label} cache continuity snapshot is missing before repetition ${build.series.repetition}`);
+ } else if (previous.builderCache.after.recordsFingerprint !== build.builderCache.before.recordsFingerprint) {
+ reasons.push(`${label} builder cache changed outside repetition ${build.series.repetition}`);
+ }
+ previous = build;
+ }
+};
+
+const runtimeConfiguration = (runtime: RuntimeResult) => ({
+ host: runtime.host,
+ browser: {
+ ...runtime.provenance.browser,
+ launchArguments: runtime.provenance.browser.launchArguments?.map((argument) =>
+ argument.startsWith("--proxy-server=") ? "--proxy-server=" : argument,
+ ),
+ },
+ container: {
+ cpuLimit: runtime.provenance.container.cpuLimit,
+ memoryLimitBytes: runtime.provenance.container.memoryLimitBytes,
+ node: runtime.provenance.container.node,
+ },
+ docker: runtime.provenance.docker,
+ workload: {
+ settleMs: runtime.workload.settleMs,
+ sampleIntervalMs: runtime.workload.sampleIntervalMs,
+ settleSampleCount: runtime.workload.settleSampleCount,
+ lcpObservationMs: runtime.workload.lcpObservationMs,
+ lcpStabilityMs: runtime.workload.lcpStabilityMs,
+ widgetDataQuietMs: runtime.workload.widgetDataQuietMs,
+ spotlightIdleMs: runtime.workload.spotlightIdleMs,
+ interactionIterations: runtime.workload.interactionIterations,
+ widgetCount: runtime.workload.widgetCount,
+ implementationMarkerPolicy: runtime.workload.implementationMarkerPolicy,
+ routePaths: runtime.workload.routeTimings.map(({ path }) => path),
+ pageSampleCount: runtime.workload.pageLoadSamples.length,
+ },
+});
+
+const validateRuntime = (label: string, runtime: RuntimeResult, reasons: string[]) => {
+ const expectedClaimScope = ["pageLoads", "largestContentfulPaint", "widgetDataSettled", "settleStability"];
+ if (runtime.schemaVersion !== 7) reasons.push(`${label} runtime schema is not 7`);
+ if (stable(runtime.claimScope) !== stable(expectedClaimScope)) {
+ reasons.push(`${label} runtime claim scope is incomplete`);
+ }
+ if (!runtime.claimEligible || runtime.claimIneligibleReasons.length > 0) {
+ reasons.push(`${label} runtime is not claim eligible`);
+ }
+ for (const metric of ["pageLoads", "largestContentfulPaint", "widgetDataSettled", "settleStability"]) {
+ if (runtime.metricEligibility[metric]?.claimEligible !== true) {
+ reasons.push(`${label} runtime metric ${metric} is not claim eligible`);
+ }
+ }
+ if (runtime.workload.pageLoadSamples.length < 20) reasons.push(`${label} runtime has fewer than 20 page samples`);
+ if (runtime.workload.lcpObservationMs < 5_000) reasons.push(`${label} runtime LCP observation is below 5000ms`);
+ if (runtime.workload.widgetDataQuietMs < 500)
+ reasons.push(`${label} runtime widget-data quiet window is below 500ms`);
+ if (runtime.workload.settleMs < 600_000) reasons.push(`${label} runtime settle duration is below 600000ms`);
+ const actualSettleSampleCount = runtime.checkpoints.filter(({ name }) => name.startsWith("settle-")).length;
+ if (runtime.workload.settleSampleCount < 20 || actualSettleSampleCount < 20) {
+ reasons.push(`${label} runtime has fewer than 20 settle samples`);
+ }
+ if (runtime.workload.settleSampleCount !== actualSettleSampleCount) {
+ reasons.push(`${label} runtime settle sample count does not match checkpoints`);
+ }
+ const workloadCheckpoints = runtime.checkpoints.filter(({ name }) => name === "workload");
+ const settleCheckpoints = runtime.checkpoints.filter(({ name }) => name.startsWith("settle-"));
+ const expectedSettleNames = Array.from({ length: actualSettleSampleCount }, (_, index) => `settle-${index + 1}`);
+ if (
+ workloadCheckpoints.length !== 1 ||
+ stable(settleCheckpoints.map(({ name }) => name)) !== stable(expectedSettleNames) ||
+ runtime.checkpoints.some(({ capturedAt }) => !Number.isFinite(Date.parse(capturedAt))) ||
+ runtime.checkpoints.some(
+ ({ capturedAt }, index, checkpoints) =>
+ index > 0 && Date.parse(capturedAt) < Date.parse(checkpoints[index - 1]?.capturedAt ?? ""),
+ )
+ ) {
+ reasons.push(`${label} runtime checkpoint chronology is invalid`);
+ }
+ const workloadCapturedAt = Date.parse(workloadCheckpoints[0]?.capturedAt ?? "");
+ const finalSettleCapturedAt = Date.parse(settleCheckpoints.at(-1)?.capturedAt ?? "");
+ if (
+ !Number.isFinite(workloadCapturedAt) ||
+ !Number.isFinite(finalSettleCapturedAt) ||
+ finalSettleCapturedAt - workloadCapturedAt < runtime.workload.settleMs
+ ) {
+ reasons.push(`${label} runtime checkpoint span is shorter than the declared settle duration`);
+ }
+ for (const metric of [
+ "boardMountedMs",
+ "fcpMs",
+ "lcpFinalizedByInputMs",
+ "lcpMs",
+ "ttfbMs",
+ "widgetImplementationsMountedMs",
+ "widgetDataSettledMs",
+ ] satisfies Array) {
+ const values = runtime.workload.pageLoadSamples.map((sample) => sample[metric]);
+ if (
+ values.some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0) ||
+ runtimeMetric(runtime, metric).count !== runtime.workload.pageLoadSamples.length
+ ) {
+ reasons.push(`${label} runtime metric ${metric} has invalid or missing samples`);
+ }
+ }
+ if (
+ runtime.workload.pageLoadSamples.some(
+ ({ lcpFinalizedByInputMs, lcpMs }) =>
+ lcpFinalizedByInputMs < runtime.workload.lcpObservationMs || lcpMs > lcpFinalizedByInputMs,
+ )
+ ) {
+ reasons.push(`${label} runtime LCP samples were not observed and finalized for the declared window`);
+ }
+ if (runtime.workload.widgetErrorCount !== 0) reasons.push(`${label} runtime rendered widget errors`);
+ if (runtime.workload.widgetImplementationMarkerCount !== runtime.workload.widgetCount) {
+ reasons.push(`${label} runtime did not mount every widget implementation`);
+ }
+ const webSocketCanaryProtocols = runtime.browserNetworkIsolation.canaries.webSockets.flatMap(
+ ({ connectTarget, url }) => {
+ try {
+ const parsed = new URL(url);
+ const defaultPort = parsed.protocol === "ws:" ? "80" : parsed.protocol === "wss:" ? "443" : null;
+ return defaultPort && connectTarget === `${parsed.hostname}:${parsed.port || defaultPort}`
+ ? [parsed.protocol]
+ : [];
+ } catch {
+ return [];
+ }
+ },
+ );
+ if (
+ !runtime.browserNetworkIsolation.active ||
+ runtime.browserNetworkIsolation.unexpectedExternalResponses.length > 0 ||
+ runtime.browserNetworkIsolation.externalBrowserWebSockets.length > 0 ||
+ runtime.browserNetworkIsolation.canaries.http.status !== 502 ||
+ runtime.browserNetworkIsolation.canaries.http.responseHeader !== "true" ||
+ runtime.browserNetworkIsolation.canaries.http.attemptCount < 1 ||
+ runtime.browserNetworkIsolation.canaries.webSockets.length !== 2 ||
+ stable(webSocketCanaryProtocols.toSorted()) !== stable(["ws:", "wss:"]) ||
+ runtime.browserNetworkIsolation.canaries.webSockets.some(
+ ({
+ attemptCount,
+ browserEventCount,
+ closeCount,
+ errorEvent,
+ frameReceivedCount,
+ frameSentCount,
+ opened,
+ socketErrorCount,
+ timedOut,
+ }) =>
+ attemptCount < 1 ||
+ browserEventCount < 1 ||
+ closeCount < 1 ||
+ !errorEvent ||
+ frameReceivedCount !== 0 ||
+ frameSentCount !== 0 ||
+ opened ||
+ socketErrorCount < 1 ||
+ timedOut,
+ )
+ ) {
+ reasons.push(`${label} browser network is not isolated`);
+ }
+ if (
+ !runtime.serverNetworkIsolation.active ||
+ !runtime.serverNetworkIsolation.network.internal ||
+ !runtime.serverNetworkIsolation.canary.domain.blocked ||
+ !runtime.serverNetworkIsolation.canary.directIp.blocked
+ ) {
+ reasons.push(`${label} server network is not isolated`);
+ }
+ if (!runtime.provenance.container.network.internal) reasons.push(`${label} app container network is not internal`);
+ if (!runtime.provenance.ingressProxy.excludedFromMemorySamples) {
+ reasons.push(`${label} ingress proxy memory accounting is ambiguous`);
+ }
+};
+
+const runtimeMetric = (runtime: RuntimeResult, key: keyof RuntimePageSample) =>
+ summarize(runtime.workload.pageLoadSamples.map((sample) => sample[key]));
+
+export const compareBenchmarkPair = (input: BenchmarkPairInput) => {
+ const reasons: string[] = [];
+ validateBuildSide("baseline", input.baselineBuilds, reasons);
+ validateBuildSide("candidate", input.candidateBuilds, reasons);
+ validateWarmup("baseline", input.baselineWarmup, input.baselineBuilds, reasons);
+ validateWarmup("candidate", input.candidateWarmup, input.candidateBuilds, reasons);
+ validateRuntime("baseline", input.baselineRuntime, reasons);
+ validateRuntime("candidate", input.candidateRuntime, reasons);
+
+ const baselineBuild = input.baselineBuilds[0];
+ const candidateBuild = input.candidateBuilds[0];
+ if (baselineBuild && candidateBuild) {
+ if (baselineBuild.series.id !== candidateBuild.series.id) reasons.push("build series ids do not match");
+ if (baselineBuild.builder === candidateBuild.builder) reasons.push("build sides reuse the same builder cache");
+ if (baselineBuild.source.measuredRevision !== candidateBuild.source.measuredRevision) {
+ reasons.push("build measured revisions do not match");
+ }
+ if (stable(baselineBuild.host) !== stable(candidateBuild.host)) reasons.push("build hosts do not match");
+ if (
+ `${baselineBuild.image.architecture}:${baselineBuild.image.targetPlatform}` !==
+ `${candidateBuild.image.architecture}:${candidateBuild.image.targetPlatform}`
+ ) {
+ reasons.push("build image platforms do not match");
+ }
+ if (baselineBuild.source.baseFingerprint === candidateBuild.source.baseFingerprint) {
+ reasons.push("baseline and candidate source fingerprints are identical");
+ }
+
+ const candidateByRepetition = new Map(
+ input.candidateBuilds.map((build) => [build.series.repetition, build.source.syntheticSourceChange?.id]),
+ );
+ for (const build of input.baselineBuilds) {
+ if (build.source.syntheticSourceChange?.id !== candidateByRepetition.get(build.series.repetition)) {
+ reasons.push(`synthetic source change differs at repetition ${build.series.repetition}`);
+ }
+ }
+
+ if (baselineBuild.series.totalRepetitions !== 2 || candidateBuild.series.totalRepetitions !== 2) {
+ reasons.push("balanced build order currently requires exactly two repetitions per side");
+ } else {
+ const chronologicalRuns = [
+ { build: input.baselineWarmup, label: "baseline:warmup" },
+ { build: input.candidateWarmup, label: "candidate:warmup" },
+ ...input.baselineBuilds.map((build) => ({ build, label: `baseline:${build.series.repetition}` })),
+ ...input.candidateBuilds.map((build) => ({ build, label: `candidate:${build.series.repetition}` })),
+ ].toSorted((left, right) => Date.parse(left.build.run.startedAt) - Date.parse(right.build.run.startedAt));
+ const chronologicalOrder = chronologicalRuns.map(({ label }) => label);
+ if (
+ stable(chronologicalOrder) !==
+ stable(["baseline:warmup", "candidate:warmup", "baseline:1", "candidate:1", "candidate:2", "baseline:2"])
+ ) {
+ reasons.push(
+ "build execution order is not cold-warmup baseline-candidate then balanced baseline-candidate-candidate-baseline",
+ );
+ }
+ for (let index = 1; index < chronologicalRuns.length; index += 1) {
+ const previous = chronologicalRuns[index - 1];
+ const current = chronologicalRuns[index];
+ if (
+ previous &&
+ current &&
+ Date.parse(previous.build.run.finishedAt) > Date.parse(current.build.run.startedAt)
+ ) {
+ reasons.push(`build runs overlap between ${previous.label} and ${current.label}`);
+ }
+ }
+ }
+
+ if (input.baselineRuntime.image.sourceFingerprint !== baselineBuild.source.baseFingerprint) {
+ reasons.push("baseline runtime image does not match the baseline build source");
+ }
+ if (input.baselineRuntime.image.sourceFingerprint !== input.baselineWarmup.image.sourceFingerprint) {
+ reasons.push("baseline runtime image does not match the baseline warmup image");
+ }
+ if (input.candidateRuntime.image.sourceFingerprint !== input.candidateWarmup.image.sourceFingerprint) {
+ reasons.push("candidate runtime image does not match the candidate warmup image");
+ }
+ if (
+ input.baselineRuntime.image.id !== input.baselineWarmup.image.id ||
+ input.baselineRuntime.image.digest !== input.baselineWarmup.image.digest
+ ) {
+ reasons.push("baseline runtime image identity does not match the baseline warmup image");
+ }
+ if (
+ input.candidateRuntime.image.id !== input.candidateWarmup.image.id ||
+ input.candidateRuntime.image.digest !== input.candidateWarmup.image.digest
+ ) {
+ reasons.push("candidate runtime image identity does not match the candidate warmup image");
+ }
+ if (input.candidateRuntime.image.sourceFingerprint !== candidateBuild.source.baseFingerprint) {
+ reasons.push("candidate runtime image does not match the candidate build source");
+ }
+ if (
+ input.baselineRuntime.image.revision !== baselineBuild.source.measuredRevision ||
+ input.candidateRuntime.image.revision !== candidateBuild.source.measuredRevision
+ ) {
+ reasons.push("runtime image revisions do not match their build revisions");
+ }
+ }
+
+ if (input.baselineRuntime.image.revision !== input.candidateRuntime.image.revision) {
+ reasons.push("runtime image revisions do not match");
+ }
+ if (input.baselineRuntime.image.architecture !== input.candidateRuntime.image.architecture) {
+ reasons.push("runtime image architectures do not match");
+ }
+ if (stable(runtimeConfiguration(input.baselineRuntime)) !== stable(runtimeConfiguration(input.candidateRuntime))) {
+ reasons.push("runtime host, browser, container, or workload configuration does not match");
+ }
+ if (stable(input.baselineRuntime.claimScope) !== stable(input.candidateRuntime.claimScope)) {
+ reasons.push("runtime claim scopes do not match");
+ }
+ if (input.baselineRuntime.workload.spotlightIdlePolicy !== "mounted") {
+ reasons.push("baseline Spotlight idle policy is not mounted");
+ }
+ if (input.candidateRuntime.workload.spotlightIdlePolicy !== "preload-only") {
+ reasons.push("candidate Spotlight idle policy is not preload-only");
+ }
+
+ const buildMetric = (key: "wallTimeMs" | "nextCompileMs") => {
+ const baseline = summarize(input.baselineBuilds.map(({ build }) => build[key]));
+ const candidate = summarize(input.candidateBuilds.map(({ build }) => build[key]));
+ return { baseline, candidate, comparison: improvement(baseline.median, candidate.median) };
+ };
+ const pageMetric = (key: keyof RuntimePageSample) => {
+ const baseline = runtimeMetric(input.baselineRuntime, key);
+ const candidate = runtimeMetric(input.candidateRuntime, key);
+ return { baseline, candidate, comparison: improvement(baseline.median, candidate.median) };
+ };
+
+ return {
+ schemaVersion: 1,
+ claimEligible: reasons.length === 0,
+ claimIneligibleReasons: unique(reasons),
+ claimScope: {
+ build: "alternating warm incremental Docker source rebuilds",
+ runtime: "fresh authenticated Chromium documents against one warmed, network-isolated app container",
+ },
+ limitations: [
+ "build series report controlled medians, not statistical significance",
+ "runtime documents share one warmed server",
+ "widget settlement covers observed non-subscription tRPC requests followed by the configured quiet window",
+ "startup, RAM levels, initial bundle data, and cold Spotlight timings remain single-container diagnostics",
+ ],
+ build: {
+ seriesId: baselineBuild?.series.id ?? null,
+ baselineFingerprint: baselineBuild?.source.baseFingerprint ?? null,
+ candidateFingerprint: candidateBuild?.source.baseFingerprint ?? null,
+ wallTimeMs: buildMetric("wallTimeMs"),
+ nextCompileMs: buildMetric("nextCompileMs"),
+ },
+ runtime: {
+ boardMountedMs: pageMetric("boardMountedMs"),
+ fcpMs: pageMetric("fcpMs"),
+ lcpMs: pageMetric("lcpMs"),
+ ttfbMs: pageMetric("ttfbMs"),
+ widgetImplementationsMountedMs: pageMetric("widgetImplementationsMountedMs"),
+ widgetDataSettledMs: pageMetric("widgetDataSettledMs"),
+ },
+ diagnostics: {
+ baseline: {
+ startToReadyMs: input.baselineRuntime.workload.startToReadyMs,
+ coldSpotlightFeedbackMs: input.baselineRuntime.workload.coldSpotlightFeedbackMs,
+ coldSpotlightReadyMs: input.baselineRuntime.workload.coldSpotlightReadyMs,
+ postIdleFirstOpenMs: input.baselineRuntime.workload.postIdleWarmInteractionMs,
+ memory: input.baselineRuntime.summary,
+ },
+ candidate: {
+ startToReadyMs: input.candidateRuntime.workload.startToReadyMs,
+ coldSpotlightFeedbackMs: input.candidateRuntime.workload.coldSpotlightFeedbackMs,
+ coldSpotlightReadyMs: input.candidateRuntime.workload.coldSpotlightReadyMs,
+ postIdleFirstOpenMs: input.candidateRuntime.workload.postIdleWarmInteractionMs,
+ memory: input.candidateRuntime.summary,
+ },
+ },
+ };
+};
+
+export type { BuildResult, RuntimeResult };
diff --git a/scripts/benchmarks/benchmark-pair-lib.spec.ts b/scripts/benchmarks/benchmark-pair-lib.spec.ts
new file mode 100644
index 000000000..be8477dba
--- /dev/null
+++ b/scripts/benchmarks/benchmark-pair-lib.spec.ts
@@ -0,0 +1,360 @@
+import { describe, expect, it } from "vitest";
+
+import type { BenchmarkPairInput, BuildResult, RuntimeResult } from "./benchmark-pair-lib.mts";
+import { compareBenchmarkPair } from "./benchmark-pair-lib.mts";
+
+const runWindow = (index: number) => {
+ const startedAt = new Date(Date.UTC(2026, 0, 1, 0, 0, index * 2)).toISOString();
+ const finishedAt = new Date(Date.UTC(2026, 0, 1, 0, 0, index * 2 + 1)).toISOString();
+ return { startedAt, finishedAt };
+};
+
+const expectedCache = [{ logicalId: "pnpm-store", target: "/pnpm/store", mustIncreaseUsage: true }];
+const cacheStability = { attempts: 6, pollIntervalMs: 2_000, stableForMs: 10_000, timeoutMs: 30_000 };
+const cacheSnapshot = (side: "baseline" | "candidate", stage: "empty" | "warm" | "r1" | "r2") => ({
+ cacheMountCount: stage === "empty" ? 0 : 1,
+ cacheMounts:
+ stage === "empty"
+ ? []
+ : [
+ {
+ logicalId: "pnpm-store",
+ recordId: `${side}-pnpm-record`,
+ target: "/pnpm/store",
+ usageCount: { warm: 1, r1: 2, r2: 3 }[stage],
+ },
+ ],
+ recordCount: stage === "empty" ? 0 : { warm: 10, r1: 12, r2: 14 }[stage],
+ recordsFingerprint: `${side}-${stage}`,
+});
+
+const build = (side: "baseline" | "candidate", repetition: number): BuildResult => ({
+ schemaVersion: 4,
+ createdAt: runWindow(side === "baseline" ? (repetition === 1 ? 2 : 5) : repetition === 1 ? 3 : 4).finishedAt,
+ run: runWindow(side === "baseline" ? (repetition === 1 ? 2 : 5) : repetition === 1 ? 3 : 4),
+ claimEligible: true,
+ claimIneligibleReasons: [],
+ mode: "source-change",
+ builder: `${side}-builder`,
+ runLabel: side,
+ declaredRevision: "head+worktree",
+ series: { id: "series", cacheState: "warm", repetition, totalRepetitions: 2 },
+ source: {
+ headSha: "head",
+ measuredRevision: "head+worktree",
+ fingerprint: `${side}-change-${repetition}`,
+ baseFingerprint: `${side}-fingerprint`,
+ syntheticSourceChange: { file: "layout.tsx", id: `change-${repetition}` },
+ },
+ host: {
+ architecture: "arm64",
+ dockerServer: { architecture: "arm64", operatingSystem: "linux", targetPlatform: "linux/arm64", version: "1" },
+ platform: "darwin",
+ },
+ image: {
+ architecture: "arm64",
+ digest: null,
+ id: `${side}-build-${repetition}`,
+ revision: "head+worktree",
+ sourceFingerprint: `${side}-change-${repetition}`,
+ targetPlatform: "linux/arm64",
+ },
+ builderCache: {
+ expected: expectedCache,
+ before: cacheSnapshot(side, repetition === 1 ? "warm" : "r1"),
+ after: cacheSnapshot(side, repetition === 1 ? "r1" : "r2"),
+ afterStability: cacheStability,
+ },
+ build: { nextCompileMs: side === "baseline" ? 200 : 100, wallTimeMs: side === "baseline" ? 300 : 150 },
+});
+
+const warmup = (side: "baseline" | "candidate"): BuildResult => {
+ const run = runWindow(side === "baseline" ? 0 : 1);
+ return {
+ schemaVersion: 4,
+ createdAt: run.finishedAt,
+ run,
+ claimEligible: false,
+ claimIneligibleReasons: ["cached mode does not force an application rebuild"],
+ mode: "cached",
+ builder: `${side}-builder`,
+ runLabel: side,
+ declaredRevision: "head+worktree",
+ series: { id: "series", cacheState: "cold", repetition: 1, totalRepetitions: 2 },
+ source: {
+ headSha: "head",
+ measuredRevision: "head+worktree",
+ fingerprint: `${side}-fingerprint`,
+ baseFingerprint: `${side}-fingerprint`,
+ syntheticSourceChange: null,
+ },
+ host: {
+ architecture: "arm64",
+ dockerServer: {
+ architecture: "arm64",
+ operatingSystem: "linux",
+ targetPlatform: "linux/arm64",
+ version: "1",
+ },
+ platform: "darwin",
+ },
+ image: {
+ architecture: "arm64",
+ digest: null,
+ id: `${side}-runtime-image`,
+ revision: "head+worktree",
+ sourceFingerprint: `${side}-fingerprint`,
+ targetPlatform: "linux/arm64",
+ },
+ builderCache: {
+ expected: expectedCache,
+ before: cacheSnapshot(side, "empty"),
+ after: cacheSnapshot(side, "warm"),
+ afterStability: cacheStability,
+ },
+ build: { nextCompileMs: side === "baseline" ? 400 : 300, wallTimeMs: side === "baseline" ? 500 : 400 },
+ };
+};
+
+const runtimeSample = (value: number) => ({
+ boardMountedMs: value,
+ fcpMs: value,
+ lcpFinalizedByInputMs: 5_100,
+ lcpMs: value,
+ ttfbMs: value,
+ widgetImplementationsMountedMs: value,
+ widgetDataSettledMs: value,
+});
+
+const runtime = (side: "baseline" | "candidate"): RuntimeResult => {
+ return {
+ schemaVersion: 7,
+ claimEligible: true,
+ claimIneligibleReasons: [],
+ claimScope: ["pageLoads", "largestContentfulPaint", "widgetDataSettled", "settleStability"],
+ metricEligibility: Object.fromEntries(
+ ["pageLoads", "largestContentfulPaint", "widgetDataSettled", "settleStability"].map((key) => [
+ key,
+ { claimEligible: true },
+ ]),
+ ),
+ image: {
+ architecture: "arm64",
+ digest: null,
+ id: `${side}-runtime-image`,
+ revision: "head+worktree",
+ sourceFingerprint: `${side}-fingerprint`,
+ },
+ host: { architecture: "arm64", node: { version: "24", v8Version: "13" }, platform: "darwin", release: "1" },
+ provenance: {
+ browser: {
+ engine: "chromium",
+ executablePath: "/chromium",
+ headless: true,
+ httpCachePolicy: "default",
+ launchArguments: [`--proxy-server=http://127.0.0.1:${side === "baseline" ? "31001" : "32002"}`],
+ version: "1",
+ viewport: { width: 1280, height: 720 },
+ },
+ container: {
+ cpuLimit: 2,
+ memoryLimitBytes: 1024,
+ network: { internal: true },
+ node: { architecture: "arm64", platform: "linux", version: "24", v8Version: "13" },
+ },
+ docker: { engine: { version: "1" }, version: { version: "1" } },
+ ingressProxy: { excludedFromMemorySamples: true, implementation: "Node.js TCP proxy" },
+ },
+ browserNetworkIsolation: {
+ active: true,
+ canaries: {
+ http: { attemptCount: 1, responseHeader: "true", status: 502 },
+ webSockets: [
+ { connectTarget: "browser-ws.invalid:80", url: "ws://browser-ws.invalid/canary" },
+ { connectTarget: "browser-wss.invalid:443", url: "wss://browser-wss.invalid/canary" },
+ ].map((canary) => ({
+ ...canary,
+ attemptCount: 1,
+ browserEventCount: 1,
+ closeCount: 1,
+ errorEvent: true,
+ frameReceivedCount: 0,
+ frameSentCount: 0,
+ opened: false,
+ socketErrorCount: 1,
+ timedOut: false,
+ })),
+ },
+ externalBrowserWebSockets: [],
+ unexpectedExternalResponses: [],
+ },
+ serverNetworkIsolation: {
+ active: true,
+ canary: { directIp: { blocked: true }, domain: { blocked: true } },
+ network: { internal: true },
+ },
+ workload: {
+ settleMs: 600_000,
+ sampleIntervalMs: 30_000,
+ settleSampleCount: 20,
+ lcpObservationMs: 5_000,
+ lcpStabilityMs: 500,
+ widgetDataQuietMs: 500,
+ spotlightIdleMs: 2_500,
+ spotlightIdlePolicy: side === "baseline" ? "mounted" : "preload-only",
+ interactionIterations: 7,
+ widgetCount: 32,
+ implementationMarkerPolicy: "true",
+ widgetImplementationMarkerCount: 32,
+ widgetErrorCount: 0,
+ startToReadyMs: 1_000,
+ coldSpotlightFeedbackMs: 20,
+ coldSpotlightReadyMs: 200,
+ postIdleWarmInteractionMs: 30,
+ routeTimings: [{ path: "/manage" }],
+ pageLoadSamples: Array.from({ length: 20 }, () => runtimeSample(side === "baseline" ? 200 : 100)),
+ },
+ checkpoints: [
+ { capturedAt: new Date(0).toISOString(), name: "startup" },
+ { capturedAt: new Date(1_000).toISOString(), name: "workload" },
+ ...Array.from({ length: 20 }, (_, index) => ({
+ capturedAt: new Date(1_000 + (index + 1) * 30_000).toISOString(),
+ name: `settle-${index + 1}`,
+ })),
+ ],
+ summary: { startupBytes: 1, workloadBytes: 2, settledBytes: 2, peakBytes: 3 },
+ };
+};
+
+const validInput = (): BenchmarkPairInput => ({
+ baselineWarmup: warmup("baseline"),
+ candidateWarmup: warmup("candidate"),
+ baselineBuilds: [build("baseline", 1), build("baseline", 2)],
+ candidateBuilds: [build("candidate", 1), build("candidate", 2)],
+ baselineRuntime: runtime("baseline"),
+ candidateRuntime: runtime("candidate"),
+});
+
+describe("benchmark pair validation", () => {
+ it("accepts a complete, isolated, provenance-matched comparison", () => {
+ const result = compareBenchmarkPair(validInput());
+
+ expect(result.claimEligible).toBe(true);
+ expect(result.claimIneligibleReasons).toEqual([]);
+ expect(result.runtime.lcpMs.comparison).toMatchObject({ baseline: 200, candidate: 100, improvementPercent: 50 });
+ });
+
+ it("rejects an incomplete build series", () => {
+ const input = validInput();
+ input.candidateBuilds.pop();
+
+ expect(compareBenchmarkPair(input).claimIneligibleReasons).toContain("candidate build repetitions are incomplete");
+ });
+
+ it("rejects runtime source drift and uncontrolled server egress", () => {
+ const input = validInput();
+ input.candidateRuntime.image.sourceFingerprint = "wrong";
+ input.candidateRuntime.serverNetworkIsolation.canary.directIp.blocked = false;
+
+ const result = compareBenchmarkPair(input);
+ expect(result.claimEligible).toBe(false);
+ expect(result.claimIneligibleReasons).toContain(
+ "candidate runtime image does not match the candidate build source",
+ );
+ expect(result.claimIneligibleReasons).toContain("candidate server network is not isolated");
+ });
+
+ it("rejects runtime evidence without blocked HTTP, WS, and WSS browser canaries", () => {
+ const input = validInput();
+ const wssCanary = input.candidateRuntime.browserNetworkIsolation.canaries.webSockets[1];
+ if (!wssCanary) throw new Error("Missing WSS canary fixture");
+ wssCanary.opened = true;
+
+ expect(compareBenchmarkPair(input).claimIneligibleReasons).toContain("candidate browser network is not isolated");
+ });
+
+ it("requires one WS and one WSS browser isolation canary", () => {
+ const input = validInput();
+ const wssCanary = input.candidateRuntime.browserNetworkIsolation.canaries.webSockets[1];
+ if (!wssCanary) throw new Error("Missing WSS canary fixture");
+ wssCanary.url = "ws://second-browser-ws.invalid/canary";
+ wssCanary.connectTarget = "second-browser-ws.invalid:80";
+
+ expect(compareBenchmarkPair(input).claimIneligibleReasons).toContain("candidate browser network is not isolated");
+ });
+
+ it("recomputes runtime claim minima instead of trusting embedded eligibility flags", () => {
+ const input = validInput();
+ input.candidateRuntime.claimScope = [];
+ input.candidateRuntime.workload.lcpObservationMs = 1_000;
+ input.candidateRuntime.workload.widgetDataQuietMs = 100;
+ input.candidateRuntime.workload.settleMs = 1_000;
+ input.candidateRuntime.workload.settleSampleCount = 19;
+ input.candidateRuntime.checkpoints.pop();
+ const firstSample = input.candidateRuntime.workload.pageLoadSamples[0];
+ if (!firstSample) throw new Error("Missing runtime sample fixture");
+ firstSample.lcpMs = Number.POSITIVE_INFINITY;
+
+ const reasons = compareBenchmarkPair(input).claimIneligibleReasons;
+ expect(reasons).toContain("candidate runtime claim scope is incomplete");
+ expect(reasons).toContain("candidate runtime LCP observation is below 5000ms");
+ expect(reasons).toContain("candidate runtime widget-data quiet window is below 500ms");
+ expect(reasons).toContain("candidate runtime settle duration is below 600000ms");
+ expect(reasons).toContain("candidate runtime has fewer than 20 settle samples");
+ expect(reasons).toContain("candidate runtime metric lcpMs has invalid or missing samples");
+ });
+
+ it("recomputes measured cache-mount use instead of trusting build eligibility flags", () => {
+ const input = validInput();
+ const firstBuild = input.candidateBuilds[0];
+ const beforeMount = firstBuild?.builderCache.before?.cacheMounts[0];
+ const afterMount = firstBuild?.builderCache.after?.cacheMounts[0];
+ if (!beforeMount || !afterMount) throw new Error("Missing cache mount fixture");
+ afterMount.usageCount = beforeMount.usageCount;
+
+ expect(compareBenchmarkPair(input).claimIneligibleReasons).toContain(
+ "candidate repetition 1 build cache mount pnpm-store usage did not increase",
+ );
+ });
+
+ it("verifies actual settle chronology, LCP finalization, and immutable build fingerprints", () => {
+ const input = validInput();
+ const finalSettle = input.candidateRuntime.checkpoints.at(-1);
+ const firstSample = input.candidateRuntime.workload.pageLoadSamples[0];
+ const firstBuild = input.candidateBuilds[0];
+ if (!finalSettle || !firstSample || !firstBuild) throw new Error("Missing evidence fixture");
+ finalSettle.capturedAt = new Date(500_000).toISOString();
+ firstSample.lcpFinalizedByInputMs = 4_999;
+ firstBuild.image.sourceFingerprint = "wrong-fingerprint";
+
+ const reasons = compareBenchmarkPair(input).claimIneligibleReasons;
+ expect(reasons).toContain("candidate runtime checkpoint span is shorter than the declared settle duration");
+ expect(reasons).toContain("candidate runtime LCP samples were not observed and finalized for the declared window");
+ expect(reasons).toContain("candidate build image fingerprint does not match its immutable source snapshot");
+ });
+
+ it("rejects a contaminated warmup or cache use outside the measured chain", () => {
+ const input = validInput();
+ if (input.candidateWarmup.builderCache.before) input.candidateWarmup.builderCache.before.recordCount = 1;
+ if (input.baselineBuilds[0]?.builderCache.before) {
+ input.baselineBuilds[0].builderCache.before.recordsFingerprint = "unrecorded-cache-use";
+ }
+
+ const result = compareBenchmarkPair(input);
+ expect(result.claimIneligibleReasons).toContain("candidate warmup builder did not begin empty");
+ expect(result.claimIneligibleReasons).toContain("baseline builder cache changed outside repetition 1");
+ });
+
+ it("rejects overlapping builds and a stale runtime image with copied labels", () => {
+ const input = validInput();
+ const candidateFirst = input.candidateBuilds[0];
+ if (candidateFirst) candidateFirst.run.startedAt = input.baselineBuilds[0]?.run.startedAt ?? "";
+ input.candidateRuntime.image.id = "stale-image-with-matching-labels";
+
+ const result = compareBenchmarkPair(input);
+ expect(result.claimIneligibleReasons).toContain("build runs overlap between baseline:1 and candidate:1");
+ expect(result.claimIneligibleReasons).toContain(
+ "candidate runtime image identity does not match the candidate warmup image",
+ );
+ });
+});
diff --git a/scripts/benchmarks/board-static-graph.mts b/scripts/benchmarks/board-static-graph.mts
new file mode 100644
index 000000000..8c7dda589
--- /dev/null
+++ b/scripts/benchmarks/board-static-graph.mts
@@ -0,0 +1,235 @@
+import fs from "node:fs";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import { parse } from "@babel/parser";
+
+export const getRuntimeModuleSpecifiers = (sourceText: string, fileName: string) => {
+ try {
+ const ast = parse(sourceText, { sourceFilename: fileName, sourceType: "module", plugins: ["typescript", "jsx"] });
+ return ast.program.body.flatMap((statement) => {
+ if (statement.type === "ImportDeclaration") {
+ if (statement.importKind === "type" || statement.importKind === "typeof") return [];
+ const hasRuntimeBinding =
+ statement.specifiers.length === 0 ||
+ statement.specifiers.some(
+ (specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type",
+ );
+ return hasRuntimeBinding ? [statement.source.value] : [];
+ }
+ if (statement.type === "ExportAllDeclaration") {
+ return statement.exportKind === "type" ? [] : [statement.source.value];
+ }
+ if (statement.type === "ExportNamedDeclaration" && statement.source) {
+ if (statement.exportKind === "type") return [];
+ const hasRuntimeBinding =
+ statement.specifiers.length === 0 ||
+ statement.specifiers.some((specifier) => specifier.exportKind !== "type");
+ return hasRuntimeBinding ? [statement.source.value] : [];
+ }
+ return [];
+ });
+ } catch (error) {
+ throw new Error(`Unable to parse static imports in ${fileName}`, { cause: error });
+ }
+};
+
+const canonicalize = (fileName: string) => {
+ try {
+ return fs.realpathSync.native(fileName);
+ } catch {
+ return path.resolve(fileName);
+ }
+};
+
+const resolveFile = (candidate: string) => {
+ const candidates = path.extname(candidate)
+ ? [candidate]
+ : [
+ candidate,
+ `${candidate}.ts`,
+ `${candidate}.tsx`,
+ `${candidate}.mts`,
+ `${candidate}.js`,
+ `${candidate}.jsx`,
+ path.join(candidate, "index.ts"),
+ path.join(candidate, "index.tsx"),
+ path.join(candidate, "index.mts"),
+ path.join(candidate, "index.js"),
+ ];
+ return candidates.find((fileName) => fs.existsSync(fileName) && fs.statSync(fileName).isFile()) ?? null;
+};
+
+const firstExportTarget = (value: unknown): string | null => {
+ if (typeof value === "string") return value;
+ if (!value || typeof value !== "object") return null;
+ for (const nested of Object.values(value)) {
+ const target = firstExportTarget(nested);
+ if (target) return target;
+ }
+ return null;
+};
+
+const getPackageName = (specifier: string) => specifier.split("/").slice(0, 2).join("/");
+
+const resolvePackageExport = (exports: unknown, exportKey: string) => {
+ if (exportKey === "." && typeof exports === "string") return exports;
+ if (!exports || typeof exports !== "object") return null;
+ const exportMap = exports as Record;
+ const exact = firstExportTarget(exportMap[exportKey]);
+ if (exact) return exact;
+
+ for (const [pattern, value] of Object.entries(exportMap)) {
+ const wildcard = pattern.indexOf("*");
+ if (wildcard < 0) continue;
+ const prefix = pattern.slice(0, wildcard);
+ const suffix = pattern.slice(wildcard + 1);
+ if (!exportKey.startsWith(prefix) || !exportKey.endsWith(suffix)) continue;
+ const match = exportKey.slice(prefix.length, exportKey.length - suffix.length);
+ const target = firstExportTarget(value);
+ if (target) return target.replaceAll("*", match);
+ }
+ return null;
+};
+
+const getWorkspacePackages = (root: string) => {
+ const packages = new Map();
+ for (const parent of ["apps", "packages", "tooling"]) {
+ const parentDirectory = path.join(root, parent);
+ if (!fs.existsSync(parentDirectory)) continue;
+ for (const directoryEntry of fs.readdirSync(parentDirectory, { withFileTypes: true })) {
+ if (!directoryEntry.isDirectory()) continue;
+ const directory = path.join(parentDirectory, directoryEntry.name);
+ const manifestPath = path.join(directory, "package.json");
+ if (!fs.existsSync(manifestPath)) continue;
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { name?: string; exports?: unknown };
+ if (manifest.name) packages.set(manifest.name, { directory, exports: manifest.exports });
+ }
+ }
+ return packages;
+};
+
+export const collectBoardStaticGraph = (repositoryRoot: string) => {
+ const root = canonicalize(repositoryRoot);
+ const entries = [
+ "apps/nextjs/src/app/[locale]/layout.tsx",
+ "apps/nextjs/src/app/[locale]/(home)/(board)/layout.tsx",
+ "apps/nextjs/src/app/[locale]/(home)/(board)/page.tsx",
+ "apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/layout.tsx",
+ "apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/page.tsx",
+ "apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx",
+ ].map((entry) => path.join(root, entry));
+ const workspacePackages = getWorkspacePackages(root);
+ const unresolvedWorkspaceImports: Array<{ importer: string; specifier: string }> = [];
+
+ const resolveSpecifier = (specifier: string, importer: string) => {
+ if (specifier.startsWith(".")) return resolveFile(path.resolve(path.dirname(importer), specifier));
+ if (specifier.startsWith("~/")) return resolveFile(path.join(root, "apps/nextjs/src", specifier.slice(2)));
+ if (specifier.startsWith("@static-data/")) {
+ return resolveFile(path.join(root, "static-data", specifier.slice("@static-data/".length)));
+ }
+ if (!specifier.startsWith("@homarr/") || specifier === "@homarr/node-unifi") return null;
+
+ const [, , ...subpathParts] = specifier.split("/");
+ const workspacePackage = workspacePackages.get(getPackageName(specifier));
+ if (!workspacePackage) return null;
+ const exportKey = subpathParts.length === 0 ? "." : `./${subpathParts.join("/")}`;
+ const target = resolvePackageExport(workspacePackage.exports, exportKey);
+ return target ? resolveFile(path.resolve(workspacePackage.directory, target)) : null;
+ };
+
+ const queue = [...entries];
+ const visited = new Set();
+ const parents = new Map();
+ const bareWidgetImports: Array<{ importer: string; specifier: string }> = [];
+ const forbiddenModules = new Set([
+ path.join(root, "packages/widgets/index.ts"),
+ path.join(root, "packages/widgets/src/index.tsx"),
+ path.join(root, "packages/spotlight/src/component.ts"),
+ path.join(root, "packages/spotlight/src/components/spotlight.tsx"),
+ path.join(root, "packages/spotlight/src/spotlight-store.ts"),
+ path.join(root, "packages/widgets/src/modals/widget-edit-modal.tsx"),
+ path.join(root, "apps/nextjs/src/components/board/items/item-move-modal.tsx"),
+ path.join(root, "apps/nextjs/src/components/onboarding/board-tour.tsx"),
+ path.join(root, "apps/nextjs/src/components/onboarding/tour-shell.tsx"),
+ ]);
+
+ while (queue.length > 0) {
+ const next = queue.shift();
+ if (!next) continue;
+ const current = canonicalize(next);
+ if (visited.has(current)) continue;
+ visited.add(current);
+ if (!/\.[cm]?[jt]sx?$/.test(current)) continue;
+
+ const sourceText = fs.readFileSync(current, "utf8");
+ for (const specifier of getRuntimeModuleSpecifiers(sourceText, current)) {
+ if (specifier === "@homarr/widgets") bareWidgetImports.push({ importer: current, specifier });
+ const resolved = resolveSpecifier(specifier, current);
+ if (!resolved) {
+ if (workspacePackages.has(getPackageName(specifier))) {
+ unresolvedWorkspaceImports.push({ importer: current, specifier });
+ }
+ continue;
+ }
+ const target = canonicalize(resolved);
+ if (!target.startsWith(`${root}${path.sep}`) || target.includes(`${path.sep}node_modules${path.sep}`)) continue;
+ if (!parents.has(target)) parents.set(target, { importer: current, specifier });
+ queue.push(target);
+ }
+ }
+
+ const reachedForbiddenModules = [...forbiddenModules].filter((fileName) => visited.has(fileName));
+ const formatChain = (target: string) => {
+ const chain = [path.relative(root, target)];
+ let current = target;
+ while (parents.has(current)) {
+ const parent = parents.get(current);
+ if (!parent) break;
+ chain.unshift(`${path.relative(root, parent.importer)} --${parent.specifier}-->`);
+ current = parent.importer;
+ }
+ return chain.join(" ");
+ };
+
+ return {
+ entries: entries.map((entry) => path.relative(root, entry)),
+ visitedModuleCount: visited.size,
+ bareWidgetImports: bareWidgetImports.map(({ importer, specifier }) => ({
+ importer: path.relative(root, importer),
+ specifier,
+ })),
+ reachedForbiddenModules: reachedForbiddenModules.map(formatChain),
+ unresolvedWorkspaceImports: unresolvedWorkspaceImports.map(({ importer, specifier }) => ({
+ importer: path.relative(root, importer),
+ specifier,
+ })),
+ };
+};
+
+const run = () => {
+ const repositoryRoot = path.resolve(process.env.BOARD_GRAPH_ROOT ?? ".");
+ const result = collectBoardStaticGraph(repositoryRoot);
+ if (
+ result.bareWidgetImports.length > 0 ||
+ result.reachedForbiddenModules.length > 0 ||
+ result.unresolvedWorkspaceImports.length > 0
+ ) {
+ throw new Error(`Board static graph reaches a forbidden eager module:\n${JSON.stringify(result, null, 2)}`);
+ }
+ console.log(
+ JSON.stringify(
+ {
+ ...result,
+ eagerWidgetRegistryReachable: false,
+ eagerSpotlightImplementationReachable: false,
+ eagerWidgetEditorReachable: false,
+ eagerItemMoveEditorReachable: false,
+ eagerBoardTourReachable: false,
+ },
+ null,
+ 2,
+ ),
+ );
+};
+
+if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) run();
diff --git a/scripts/benchmarks/board-static-graph.spec.ts b/scripts/benchmarks/board-static-graph.spec.ts
new file mode 100644
index 000000000..dede54b7d
--- /dev/null
+++ b/scripts/benchmarks/board-static-graph.spec.ts
@@ -0,0 +1,37 @@
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+import { collectBoardStaticGraph, getRuntimeModuleSpecifiers } from "./board-static-graph.mts";
+
+describe("board static dependency graph", () => {
+ it("ignores type-only and dynamic imports", () => {
+ expect(
+ getRuntimeModuleSpecifiers(
+ [
+ 'import type { A } from "type-only";',
+ 'import { type B } from "also-type-only";',
+ 'import { C, type D } from "runtime";',
+ 'export type { E } from "export-type";',
+ 'export { type F } from "also-export-type";',
+ 'export { G } from "runtime-export";',
+ 'export * from "runtime-star";',
+ 'import "runtime-side-effect";',
+ "import {",
+ " H,",
+ " type I,",
+ '} from "runtime-multiline";',
+ 'void import("lazy");',
+ ].join("\n"),
+ "fixture.ts",
+ ),
+ ).toEqual(["runtime", "runtime-export", "runtime-star", "runtime-side-effect", "runtime-multiline"]);
+ });
+
+ it("keeps widget registries, editors, Spotlight, and onboarding tour out of the board's static graph", () => {
+ const result = collectBoardStaticGraph(path.resolve("."));
+ expect(result.visitedModuleCount).toBeGreaterThan(0);
+ expect(result.bareWidgetImports).toEqual([]);
+ expect(result.reachedForbiddenModules).toEqual([]);
+ expect(result.unresolvedWorkspaceImports).toEqual([]);
+ });
+});
diff --git a/scripts/benchmarks/dev-benchmark-lib.mts b/scripts/benchmarks/dev-benchmark-lib.mts
index 858e8fe7d..4113eaaf3 100644
--- a/scripts/benchmarks/dev-benchmark-lib.mts
+++ b/scripts/benchmarks/dev-benchmark-lib.mts
@@ -1,15 +1,17 @@
export type NumericSummary = {
count: number;
- medianMs: number | null;
- p95Ms: number | null;
- standardDeviationMs: number | null;
- outliersMs: number[];
+ min: number | null;
+ median: number | null;
+ p95: number | null;
+ max: number | null;
+ standardDeviation: number | null;
+ outliers: number[];
};
export const normalizeTrpcPath = (requestUrl: string) => {
try {
const url = new URL(requestUrl, "http://benchmark.invalid");
- url.searchParams.delete("input");
+ url.searchParams.sort();
return url.pathname + url.search;
} catch {
return requestUrl;
@@ -27,13 +29,13 @@ const values = (numbers: Array) =>
export const summarize = (numbers: Array): NumericSummary => {
const sorted = values(numbers);
if (sorted.length === 0) {
- return { count: 0, medianMs: null, p95Ms: null, standardDeviationMs: null, outliersMs: [] };
+ return { count: 0, min: null, median: null, p95: null, max: null, standardDeviation: null, outliers: [] };
}
const middleIndex = Math.floor(sorted.length / 2);
- let medianMs = sorted[middleIndex] ?? 0;
+ let median = sorted[middleIndex] ?? 0;
if (sorted.length % 2 === 0) {
- medianMs = ((sorted[middleIndex - 1] ?? 0) + medianMs) / 2;
+ median = ((sorted[middleIndex - 1] ?? 0) + median) / 2;
}
const p95Index = Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1);
const mean = sorted.reduce((total, value) => total + value, 0) / sorted.length;
@@ -45,10 +47,12 @@ export const summarize = (numbers: Array): NumericSum
return {
count: sorted.length,
- medianMs,
- p95Ms: sorted[p95Index] ?? 0,
- standardDeviationMs: Math.round(Math.sqrt(variance)),
- outliersMs: sorted.filter((value) => value > outlierLimit),
+ min: sorted[0] ?? null,
+ median,
+ p95: sorted.length >= 20 ? (sorted[p95Index] ?? null) : null,
+ max: sorted.at(-1) ?? null,
+ standardDeviation: Math.round(Math.sqrt(variance)),
+ outliers: sorted.filter((value) => value > outlierLimit),
};
};
@@ -56,7 +60,10 @@ export const validateBrowserMeasurement = (measurement: {
responseStatus: number | null;
domContentLoadedMs: number;
boardHydratedMs: number | null;
- firstInteractionMs: number | null;
+ widgetsReadyMs?: number | null;
+ widgetCount?: number;
+ coldImmediateInteractionMs?: number | null;
+ postIdleWarmInteractionMs?: number | null;
error?: string;
}) => {
const errors: string[] = [];
@@ -66,6 +73,9 @@ export const validateBrowserMeasurement = (measurement: {
errors.push(`browser navigation returned HTTP ${measurement.responseStatus}`);
if (measurement.domContentLoadedMs < 0) errors.push("DOMContentLoaded timing is missing");
if (measurement.boardHydratedMs === null) errors.push("board hydration marker is missing");
- if (measurement.firstInteractionMs === null) errors.push("first interaction timing is missing");
+ if (measurement.widgetsReadyMs === null) errors.push("widget readiness marker is missing");
+ if (measurement.widgetCount === 0) errors.push("representative board contains no widgets");
+ if (measurement.coldImmediateInteractionMs === null) errors.push("cold immediate interaction timing is missing");
+ if (measurement.postIdleWarmInteractionMs === null) errors.push("post-idle warm interaction timing is missing");
return errors;
};
diff --git a/scripts/benchmarks/dev-benchmark-lib.spec.ts b/scripts/benchmarks/dev-benchmark-lib.spec.ts
index fa374270c..b7755102d 100644
--- a/scripts/benchmarks/dev-benchmark-lib.spec.ts
+++ b/scripts/benchmarks/dev-benchmark-lib.spec.ts
@@ -8,26 +8,29 @@ import {
} from "./dev-benchmark-lib.mts";
describe("development benchmark measurements", () => {
- it("normalizes tRPC input payloads before counting duplicates", () => {
+ it("preserves tRPC input payloads when counting duplicates", () => {
const first = "/api/trpc/board.get?batch=1&input=%7B%22json%22%3A1%7D";
const second = "/api/trpc/board.get?batch=1&input=%7B%22json%22%3A2%7D";
- expect(normalizeTrpcPath(first)).toBe("/api/trpc/board.get?batch=1");
- expect(countDuplicateTrpcPaths([first, second, "/api/trpc/user.get"])).toBe(1);
+ expect(normalizeTrpcPath(first)).toBe("/api/trpc/board.get?batch=1&input=%7B%22json%22%3A1%7D");
+ expect(countDuplicateTrpcPaths([first, second, first, "/api/trpc/user.get"])).toBe(1);
});
it("reports median, p95, deviation, and high outliers", () => {
expect(summarize([1, 2, 3, 4, 5, 6, 100])).toEqual({
count: 7,
- medianMs: 4,
- p95Ms: 100,
- standardDeviationMs: 34,
- outliersMs: [100],
+ min: 1,
+ median: 4,
+ p95: null,
+ max: 100,
+ standardDeviation: 34,
+ outliers: [100],
});
});
it("averages both middle values for an even sample count", () => {
- expect(summarize([100, 200]).medianMs).toBe(150);
+ expect(summarize([100, 200]).median).toBe(150);
+ expect(summarize(Array.from({ length: 20 }, (_, index) => index + 1)).p95).toBe(19);
});
it("requires a successful authenticated board hydration and interaction", () => {
@@ -36,12 +39,18 @@ describe("development benchmark measurements", () => {
responseStatus: 307,
domContentLoadedMs: 120,
boardHydratedMs: null,
- firstInteractionMs: null,
+ widgetsReadyMs: null,
+ widgetCount: 0,
+ coldImmediateInteractionMs: null,
+ postIdleWarmInteractionMs: null,
}),
).toEqual([
"browser navigation returned HTTP 307",
"board hydration marker is missing",
- "first interaction timing is missing",
+ "widget readiness marker is missing",
+ "representative board contains no widgets",
+ "cold immediate interaction timing is missing",
+ "post-idle warm interaction timing is missing",
]);
});
});
diff --git a/scripts/benchmarks/dev-board.mts b/scripts/benchmarks/dev-board.mts
index 7b400ab7f..1765b1623 100644
--- a/scripts/benchmarks/dev-board.mts
+++ b/scripts/benchmarks/dev-board.mts
@@ -2,25 +2,39 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { performance } from "node:perf_hooks";
-import type { Page } from "@playwright/test";
+import type { BrowserContext, Page } from "@playwright/test";
import { chromium } from "@playwright/test";
import { countDuplicateTrpcPaths, summarize, validateBrowserMeasurement } from "./dev-benchmark-lib.mts";
type BoardMeasurement = {
- boardReadyMs: number;
+ boardMountedMs: number;
domContentLoadedMs: number;
- firstInteractionMs: number;
+ postIdleWarmInteractionMs: number;
+ widgetCount: number;
+ widgetsReadyMs: number;
pageErrors: string[];
responseStatus: number | null;
trpcDuplicateRequestCount: number;
trpcRequestCount: number;
+ webSocketCount: number;
+ performance: {
+ cls: number;
+ decodedScriptBytes: number;
+ jsHeapBytes: number | null;
+ lcpMs: number | null;
+ longTaskCount: number;
+ longTaskTotalMs: number;
+ resourceCount: number;
+ transferBytes: number;
+ };
};
const root = path.resolve(import.meta.dirname, "../..");
const argumentsSet = new Set(process.argv.slice(2));
const smokeOnly = argumentsSet.has("--smoke");
-const iterations = Number(process.env.DEV_BENCHMARK_ITERATIONS ?? 7);
+const iterations = Number(process.env.DEV_BENCHMARK_ITERATIONS ?? 10);
+const spotlightIdleMs = Number(process.env.DEV_BENCHMARK_SPOTLIGHT_IDLE_MS ?? 2_500);
const outputDirectory = path.resolve(
process.env.DEV_BENCHMARK_OUTPUT_DIR ?? path.join(root, "benchmark-results", "dev"),
);
@@ -30,10 +44,15 @@ const readinessUrl = process.env.DEV_BENCHMARK_READINESS_URL ?? "http://127.0.0.
const boardSelector = "[data-homarr-dev-benchmark-board]";
const interactionSelector = '[data-homarr-dev-benchmark-interaction="search"]';
const spotlightSelector = "[data-homarr-dev-benchmark-spotlight]";
+const spotlightFeedbackSelector =
+ "[data-homarr-dev-benchmark-spotlight-feedback], [data-homarr-dev-benchmark-spotlight]";
if (!Number.isInteger(iterations) || iterations < 7) {
throw new Error("DEV_BENCHMARK_ITERATIONS must be an integer of at least 7");
}
+if (!Number.isFinite(spotlightIdleMs) || spotlightIdleMs < 0) {
+ throw new Error("DEV_BENCHMARK_SPOTLIGHT_IDLE_MS must be non-negative");
+}
await fs.mkdir(outputDirectory, { recursive: true });
@@ -50,6 +69,7 @@ if (smokeOnly) {
const result = {
kind: "readiness-smoke",
claimEligible: false,
+ reason: "Readiness checks do not exercise the browser or dashboard graph",
url: readinessUrl,
iterations,
summary: summarize(measurements),
@@ -62,50 +82,139 @@ if (smokeOnly) {
if (!boardUrl || !storageStatePath) {
throw new Error(
- "Authenticated claim mode requires DEV_BENCHMARK_BOARD_URL and DEV_BENCHMARK_STORAGE_STATE. " +
+ "Authenticated mode requires DEV_BENCHMARK_BOARD_URL and DEV_BENCHMARK_STORAGE_STATE. " +
"Use --smoke only for the non-claimable readiness check.",
);
}
await fs.access(storageStatePath);
+const installPerformanceObservers = () => {
+ const state = { cls: 0, lcpMs: null as number | null, longTaskCount: 0, longTaskTotalMs: 0 };
+ Object.defineProperty(window, "homarrBenchmarkVitals", { value: state, configurable: true });
+
+ if (PerformanceObserver.supportedEntryTypes.includes("largest-contentful-paint")) {
+ new PerformanceObserver((list) => {
+ const latest = list.getEntries().at(-1);
+ if (latest) state.lcpMs = latest.startTime;
+ }).observe({ type: "largest-contentful-paint", buffered: true });
+ }
+ if (PerformanceObserver.supportedEntryTypes.includes("layout-shift")) {
+ new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ const shift = entry as PerformanceEntry & { hadRecentInput?: boolean; value?: number };
+ if (!shift.hadRecentInput) state.cls += shift.value ?? 0;
+ }
+ }).observe({ type: "layout-shift", buffered: true });
+ }
+ if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
+ new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ state.longTaskCount += 1;
+ state.longTaskTotalMs += entry.duration;
+ }
+ }).observe({ type: "longtask", buffered: true });
+ }
+};
+
+const createContextAsync = async (): Promise => {
+ const context = await browser.newContext({ storageState: storageStatePath });
+ await context.addInitScript(installPerformanceObservers);
+ return context;
+};
+
const browser = await chromium.launch();
-const cold: BoardMeasurement[] = [];
-const warm: BoardMeasurement[] = [];
+const freshContext: BoardMeasurement[] = [];
+const warmPage: BoardMeasurement[] = [];
+const coldInteractionSamples: Array<{ feedbackMs: number; readyMs: number }> = [];
const measureBoardAsync = async (page: Page) => {
const trpcRequests: string[] = [];
const pageErrors: string[] = [];
+ let webSocketCount = 0;
+
page.on("request", (request) => {
if (request.url().includes("/api/trpc/")) trpcRequests.push(request.url());
});
+ page.on("websocket", () => {
+ webSocketCount += 1;
+ });
page.on("pageerror", (error) => pageErrors.push(error.message));
const startedAt = performance.now();
const response = await page.goto(boardUrl, { waitUntil: "domcontentloaded" });
const domContentLoadedMs = Math.round(performance.now() - startedAt);
await page.locator(boardSelector).waitFor({ state: "visible" });
- const boardReadyMs = Math.round(performance.now() - startedAt);
+ const boardMountedMs = Math.round(performance.now() - startedAt);
- const interactionStartedAt = performance.now();
- await page.locator(interactionSelector).first().click();
+ await page.waitForFunction(
+ (selector) => {
+ const board = document.querySelector(selector);
+ if (!board) return false;
+ const widgetCount = board.querySelectorAll('[data-type="item"]').length;
+ return widgetCount > 0 && board.querySelectorAll("[data-homarr-widget-ready]").length === widgetCount;
+ },
+ boardSelector,
+ { timeout: 30_000 },
+ );
+ const widgetsReadyMs = Math.round(performance.now() - startedAt);
+ const widgetCount = await page.locator(`${boardSelector} [data-type="item"]`).count();
+
+ await page.waitForTimeout(spotlightIdleMs);
+ const warmInteractionStartedAt = performance.now();
+ await page.locator(`${interactionSelector}:visible`).click();
await page.locator(spotlightSelector).waitFor({ state: "visible" });
- const firstInteractionMs = Math.round(performance.now() - interactionStartedAt);
+ const postIdleWarmInteractionMs = Math.round(performance.now() - warmInteractionStartedAt);
+ await page.keyboard.press("Escape");
+ await page.locator(spotlightSelector).waitFor({ state: "hidden" });
+
+ const browserPerformance = await page.evaluate(() => {
+ const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
+ const vitals = (
+ window as Window & {
+ homarrBenchmarkVitals?: {
+ cls: number;
+ lcpMs: number | null;
+ longTaskCount: number;
+ longTaskTotalMs: number;
+ };
+ }
+ ).homarrBenchmarkVitals;
+ const memory = performance as Performance & { memory?: { usedJSHeapSize: number } };
+ return {
+ cls: Number((vitals?.cls ?? 0).toFixed(4)),
+ decodedScriptBytes: resources
+ .filter((resource) => resource.initiatorType === "script")
+ .reduce((total, resource) => total + resource.decodedBodySize, 0),
+ jsHeapBytes: memory.memory?.usedJSHeapSize ?? null,
+ lcpMs: vitals?.lcpMs === null || vitals?.lcpMs === undefined ? null : Math.round(vitals.lcpMs),
+ longTaskCount: vitals?.longTaskCount ?? 0,
+ longTaskTotalMs: Math.round(vitals?.longTaskTotalMs ?? 0),
+ resourceCount: resources.length,
+ transferBytes: resources.reduce((total, resource) => total + resource.transferSize, 0),
+ };
+ });
const measurement: BoardMeasurement = {
responseStatus: response?.status() ?? null,
domContentLoadedMs,
- boardReadyMs,
- firstInteractionMs,
+ boardMountedMs,
+ widgetsReadyMs,
+ widgetCount,
+ postIdleWarmInteractionMs,
trpcRequestCount: trpcRequests.length,
trpcDuplicateRequestCount: countDuplicateTrpcPaths(trpcRequests),
+ webSocketCount,
+ performance: browserPerformance,
pageErrors,
};
const errors = validateBrowserMeasurement({
responseStatus: measurement.responseStatus,
domContentLoadedMs,
- boardHydratedMs: boardReadyMs,
- firstInteractionMs,
+ boardHydratedMs: boardMountedMs,
+ widgetsReadyMs,
+ widgetCount,
+ postIdleWarmInteractionMs,
error: pageErrors.length > 0 ? pageErrors.join("; ") : undefined,
});
if (errors.length > 0) throw new Error(errors.map((error) => `${boardUrl}: ${error}`).join("; "));
@@ -113,47 +222,103 @@ const measureBoardAsync = async (page: Page) => {
return measurement;
};
+const measureColdInteractionAsync = async (page: Page) => {
+ const pageErrors: string[] = [];
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+
+ const response = await page.goto(boardUrl, { waitUntil: "domcontentloaded" });
+ if (!response?.ok()) {
+ throw new Error(`Cold-interaction board returned HTTP ${String(response?.status() ?? "none")}`);
+ }
+ await page.locator(boardSelector).waitFor({ state: "visible" });
+ const interactionStartedAt = performance.now();
+ await page.locator(`${interactionSelector}:visible`).click();
+ await page.locator(spotlightFeedbackSelector).waitFor({ state: "visible" });
+ const feedbackMs = Math.round(performance.now() - interactionStartedAt);
+ await page.locator(spotlightSelector).waitFor({ state: "visible" });
+ const readyMs = Math.round(performance.now() - interactionStartedAt);
+ if (pageErrors.length > 0) throw new Error(`Cold-interaction page errors: ${pageErrors.join("; ")}`);
+ return { feedbackMs, readyMs };
+};
+
try {
for (let iteration = 0; iteration < iterations; iteration++) {
- const context = await browser.newContext({ storageState: storageStatePath });
- const coldPage = await context.newPage();
- cold.push(await measureBoardAsync(coldPage));
- await coldPage.close();
- const warmPage = await context.newPage();
- warm.push(await measureBoardAsync(warmPage));
+ const context = await createContextAsync();
+ const firstPage = await context.newPage();
+ freshContext.push(await measureBoardAsync(firstPage));
+ await firstPage.close();
+ const secondPage = await context.newPage();
+ warmPage.push(await measureBoardAsync(secondPage));
await context.close();
+
+ const coldContext = await createContextAsync();
+ await coldContext.addInitScript(() => {
+ // Measure the actual first-open path instead of racing the intentional
+ // requestIdleCallback prefetch used during normal idle time.
+ Object.defineProperty(window, "requestIdleCallback", { value: () => 0, configurable: true });
+ Object.defineProperty(window, "cancelIdleCallback", { value: () => undefined, configurable: true });
+ });
+ const coldPage = await coldContext.newPage();
+ coldInteractionSamples.push(await measureColdInteractionAsync(coldPage));
+ await coldContext.close();
}
} finally {
await browser.close();
}
const summarizeMeasurements = (measurements: BoardMeasurement[]) => ({
- boardReady: summarize(measurements.map(({ boardReadyMs }) => boardReadyMs)),
- domContentLoaded: summarize(measurements.map(({ domContentLoadedMs }) => domContentLoadedMs)),
- firstInteraction: summarize(measurements.map(({ firstInteractionMs }) => firstInteractionMs)),
+ boardMountedMs: summarize(measurements.map(({ boardMountedMs }) => boardMountedMs)),
+ widgetsReadyMs: summarize(measurements.map(({ widgetsReadyMs }) => widgetsReadyMs)),
+ domContentLoadedMs: summarize(measurements.map(({ domContentLoadedMs }) => domContentLoadedMs)),
+ postIdleWarmInteractionMs: summarize(measurements.map(({ postIdleWarmInteractionMs }) => postIdleWarmInteractionMs)),
+ lcpMs: summarize(measurements.map(({ performance: browserPerformance }) => browserPerformance.lcpMs)),
+ cls: summarize(measurements.map(({ performance: browserPerformance }) => browserPerformance.cls)),
+ longTaskTotalMs: summarize(
+ measurements.map(({ performance: browserPerformance }) => browserPerformance.longTaskTotalMs),
+ ),
+ decodedScriptBytes: summarize(
+ measurements.map(({ performance: browserPerformance }) => browserPerformance.decodedScriptBytes),
+ ),
+ transferBytes: summarize(measurements.map(({ performance: browserPerformance }) => browserPerformance.transferBytes)),
+ jsHeapBytes: summarize(measurements.map(({ performance: browserPerformance }) => browserPerformance.jsHeapBytes)),
trpcRequests: summarize(measurements.map(({ trpcRequestCount }) => trpcRequestCount)),
duplicateTrpcRequests: summarize(measurements.map(({ trpcDuplicateRequestCount }) => trpcDuplicateRequestCount)),
});
+const sha = process.env.DEV_BENCHMARK_SHA ?? null;
+const image = process.env.DEV_BENCHMARK_IMAGE ?? null;
+const runtime = process.env.DEV_BENCHMARK_RUNTIME ?? "development";
+const claimEligible = false;
const result = {
kind: "authenticated-board",
- claimEligible: true,
+ claimEligible,
+ claimIneligibleReasons: [
+ "served revision and production runtime are self-reported, not independently verified",
+ ...(runtime === "production" ? [] : ["runtime is not production"]),
+ ...(sha ? [] : ["DEV_BENCHMARK_SHA is missing"]),
+ ...(image ? [] : ["DEV_BENCHMARK_IMAGE is missing"]),
+ ],
boardUrl,
createdAt: new Date().toISOString(),
environment: {
architecture: process.arch,
cpus: os.cpus().map(({ model }) => model),
+ image,
node: process.version,
platform: `${process.platform} ${os.release()}`,
- sha: process.env.DEV_BENCHMARK_SHA ?? null,
+ runtime,
+ sha,
},
iterations,
- measurements: { cold, warm },
+ spotlightIdleMs,
+ measurements: { freshContext, warmPage, coldInteractionSamples },
summary: {
- cold: summarizeMeasurements(cold),
- warm: summarizeMeasurements(warm),
+ coldInteractionFeedbackMs: summarize(coldInteractionSamples.map(({ feedbackMs }) => feedbackMs)),
+ coldInteractionReadyMs: summarize(coldInteractionSamples.map(({ readyMs }) => readyMs)),
+ freshContext: summarizeMeasurements(freshContext),
+ warmPage: summarizeMeasurements(warmPage),
},
};
const outputPath = path.join(outputDirectory, `board-${Date.now()}.json`);
await fs.writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
-console.log(JSON.stringify({ outputPath, summary: result.summary }, null, 2));
+console.log(JSON.stringify({ outputPath, claimEligible, summary: result.summary }, null, 2));
diff --git a/scripts/benchmarks/docker-build-lib.mts b/scripts/benchmarks/docker-build-lib.mts
new file mode 100644
index 000000000..4eaebcdad
--- /dev/null
+++ b/scripts/benchmarks/docker-build-lib.mts
@@ -0,0 +1,259 @@
+import { createHash } from "node:crypto";
+
+export type DockerBuildSourceEntry =
+ | { file: string; mode: number; content: Buffer; linkTarget?: never }
+ | { file: string; mode: number; content?: never; linkTarget: string };
+
+export type DockerBuildSyntheticSourceChange = {
+ file: string;
+ id: string;
+ from: string;
+ to: string;
+};
+
+export const nextBuildCacheTarget = "/app/apps/nextjs/.next/cache";
+
+export type ExpectedDockerBuildCacheMount = {
+ logicalId: string;
+ target: string;
+ mustIncreaseUsage: boolean;
+};
+
+export type DockerBuildCacheMount = {
+ description: string | null;
+ logicalId: string | null;
+ recordId: string;
+ size: string | null;
+ target: string | null;
+ usageCount: number | null;
+};
+
+export type DockerBuildCacheSnapshot = {
+ cacheMountCount: number;
+ cacheMounts: DockerBuildCacheMount[];
+ recordCount: number;
+};
+
+export const fingerprintDockerBuildSource = (entries: DockerBuildSourceEntry[]) => {
+ const hash = createHash("sha256");
+ for (const entry of entries.toSorted((left, right) => left.file.localeCompare(right.file))) {
+ hash.update(entry.file);
+ hash.update("\0");
+ hash.update(String(entry.mode));
+ hash.update("\0");
+ hash.update(entry.linkTarget ?? entry.content);
+ hash.update("\0");
+ }
+ return hash.digest("hex");
+};
+
+export const applyDockerBuildSyntheticSourceChange = (
+ entries: DockerBuildSourceEntry[],
+ id: string,
+): { entries: DockerBuildSourceEntry[]; change: DockerBuildSyntheticSourceChange } => {
+ const file = "apps/nextjs/src/app/[locale]/layout.tsx";
+ const from = 'siteName: "Homarr",';
+ const to = `siteName: ${JSON.stringify(`Homarr build benchmark ${id}`)},`;
+ let changed = false;
+ const nextEntries = entries.map((entry) => {
+ if (entry.file !== file || entry.content === undefined) return entry;
+ const source = entry.content.toString("utf8");
+ if (source.split(from).length !== 2) {
+ throw new Error(`Expected exactly one build benchmark source marker in ${file}`);
+ }
+ changed = true;
+ return { ...entry, content: Buffer.from(source.replace(from, to)) };
+ });
+ if (!changed) throw new Error(`Build benchmark source marker file is missing: ${file}`);
+ return { entries: nextEntries, change: { file, id, from, to } };
+};
+
+const expandDockerfileMountValue = (value: string, variables: Record) => {
+ const expanded = value.replaceAll(
+ /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g,
+ (reference, braced, plain) => {
+ const name = (braced ?? plain) as string;
+ const replacement = variables[name];
+ if (replacement === undefined) {
+ throw new Error(`Dockerfile cache mount contains unresolved variable ${reference}`);
+ }
+ return replacement;
+ },
+ );
+ if (expanded.includes("$")) {
+ throw new Error(`Dockerfile cache mount contains unresolved variable in ${value}`);
+ }
+ return expanded;
+};
+
+export const parseDockerfileCacheMounts = (
+ dockerfile: string,
+ targetPlatform: string,
+): ExpectedDockerBuildCacheMount[] => {
+ const mounts = [...dockerfile.matchAll(/--mount=([^\s\\]+)/g)]
+ .map(([, serialized = ""]) =>
+ Object.fromEntries(
+ serialized.split(",").map((option) => {
+ const separator = option.indexOf("=");
+ return separator === -1 ? [option, ""] : [option.slice(0, separator), option.slice(separator + 1)];
+ }),
+ ),
+ )
+ .filter(({ type }) => type === "cache")
+ .map((options) => {
+ const id = options.id;
+ const target = options.target ?? options.dst ?? options.destination;
+ if (!id || !target) {
+ throw new Error("Dockerfile cache mounts must declare explicit id and target options");
+ }
+ const variables = { TARGETPLATFORM: targetPlatform };
+ const logicalId = expandDockerfileMountValue(id, variables);
+ const expandedTarget = expandDockerfileMountValue(target, variables);
+ return {
+ logicalId,
+ target: expandedTarget,
+ mustIncreaseUsage: expandedTarget === nextBuildCacheTarget,
+ };
+ });
+
+ const uniqueMounts = new Map(mounts.map((mount) => [`${mount.logicalId}\0${mount.target}`, mount]));
+ return [...uniqueMounts.values()].toSorted(
+ (left, right) => left.logicalId.localeCompare(right.logicalId) || left.target.localeCompare(right.target),
+ );
+};
+
+export const parseDockerBuildCacheMountDescription = (description: string | null) => {
+ if (!description) return null;
+ const match = /^cached mount (\S+) from exec .* with id "(\/[^"\n]+)"$/.exec(description);
+ if (!match) return null;
+ const [, target = "", printedId = ""] = match;
+ return { logicalId: printedId.slice(1), target };
+};
+
+export const normalizeDockerArchitecture = (architecture: string) => {
+ switch (architecture.toLowerCase()) {
+ case "x64":
+ case "x86_64":
+ return "amd64";
+ case "aarch64":
+ return "arm64";
+ case "armv6l":
+ return "arm/v6";
+ case "armv7l":
+ return "arm/v7";
+ default:
+ return architecture.toLowerCase();
+ }
+};
+
+export const parseDockerBuildOutput = (output: string) => {
+ const compileDurationsMs = [...output.matchAll(/Compiled (?:successfully )?in\s+([\d.]+)\s*(ms|s)/gi)].map(
+ ([, value = "0", unit = "ms"]) => Number(value) * (unit.toLowerCase() === "s" ? 1_000 : 1),
+ );
+
+ return {
+ cachedStepCount: (output.match(/^#\d+ CACHED$/gm) ?? []).length,
+ completedStepCount: (output.match(/^#\d+ DONE(?:\s+[\d.]+s)?$/gm) ?? []).length,
+ nextCompileMs: compileDurationsMs.at(-1) ?? null,
+ };
+};
+
+export const getMeasuredRevision = (headSha: string, dirty: boolean) => (dirty ? `${headSha}+worktree` : headSha);
+
+export const getBuildCacheStateIneligibleReasons = (
+ cacheState: string | null,
+ snapshot: DockerBuildCacheSnapshot | null,
+ expectedMounts: ExpectedDockerBuildCacheMount[],
+) => {
+ if (cacheState !== "cold" && cacheState !== "warm") {
+ return ["BUILD_BENCHMARK_CACHE_STATE must be cold or warm"];
+ }
+ if (!snapshot) return ["Docker builder cache state could not be inspected"];
+ if (cacheState === "cold" && snapshot.recordCount !== 0) {
+ return [`cold builder already contains ${snapshot.recordCount} cache records`];
+ }
+ if (cacheState === "warm" && expectedMounts.length === 0) {
+ return ["Dockerfile declares no cache mounts for a warm build"];
+ }
+ if (cacheState === "warm") {
+ return expectedMounts.flatMap((expected) => {
+ const matches = snapshot.cacheMounts.filter(
+ ({ logicalId, target }) => logicalId === expected.logicalId && target === expected.target,
+ );
+ if (matches.length === 0) {
+ return [`warm builder is missing cache mount ${expected.logicalId} at ${expected.target}`];
+ }
+ if (matches.length > 1) {
+ return [`warm builder contains duplicate cache mounts ${expected.logicalId} at ${expected.target}`];
+ }
+ return [];
+ });
+ }
+ return [];
+};
+
+export const getBuildCacheContinuityIneligibleReasons = (
+ cacheState: string | null,
+ before: DockerBuildCacheSnapshot | null,
+ after: DockerBuildCacheSnapshot | null,
+ expectedMounts: ExpectedDockerBuildCacheMount[],
+) => {
+ if (cacheState !== "warm") return [];
+ if (!before || !after) return ["warm builder cache continuity could not be inspected"];
+
+ return expectedMounts.flatMap((expected) => {
+ const beforeMatches = before.cacheMounts.filter(
+ ({ logicalId, target }) => logicalId === expected.logicalId && target === expected.target,
+ );
+ const afterMatches = after.cacheMounts.filter(
+ ({ logicalId, target }) => logicalId === expected.logicalId && target === expected.target,
+ );
+ if (beforeMatches.length !== 1 || afterMatches.length !== 1) {
+ return [`warm cache mount ${expected.logicalId} at ${expected.target} could not be traced across the build`];
+ }
+ const beforeMount = beforeMatches[0];
+ const afterMount = afterMatches[0];
+ if (!beforeMount || !afterMount || beforeMount.recordId !== afterMount.recordId) {
+ return [`warm cache mount ${expected.logicalId} at ${expected.target} was replaced during the build`];
+ }
+ if (!expected.mustIncreaseUsage) return [];
+ if (beforeMount.usageCount === null || afterMount.usageCount === null) {
+ return [`warm cache mount ${expected.logicalId} usage could not be verified`];
+ }
+ if (afterMount.usageCount <= beforeMount.usageCount) {
+ return [`warm cache mount ${expected.logicalId} was not used by the application build`];
+ }
+ return [];
+ });
+};
+
+export const getDockerBuildClaimIneligibleReasons = ({
+ expectedRevision,
+ expectedSourceFingerprint,
+ imageArchitecture,
+ imageRevision,
+ imageSourceFingerprint,
+ expectedImageArchitecture,
+}: {
+ expectedRevision: string | null;
+ expectedSourceFingerprint: string;
+ imageArchitecture: string;
+ imageRevision: string | null;
+ imageSourceFingerprint: string | null;
+ expectedImageArchitecture: string;
+}) => {
+ const reasons: string[] = [];
+
+ if (!expectedRevision) reasons.push("BUILD_BENCHMARK_REVISION is missing");
+ else if (imageRevision !== expectedRevision) {
+ reasons.push(`image revision ${imageRevision ?? "missing"} does not match ${expectedRevision}`);
+ }
+ if (imageSourceFingerprint !== expectedSourceFingerprint) {
+ reasons.push("image source fingerprint does not match the measured context");
+ }
+ if (imageArchitecture !== expectedImageArchitecture) {
+ reasons.push(`image architecture ${imageArchitecture} is not Docker server ${expectedImageArchitecture}`);
+ }
+
+ return reasons;
+};
diff --git a/scripts/benchmarks/docker-build-lib.spec.ts b/scripts/benchmarks/docker-build-lib.spec.ts
new file mode 100644
index 000000000..f2b4ac9a2
--- /dev/null
+++ b/scripts/benchmarks/docker-build-lib.spec.ts
@@ -0,0 +1,224 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ applyDockerBuildSyntheticSourceChange,
+ fingerprintDockerBuildSource,
+ getBuildCacheContinuityIneligibleReasons,
+ getBuildCacheStateIneligibleReasons,
+ getDockerBuildClaimIneligibleReasons,
+ getMeasuredRevision,
+ nextBuildCacheTarget,
+ normalizeDockerArchitecture,
+ parseDockerBuildCacheMountDescription,
+ parseDockerBuildOutput,
+ parseDockerfileCacheMounts,
+} from "./docker-build-lib.mts";
+
+const pnpmMount = { logicalId: "pnpm-store", target: "/pnpm/store", mustIncreaseUsage: false };
+const nextMount = {
+ logicalId: "homarr-next-build-linux/arm64",
+ target: nextBuildCacheTarget,
+ mustIncreaseUsage: true,
+};
+const cacheRecord = ({
+ logicalId,
+ recordId,
+ target,
+ usageCount,
+}: {
+ logicalId: string;
+ recordId: string;
+ target: string;
+ usageCount: number | null;
+}) => ({
+ description: `cached mount ${target} from exec benchmark with id "/${logicalId}"`,
+ logicalId,
+ recordId,
+ size: "1MB",
+ target,
+ usageCount,
+});
+
+describe("Docker build benchmark", () => {
+ it("fingerprints the exact imported source mutation used to force incremental compilation", () => {
+ const entries = [
+ {
+ file: "apps/nextjs/src/app/[locale]/layout.tsx",
+ mode: 0o100644,
+ content: Buffer.from('openGraph: {\n siteName: "Homarr",\n },\n'),
+ },
+ ];
+ const before = fingerprintDockerBuildSource(entries);
+ const { entries: changedEntries, change } = applyDockerBuildSyntheticSourceChange(entries, "paired-edit-1");
+
+ expect(change).toEqual({
+ file: "apps/nextjs/src/app/[locale]/layout.tsx",
+ id: "paired-edit-1",
+ from: 'siteName: "Homarr",',
+ to: 'siteName: "Homarr build benchmark paired-edit-1",',
+ });
+ expect(changedEntries[0]?.content?.toString("utf8")).toContain(change.to);
+ expect(fingerprintDockerBuildSource(changedEntries)).not.toBe(before);
+ });
+
+ it("fails closed when the real source marker is absent or ambiguous", () => {
+ expect(() =>
+ applyDockerBuildSyntheticSourceChange(
+ [{ file: "apps/nextjs/src/app/[locale]/layout.tsx", mode: 0o100644, content: Buffer.from("no marker") }],
+ "edit",
+ ),
+ ).toThrow("Expected exactly one build benchmark source marker");
+ expect(() => applyDockerBuildSyntheticSourceChange([], "edit")).toThrow("source marker file is missing");
+ });
+
+ it("derives the exact context-specific cache mounts for the target platform", () => {
+ const dockerfile = `RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store echo fetch
+RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store echo install
+RUN --mount=type=cache,id=homarr-next-build-\${TARGETPLATFORM},target=${nextBuildCacheTarget},sharing=locked echo build`;
+
+ expect(parseDockerfileCacheMounts(dockerfile, "linux/arm64")).toEqual([nextMount, pnpmMount]);
+ expect(parseDockerfileCacheMounts(dockerfile.split("\n").slice(0, 2).join("\n"), "linux/arm64")).toEqual([
+ pnpmMount,
+ ]);
+ });
+
+ it("fails closed for implicit or unresolved Dockerfile cache mount identity", () => {
+ expect(() => parseDockerfileCacheMounts("RUN --mount=type=cache,target=/cache echo build", "linux/arm64")).toThrow(
+ "explicit id and target",
+ );
+ expect(() =>
+ parseDockerfileCacheMounts(
+ "RUN --mount=type=cache,id=homarr-${BUILDPLATFORM},target=/cache echo build",
+ "linux/arm64",
+ ),
+ ).toThrow("unresolved variable ${BUILDPLATFORM}");
+ expect(() =>
+ parseDockerfileCacheMounts(
+ "RUN --mount=type=cache,id=homarr-${INVALID-NAME},target=/cache echo build",
+ "linux/arm64",
+ ),
+ ).toThrow("unresolved variable");
+ });
+
+ it("reads exact logical cache identity from real buildx du descriptions", () => {
+ expect(
+ parseDockerBuildCacheMountDescription(
+ 'cached mount /app/apps/nextjs/.next/cache from exec /bin/sh -c pnpm build with id "/homarr-next-build-linux/arm64"',
+ ),
+ ).toEqual({ logicalId: "homarr-next-build-linux/arm64", target: nextBuildCacheTarget });
+ expect(parseDockerBuildCacheMountDescription("unrecognized output")).toBeNull();
+ });
+
+ it("extracts cache and Next compilation measurements from plain BuildKit output", () => {
+ const output = `#7 CACHED
+#8 DONE 0.4s
+#9 12.0 ✓ Compiled successfully in 21.7s
+#9 DONE 22.3s`;
+
+ expect(parseDockerBuildOutput(output)).toEqual({
+ cachedStepCount: 1,
+ completedStepCount: 2,
+ nextCompileMs: 21_700,
+ });
+ });
+
+ it("only permits claims for a native image labeled with the exact measured source", () => {
+ expect(
+ getDockerBuildClaimIneligibleReasons({
+ expectedRevision: "head+worktree",
+ expectedSourceFingerprint: "source-hash",
+ imageArchitecture: "amd64",
+ imageRevision: "head+worktree",
+ imageSourceFingerprint: "source-hash",
+ expectedImageArchitecture: "amd64",
+ }),
+ ).toEqual([]);
+ });
+
+ it("normalizes Docker server architectures into OCI platform names", () => {
+ expect(normalizeDockerArchitecture("x86_64")).toBe("amd64");
+ expect(normalizeDockerArchitecture("aarch64")).toBe("arm64");
+ expect(normalizeDockerArchitecture("armv7l")).toBe("arm/v7");
+ });
+
+ it("derives revisions from Git state instead of trusting image-label input", () => {
+ expect(getMeasuredRevision("abc123", false)).toBe("abc123");
+ expect(getMeasuredRevision("abc123", true)).toBe("abc123+worktree");
+ });
+
+ it("verifies declared cold and warm cache preconditions", () => {
+ const empty = { cacheMountCount: 0, cacheMounts: [], recordCount: 0 };
+ const warm = {
+ cacheMountCount: 2,
+ cacheMounts: [
+ cacheRecord({ logicalId: pnpmMount.logicalId, recordId: "pnpm", target: pnpmMount.target, usageCount: 2 }),
+ cacheRecord({ logicalId: nextMount.logicalId, recordId: "next", target: nextMount.target, usageCount: 4 }),
+ ],
+ recordCount: 4,
+ };
+ expect(getBuildCacheStateIneligibleReasons("cold", empty, [pnpmMount])).toEqual([]);
+ expect(getBuildCacheStateIneligibleReasons("cold", warm, [pnpmMount])).toEqual([
+ "cold builder already contains 4 cache records",
+ ]);
+ expect(getBuildCacheStateIneligibleReasons("warm", warm, [pnpmMount, nextMount])).toEqual([]);
+
+ const staleNext = {
+ ...warm,
+ cacheMounts: [
+ warm.cacheMounts[0]!,
+ cacheRecord({
+ logicalId: "homarr-next-build",
+ recordId: "old-next",
+ target: nextBuildCacheTarget,
+ usageCount: 9,
+ }),
+ ],
+ };
+ expect(getBuildCacheStateIneligibleReasons("warm", staleNext, [pnpmMount, nextMount])).toEqual([
+ `warm builder is missing cache mount ${nextMount.logicalId} at ${nextBuildCacheTarget}`,
+ ]);
+ });
+
+ it("requires every warm cache record to survive and the Next cache to be used", () => {
+ const before = {
+ cacheMountCount: 2,
+ cacheMounts: [
+ cacheRecord({ logicalId: pnpmMount.logicalId, recordId: "pnpm", target: pnpmMount.target, usageCount: 2 }),
+ cacheRecord({ logicalId: nextMount.logicalId, recordId: "next", target: nextMount.target, usageCount: 4 }),
+ ],
+ recordCount: 4,
+ };
+ const after = {
+ cacheMountCount: 2,
+ cacheMounts: [
+ cacheRecord({ logicalId: pnpmMount.logicalId, recordId: "pnpm", target: pnpmMount.target, usageCount: 2 }),
+ cacheRecord({ logicalId: nextMount.logicalId, recordId: "next", target: nextMount.target, usageCount: 6 }),
+ ],
+ recordCount: 8,
+ };
+
+ expect(getBuildCacheContinuityIneligibleReasons("warm", before, after, [pnpmMount, nextMount])).toEqual([]);
+ expect(
+ getBuildCacheContinuityIneligibleReasons(
+ "warm",
+ before,
+ {
+ ...after,
+ cacheMounts: [after.cacheMounts[0]!, { ...after.cacheMounts[1]!, recordId: "replacement" }],
+ },
+ [pnpmMount, nextMount],
+ ),
+ ).toEqual([`warm cache mount ${nextMount.logicalId} at ${nextBuildCacheTarget} was replaced during the build`]);
+ expect(
+ getBuildCacheContinuityIneligibleReasons(
+ "warm",
+ before,
+ {
+ ...after,
+ cacheMounts: [after.cacheMounts[0]!, { ...after.cacheMounts[1]!, usageCount: 4 }],
+ },
+ [pnpmMount, nextMount],
+ ),
+ ).toEqual([`warm cache mount ${nextMount.logicalId} was not used by the application build`]);
+ });
+});
diff --git a/scripts/benchmarks/docker-build.mts b/scripts/benchmarks/docker-build.mts
new file mode 100644
index 000000000..5df7e1fcc
--- /dev/null
+++ b/scripts/benchmarks/docker-build.mts
@@ -0,0 +1,351 @@
+import { execFile, spawn } from "node:child_process";
+import { createHash } from "node:crypto";
+import { chmod, lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { performance } from "node:perf_hooks";
+import { promisify } from "node:util";
+
+import {
+ applyDockerBuildSyntheticSourceChange,
+ fingerprintDockerBuildSource,
+ getBuildCacheContinuityIneligibleReasons,
+ getBuildCacheStateIneligibleReasons,
+ getDockerBuildClaimIneligibleReasons,
+ getMeasuredRevision,
+ normalizeDockerArchitecture,
+ parseDockerBuildCacheMountDescription,
+ parseDockerBuildOutput,
+ parseDockerfileCacheMounts,
+} from "./docker-build-lib.mts";
+import type { DockerBuildSourceEntry } from "./docker-build-lib.mts";
+
+const runStartedAt = new Date().toISOString();
+const execFileAsync = promisify(execFile);
+const contextDirectory = path.resolve(process.env.BUILD_BENCHMARK_CONTEXT ?? ".");
+const declaredRevision = process.env.BUILD_BENCHMARK_REVISION?.trim() || null;
+const mode = process.env.BUILD_BENCHMARK_MODE ?? "cached";
+if (mode !== "cached" && mode !== "no-cache" && mode !== "source-change") {
+ throw new Error("BUILD_BENCHMARK_MODE must be cached, no-cache, or source-change");
+}
+const sourceChangeId = process.env.BUILD_BENCHMARK_SOURCE_CHANGE_ID?.trim() || null;
+if (mode === "source-change" && !sourceChangeId) {
+ throw new Error("BUILD_BENCHMARK_SOURCE_CHANGE_ID is required in source-change mode");
+}
+
+const imageTag = process.env.BUILD_BENCHMARK_IMAGE ?? `homarr-build-benchmark:${process.pid}`;
+const builder = process.env.BUILD_BENCHMARK_BUILDER?.trim() || null;
+const runLabel = process.env.BUILD_BENCHMARK_RUN_LABEL?.trim() || null;
+const seriesId = process.env.BUILD_BENCHMARK_SERIES_ID?.trim() || null;
+const cacheState = process.env.BUILD_BENCHMARK_CACHE_STATE?.trim() || null;
+const repetition = Number(process.env.BUILD_BENCHMARK_REPETITION ?? 0);
+const totalRepetitions = Number(process.env.BUILD_BENCHMARK_TOTAL_REPETITIONS ?? 0);
+const outputDirectory = path.resolve(process.env.BUILD_BENCHMARK_OUTPUT_DIR ?? "benchmark-results/docker-build");
+const cacheSnapshotPollMs = 2_000;
+const cacheSnapshotStableMs = 10_000;
+const cacheSnapshotTimeoutMs = 30_000;
+const execGitAsync = async (args: string[]) =>
+ await execFileAsync("git", ["-C", contextDirectory, ...args], { maxBuffer: 16 * 1024 * 1024 });
+
+const inspectDockerServerAsync = async () => {
+ const { stdout } = await execFileAsync("docker", ["version", "--format", "{{json .Server}}"], {
+ maxBuffer: 16 * 1024 * 1024,
+ });
+ const server = JSON.parse(stdout) as {
+ ApiVersion?: string;
+ Arch?: string;
+ Os?: string;
+ Version?: string;
+ } | null;
+ if (!server?.Arch || !server.Os) throw new Error("Docker server OS and architecture could not be inspected");
+ const architecture = normalizeDockerArchitecture(server.Arch);
+ return {
+ apiVersion: server.ApiVersion ?? null,
+ architecture,
+ operatingSystem: server.Os.toLowerCase(),
+ targetPlatform: `${server.Os.toLowerCase()}/${architecture}`,
+ version: server.Version ?? null,
+ };
+};
+
+const fingerprintSourceAsync = async () => {
+ const { stdout } = await execGitAsync(["ls-files", "--cached", "--others", "--exclude-standard", "-z"]);
+ const files = stdout.split("\0").filter(Boolean).toSorted();
+ let fileCount = 0;
+ const entries: DockerBuildSourceEntry[] = [];
+
+ for (const file of files) {
+ const absolutePath = path.join(contextDirectory, file);
+ const stats = await lstat(absolutePath).catch((error: NodeJS.ErrnoException) => {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ });
+ if (!stats) continue;
+ fileCount += 1;
+ const entry: DockerBuildSourceEntry = stats.isSymbolicLink()
+ ? { file, mode: stats.mode, linkTarget: await readlink(absolutePath) }
+ : { file, mode: stats.mode, content: await readFile(absolutePath) };
+ entries.push(entry);
+ }
+
+ return { fingerprint: fingerprintDockerBuildSource(entries), fileCount, entries };
+};
+
+const materializeSourceSnapshotAsync = async (entries: DockerBuildSourceEntry[]) => {
+ const directory = await mkdtemp(path.join(os.tmpdir(), "homarr-build-benchmark-"));
+ for (const entry of entries) {
+ const destination = path.join(directory, entry.file);
+ await mkdir(path.dirname(destination), { recursive: true });
+ if (entry.linkTarget !== undefined) {
+ await symlink(entry.linkTarget, destination);
+ } else {
+ await writeFile(destination, entry.content);
+ await chmod(destination, entry.mode & 0o777);
+ }
+ }
+ return directory;
+};
+
+const runDockerBuildAsync = async (args: string[]) =>
+ await new Promise<{ output: string; wallTimeMs: number }>((resolve, reject) => {
+ const startedAt = performance.now();
+ const child = spawn("docker", args, { cwd: contextDirectory, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
+ const chunks: Buffer[] = [];
+ const capture = (chunk: Buffer) => {
+ chunks.push(chunk);
+ process.stdout.write(chunk);
+ };
+ child.stdout.on("data", capture);
+ child.stderr.on("data", capture);
+ child.once("error", reject);
+ child.once("close", (code) => {
+ const output = Buffer.concat(chunks).toString("utf8");
+ const wallTimeMs = Math.round(performance.now() - startedAt);
+ if (code !== 0) {
+ reject(new Error(`docker build exited with ${String(code)} after ${wallTimeMs}ms`));
+ return;
+ }
+ resolve({ output, wallTimeMs });
+ });
+ });
+
+const inspectBuilderCacheAsync = async () => {
+ if (!builder) return null;
+ await execFileAsync("docker", ["buildx", "inspect", "--builder", builder, "--bootstrap"], {
+ maxBuffer: 16 * 1024 * 1024,
+ });
+ const { stdout } = await execFileAsync("docker", ["buildx", "du", "--builder", builder, "--format", "json"], {
+ maxBuffer: 16 * 1024 * 1024,
+ });
+ const records = stdout
+ .split("\n")
+ .filter(Boolean)
+ .map(
+ (line) =>
+ JSON.parse(line) as {
+ CreatedAt?: string;
+ Description?: string;
+ ID: string;
+ Mutable?: boolean;
+ Parents?: string[];
+ Reclaimable?: boolean;
+ Shared?: boolean;
+ Size?: string;
+ Type?: string;
+ UsageCount?: number;
+ },
+ );
+ const normalizedRecords = records
+ .map(({ CreatedAt, Description, ID, Mutable, Parents, Reclaimable, Shared, Size, Type, UsageCount }) => ({
+ createdAt: CreatedAt ?? null,
+ description: Description ?? null,
+ id: ID,
+ mutable: Mutable ?? null,
+ parents: Parents?.toSorted() ?? [],
+ reclaimable: Reclaimable ?? null,
+ shared: Shared ?? null,
+ size: Size ?? null,
+ type: Type ?? null,
+ usageCount: UsageCount ?? null,
+ }))
+ .toSorted((left, right) => left.id.localeCompare(right.id));
+ const cacheMounts = records
+ .filter(({ Type }) => Type === "exec.cachemount")
+ .map(({ Description, ID, Size, UsageCount }) => {
+ const description = Description ?? null;
+ const identity = parseDockerBuildCacheMountDescription(description);
+ return {
+ description,
+ logicalId: identity?.logicalId ?? null,
+ recordId: ID,
+ size: Size ?? null,
+ target: identity?.target ?? null,
+ usageCount: UsageCount ?? null,
+ };
+ });
+ return {
+ cacheMountCount: cacheMounts.length,
+ cacheMounts,
+ recordCount: records.length,
+ recordsFingerprint: createHash("sha256").update(JSON.stringify(normalizedRecords)).digest("hex"),
+ };
+};
+
+const inspectSettledBuilderCacheAsync = async () => {
+ if (!builder) return { snapshot: null, stability: null };
+ const startedAt = performance.now();
+ let attempts = 1;
+ let previous = await inspectBuilderCacheAsync();
+ let stableSince = performance.now();
+
+ while (performance.now() - startedAt < cacheSnapshotTimeoutMs) {
+ await new Promise((resolve) => setTimeout(resolve, cacheSnapshotPollMs));
+ const current = await inspectBuilderCacheAsync();
+ attempts += 1;
+ if (current?.recordsFingerprint !== previous?.recordsFingerprint) stableSince = performance.now();
+ previous = current;
+ const stableForMs = Math.round(performance.now() - stableSince);
+ if (stableForMs >= cacheSnapshotStableMs) {
+ return {
+ snapshot: current,
+ stability: { attempts, pollIntervalMs: cacheSnapshotPollMs, stableForMs, timeoutMs: cacheSnapshotTimeoutMs },
+ };
+ }
+ }
+
+ throw new Error(`Docker builder cache did not settle for ${cacheSnapshotStableMs}ms`);
+};
+
+await mkdir(outputDirectory, { recursive: true });
+const [{ stdout: headOutput }, { stdout: statusOutput }, source, dockerServer] = await Promise.all([
+ execGitAsync(["rev-parse", "HEAD"]),
+ execGitAsync(["status", "--porcelain=v1"]),
+ fingerprintSourceAsync(),
+ inspectDockerServerAsync(),
+]);
+const headSha = headOutput.trim();
+const dirty = statusOutput.trim().length > 0;
+const measuredRevision = getMeasuredRevision(headSha, dirty);
+const dockerfile = source.entries.find(({ file }) => file === "Dockerfile");
+if (!dockerfile?.content) throw new Error("Docker build context contains no regular Dockerfile");
+const expectedCacheMounts = parseDockerfileCacheMounts(
+ dockerfile.content.toString("utf8"),
+ dockerServer.targetPlatform,
+);
+const cacheBefore = await inspectBuilderCacheAsync();
+const syntheticSource =
+ mode === "source-change"
+ ? applyDockerBuildSyntheticSourceChange(source.entries, sourceChangeId as string)
+ : { entries: source.entries, change: null };
+const measuredSourceFingerprint = fingerprintDockerBuildSource(syntheticSource.entries);
+
+const buildArgs = [
+ "build",
+ "--progress=plain",
+ "--platform",
+ dockerServer.targetPlatform,
+ "--label",
+ `org.opencontainers.image.revision=${measuredRevision}`,
+ "--label",
+ `dev.homarr.source-fingerprint=${measuredSourceFingerprint}`,
+ "--tag",
+ imageTag,
+];
+if (builder) buildArgs.push("--builder", builder, "--load");
+if (mode === "no-cache") buildArgs.push("--no-cache");
+const snapshotDirectory = await materializeSourceSnapshotAsync(syntheticSource.entries);
+buildArgs.push(snapshotDirectory);
+
+const build = await runDockerBuildAsync(buildArgs).finally(
+ async () => await rm(snapshotDirectory, { recursive: true }),
+);
+const { snapshot: cacheAfter, stability: cacheAfterStability } = await inspectSettledBuilderCacheAsync();
+const imageInspect = JSON.parse((await execFileAsync("docker", ["image", "inspect", imageTag])).stdout)[0] as {
+ Architecture: string;
+ Config?: { Labels?: Record };
+ Id: string;
+ RepoDigests?: string[];
+ Size: number;
+};
+const labels = imageInspect.Config?.Labels ?? {};
+const parsedBuild = parseDockerBuildOutput(build.output);
+const claimIneligibleReasons = getDockerBuildClaimIneligibleReasons({
+ expectedRevision: measuredRevision,
+ expectedSourceFingerprint: measuredSourceFingerprint,
+ imageArchitecture: imageInspect.Architecture,
+ imageRevision: labels["org.opencontainers.image.revision"] ?? null,
+ imageSourceFingerprint: labels["dev.homarr.source-fingerprint"] ?? null,
+ expectedImageArchitecture: dockerServer.architecture.split("/")[0] as string,
+});
+if (!declaredRevision) claimIneligibleReasons.push("BUILD_BENCHMARK_REVISION is missing");
+else if (declaredRevision !== measuredRevision) {
+ claimIneligibleReasons.push(`declared revision ${declaredRevision} does not match measured ${measuredRevision}`);
+}
+if (!builder) claimIneligibleReasons.push("BUILD_BENCHMARK_BUILDER is missing");
+if (!seriesId) claimIneligibleReasons.push("BUILD_BENCHMARK_SERIES_ID is missing");
+claimIneligibleReasons.push(...getBuildCacheStateIneligibleReasons(cacheState, cacheBefore, expectedCacheMounts));
+claimIneligibleReasons.push(
+ ...getBuildCacheContinuityIneligibleReasons(cacheState, cacheBefore, cacheAfter, expectedCacheMounts),
+);
+if (mode === "cached") claimIneligibleReasons.push("cached mode does not force an application rebuild");
+if (mode === "no-cache" && cacheState === "warm") {
+ claimIneligibleReasons.push("no-cache mode resets BuildKit cache mounts and cannot measure a warm build");
+}
+if (mode !== "cached" && parsedBuild.nextCompileMs === null) {
+ claimIneligibleReasons.push("application build did not expose a Next compilation measurement");
+}
+if (!Number.isInteger(totalRepetitions) || totalRepetitions < 2) {
+ claimIneligibleReasons.push("BUILD_BENCHMARK_TOTAL_REPETITIONS must be at least 2");
+}
+if (!Number.isInteger(repetition) || repetition < 1 || repetition > totalRepetitions) {
+ claimIneligibleReasons.push("BUILD_BENCHMARK_REPETITION is outside the declared series");
+}
+const timestamp = Date.now();
+const logPath = path.join(outputDirectory, `build-${timestamp}.log`);
+const outputPath = path.join(outputDirectory, `build-${timestamp}.json`);
+const runFinishedAt = new Date().toISOString();
+const result = {
+ schemaVersion: 4,
+ claimEligible: claimIneligibleReasons.length === 0,
+ claimIneligibleReasons,
+ createdAt: runFinishedAt,
+ run: { startedAt: runStartedAt, finishedAt: runFinishedAt },
+ mode,
+ builder,
+ runLabel,
+ declaredRevision,
+ series: { id: seriesId, cacheState, repetition, totalRepetitions },
+ source: {
+ contextDirectory,
+ headSha,
+ dirty,
+ measuredRevision,
+ fingerprint: measuredSourceFingerprint,
+ baseFingerprint: source.fingerprint,
+ fileCount: source.fileCount,
+ immutableSnapshot: true,
+ syntheticSourceChange: syntheticSource.change,
+ },
+ builderCache: {
+ expected: expectedCacheMounts,
+ before: cacheBefore,
+ after: cacheAfter,
+ afterStability: cacheAfterStability,
+ },
+ host: { architecture: process.arch, cpuCount: os.cpus().length, dockerServer, platform: os.platform() },
+ image: {
+ requested: imageTag,
+ id: imageInspect.Id,
+ digest: imageInspect.RepoDigests?.[0] ?? null,
+ revision: labels["org.opencontainers.image.revision"] ?? null,
+ sourceFingerprint: labels["dev.homarr.source-fingerprint"] ?? null,
+ architecture: imageInspect.Architecture,
+ targetPlatform: dockerServer.targetPlatform,
+ localUncompressedBytes: imageInspect.Size,
+ },
+ build: { wallTimeMs: build.wallTimeMs, ...parsedBuild },
+ logPath,
+};
+
+await Promise.all([writeFile(logPath, build.output), writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`)]);
+console.log(JSON.stringify({ outputPath, ...result }, null, 2));
diff --git a/scripts/benchmarks/docker-runtime-lib.mts b/scripts/benchmarks/docker-runtime-lib.mts
new file mode 100644
index 000000000..adae41819
--- /dev/null
+++ b/scripts/benchmarks/docker-runtime-lib.mts
@@ -0,0 +1,649 @@
+import { createHash } from "node:crypto";
+import { jsonlStreamConsumer } from "@trpc/server/unstable-core-do-not-import";
+import { deserialize } from "superjson";
+import type { SuperJSONResult } from "superjson";
+
+import { isWidgetDataTrpcPath } from "../../packages/api/src/query-cache.ts";
+
+export type RuntimeMemoryCheckpoint = {
+ name: string;
+ capturedAt: string;
+ container: {
+ currentBytes: number;
+ peakBytes: number | null;
+ anonymousBytes: number;
+ fileBytes: number;
+ };
+ cpu: {
+ usageUsec: number;
+ userUsec: number;
+ systemUsec: number;
+ nrThrottled: number;
+ throttledUsec: number;
+ };
+ processes: Array<{
+ command: string;
+ parentPid: number;
+ pid: number;
+ pssBytes: number;
+ rssBytes: number;
+ }>;
+ redis: { usedMemoryBytes: number; peakMemoryBytes: number } | null;
+};
+
+export const minimumClaimSettleMs = 10 * 60 * 1_000;
+export const minimumClaimSettleSamples = 20;
+export const minimumClaimPageSamples = 20;
+export const minimumClaimLcpObservationMs = 5_000;
+export const minimumClaimWidgetDataQuietMs = 500;
+
+export const getBrowserNetworkIsolationLaunchArgs = (denyProxyUrl: string) => [
+ `--proxy-server=${denyProxyUrl}`,
+ "--proxy-bypass-list=127.0.0.1;localhost",
+ "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1, EXCLUDE localhost",
+];
+
+export const normalizeDockerArchitecture = (nodeArchitecture: string) =>
+ nodeArchitecture === "x64" ? "amd64" : nodeArchitecture;
+
+export const getTrpcProcedures = (requestUrl: string) => {
+ const pathname = new URL(requestUrl).pathname;
+ const prefix = "/api/trpc/";
+ const index = pathname.indexOf(prefix);
+ if (index < 0) return [];
+
+ return decodeURIComponent(pathname.slice(index + prefix.length))
+ .split(",")
+ .filter(Boolean);
+};
+
+const canonicalizeJson = (value: unknown): string => {
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
+ if (Array.isArray(value)) return `[${value.map(canonicalizeJson).join(",")}]`;
+
+ return `{${Object.entries(value)
+ .toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
+ .map(([key, nestedValue]) => `${JSON.stringify(key)}:${canonicalizeJson(nestedValue)}`)
+ .join(",")}}`;
+};
+
+const hashRequestInput = (value: unknown) => createHash("sha256").update(canonicalizeJson(value)).digest("hex");
+
+export type TrpcRequestIdentity = { batchIndex: number; identity: string; inputHash: string; procedure: string };
+
+export const getTrpcRequestIdentities = (requestUrl: string): TrpcRequestIdentity[] => {
+ const url = new URL(requestUrl);
+ const procedures = getTrpcProcedures(requestUrl);
+ if (procedures.length === 0) return [];
+ const rawInput = url.searchParams.get("input");
+ let parsedInput: unknown = null;
+ if (rawInput !== null) {
+ try {
+ parsedInput = JSON.parse(rawInput) as unknown;
+ } catch {
+ throw new Error("Malformed serialized tRPC request input");
+ }
+ }
+
+ const isBatch = url.searchParams.get("batch") === "1";
+ return procedures.map((procedure, index) => {
+ const input =
+ isBatch && parsedInput !== null && typeof parsedInput === "object" && !Array.isArray(parsedInput)
+ ? (parsedInput as Record)[String(index)]
+ : parsedInput;
+ const inputHash = hashRequestInput(input);
+ return { batchIndex: index, procedure, inputHash, identity: `${procedure}#${inputHash}` };
+ });
+};
+
+export const isRuntimeWidgetDataProcedure = (procedure: string) => {
+ if (procedure.split(".").some((part) => part.startsWith("subscribe"))) return false;
+ if (procedure.startsWith("widget.")) return procedure !== "widget.app.ping";
+ return isWidgetDataTrpcPath(procedure);
+};
+
+export const isRuntimeWidgetDataRequest = (method: string, procedure: string) =>
+ method === "GET" && isRuntimeWidgetDataProcedure(procedure);
+
+export const recordWidgetDataRequestOutcome = (
+ recoveryBalance: Map,
+ requestIdentities: string[],
+ outcome: "aborted" | "completed",
+) => {
+ const delta = outcome === "aborted" ? 1 : -1;
+ for (const requestIdentity of requestIdentities) {
+ recoveryBalance.set(requestIdentity, (recoveryBalance.get(requestIdentity) ?? 0) + delta);
+ }
+};
+
+export const getUnrecoveredWidgetDataRequests = (recoveryBalance: Map) =>
+ [...recoveryBalance.entries()]
+ .filter(([, balance]) => balance > 0)
+ .flatMap(([requestIdentity, balance]) => Array.from({ length: balance }, () => requestIdentity))
+ .toSorted();
+
+export const hasBlockingWidgetDataAborts = (allowUnrecoveredAborts: boolean, unrecoveredProcedureCount: number) =>
+ !allowUnrecoveredAborts && unrecoveredProcedureCount > 0;
+
+export const getSpotlightIdleNetworkFailure = (policy: string, nonWidgetTrpcRequestCount: number) =>
+ policy === "preload-only" && nonWidgetTrpcRequestCount > 0
+ ? `Idle Spotlight preload-only preparation made ${nonWidgetTrpcRequestCount} non-widget tRPC requests`
+ : null;
+
+export const getSpotlightIdleMountFailure = (policy: string, mountedCount: number) =>
+ policy === "preload-only" && mountedCount > 0 ? "Idle Spotlight preload mounted the UI before user intent" : null;
+
+const isRecord = (value: unknown): value is Record =>
+ value !== null && typeof value === "object" && !Array.isArray(value);
+
+export type TrpcOperationFailure = {
+ batchIndex: number;
+ kind: "http-status" | "invalid-jsonl" | "index-mismatch" | "invalid-envelope" | "trpc-error" | "stream-error";
+ httpStatus?: number;
+ jsonRpcCode?: number;
+ status?: number;
+ trpcCode?: string;
+};
+
+export type TrpcBatchInspection = { failures: TrpcOperationFailure[]; successfulIndexes: number[] };
+
+const failEveryOperation = (
+ operationCount: number,
+ failure: Omit,
+): TrpcBatchInspection => ({
+ failures: Array.from({ length: operationCount }, (_, batchIndex) => ({ batchIndex, ...failure })),
+ successfulIndexes: [],
+});
+
+const safeTrpcError = (batchIndex: number, error: unknown): TrpcOperationFailure => {
+ const record = isRecord(error) ? error : {};
+ const data = isRecord(record.data) ? record.data : {};
+ return {
+ batchIndex,
+ kind: "trpc-error",
+ ...(typeof data.httpStatus === "number" ? { httpStatus: data.httpStatus } : {}),
+ ...(typeof record.code === "number" ? { jsonRpcCode: record.code } : {}),
+ ...(typeof data.code === "string" ? { trpcCode: data.code } : {}),
+ };
+};
+
+export const inspectTrpcBatchStreamResponse = async (
+ status: number,
+ responseBody: string,
+ expectedOperationCount: number,
+): Promise => {
+ if (!Number.isInteger(expectedOperationCount) || expectedOperationCount < 1) {
+ throw new Error("expectedOperationCount must be a positive integer");
+ }
+ if (status < 200 || status >= 300) {
+ return failEveryOperation(expectedOperationCount, { kind: "http-status", status });
+ }
+ if (responseBody.trim().length === 0) return failEveryOperation(expectedOperationCount, { kind: "invalid-jsonl" });
+
+ const physicalLines = responseBody.split("\n");
+ const trailing = physicalLines.pop() ?? "";
+ if (trailing.trim() !== "") return failEveryOperation(expectedOperationCount, { kind: "invalid-jsonl" });
+ try {
+ const records = physicalLines.filter((line) => line.trim().length > 0);
+ if (records.length === 0) throw new Error("missing JSONL records");
+ for (const line of records) {
+ const serialized = JSON.parse(line) as unknown;
+ if (!isRecord(serialized) || !Object.hasOwn(serialized, "json")) throw new Error("not SuperJSON");
+ deserialize(serialized as SuperJSONResult);
+ }
+ } catch {
+ return failEveryOperation(expectedOperationCount, { kind: "invalid-jsonl" });
+ }
+
+ const abortController = new AbortController();
+ try {
+ const bytes = new TextEncoder().encode(responseBody);
+ const from = new ReadableStream({
+ start(controller) {
+ controller.enqueue(bytes);
+ controller.close();
+ },
+ });
+ const [head] = await jsonlStreamConsumer>>({
+ abortController,
+ deserialize: (value) => deserialize(value as SuperJSONResult),
+ formatError: () => new Error("tRPC stream operation error"),
+ from,
+ });
+ if (!isRecord(head)) return failEveryOperation(expectedOperationCount, { kind: "invalid-envelope" });
+
+ const actualIndexes = Object.keys(head);
+ const expectedIndexes = Array.from({ length: expectedOperationCount }, (_, index) => String(index));
+ if (
+ actualIndexes.length !== expectedIndexes.length ||
+ expectedIndexes.some((index) => !Object.hasOwn(head, index))
+ ) {
+ for (const value of Object.values(head)) void Promise.resolve(value).catch(() => undefined);
+ return failEveryOperation(expectedOperationCount, { kind: "index-mismatch" });
+ }
+
+ const outcomes = await Promise.all(
+ expectedIndexes.map(async (key): Promise<{ failure: TrpcOperationFailure | null; index: number }> => {
+ const batchIndex = Number(key);
+ try {
+ const envelope = await Promise.resolve(head[key]);
+ if (!isRecord(envelope)) return { failure: { batchIndex, kind: "invalid-envelope" }, index: batchIndex };
+ if (Object.hasOwn(envelope, "error")) {
+ return { failure: safeTrpcError(batchIndex, envelope.error), index: batchIndex };
+ }
+ if (!Object.hasOwn(envelope, "result")) {
+ return { failure: { batchIndex, kind: "invalid-envelope" }, index: batchIndex };
+ }
+ const result = await Promise.resolve(envelope.result);
+ if (!isRecord(result) || !Object.hasOwn(result, "data")) {
+ return { failure: { batchIndex, kind: "invalid-envelope" }, index: batchIndex };
+ }
+ await Promise.resolve(result.data);
+ return { failure: null, index: batchIndex };
+ } catch {
+ return { failure: { batchIndex, kind: "stream-error" }, index: batchIndex };
+ }
+ }),
+ );
+ return {
+ failures: outcomes.flatMap(({ failure }) => (failure ? [failure] : [])),
+ successfulIndexes: outcomes.flatMap(({ failure, index }) => (failure ? [] : [index])),
+ };
+ } catch {
+ return failEveryOperation(expectedOperationCount, { kind: "invalid-jsonl" });
+ } finally {
+ abortController.abort();
+ }
+};
+
+type RuntimeMetricEligibilityInput = {
+ expectedSha: string | null;
+ expectedSourceFingerprint: string | null;
+ browserNetworkIsolated: boolean;
+ serverNetworkIsolated: boolean;
+ imageArchitecture: string;
+ lcpObservationMs: number;
+ lcpSampleCount: number;
+ nodeArchitecture: string;
+ pageSampleCount: number;
+ requireImplementationMarkers: boolean;
+ revision: string | null;
+ sourceFingerprint: string | null;
+ settleMs: number;
+ settleSampleCount: number;
+ widgetDataQuietMs: number;
+ widgetDataSampleCount: number;
+};
+
+const getRuntimeProvenanceIneligibleReasons = ({
+ browserNetworkIsolated,
+ serverNetworkIsolated,
+ expectedSha,
+ expectedSourceFingerprint,
+ imageArchitecture,
+ nodeArchitecture,
+ revision,
+ sourceFingerprint,
+}: Pick<
+ RuntimeMetricEligibilityInput,
+ | "browserNetworkIsolated"
+ | "serverNetworkIsolated"
+ | "expectedSha"
+ | "expectedSourceFingerprint"
+ | "imageArchitecture"
+ | "nodeArchitecture"
+ | "revision"
+ | "sourceFingerprint"
+>) => {
+ const reasons: string[] = [];
+ const expectedArchitecture = normalizeDockerArchitecture(nodeArchitecture);
+
+ if (imageArchitecture !== expectedArchitecture) {
+ reasons.push(`image architecture ${imageArchitecture} is not host ${expectedArchitecture}`);
+ }
+ if (!expectedSha) reasons.push("RUNTIME_BENCHMARK_SHA is missing");
+ else if (!revision) reasons.push("image revision label is missing");
+ else if (revision !== expectedSha) reasons.push(`image revision ${revision} does not match ${expectedSha}`);
+ if (!expectedSourceFingerprint) reasons.push("RUNTIME_BENCHMARK_SOURCE_FINGERPRINT is missing");
+ else if (!sourceFingerprint) reasons.push("image source fingerprint label is missing");
+ else if (sourceFingerprint !== expectedSourceFingerprint) {
+ reasons.push(`image source fingerprint ${sourceFingerprint} does not match ${expectedSourceFingerprint}`);
+ }
+ if (!browserNetworkIsolated) reasons.push("browser external network isolation is not active");
+ if (!serverNetworkIsolated) reasons.push("server external network isolation is not active");
+
+ return reasons;
+};
+
+const createEligibility = ({
+ ineligibleReasons,
+ kind,
+ limitations,
+ requiredSampleCount,
+ sampleCount,
+ sampleUnit,
+ scope,
+}: {
+ ineligibleReasons: string[];
+ kind: "comparative" | "diagnostic" | "within-run";
+ limitations: string[];
+ requiredSampleCount: number | null;
+ sampleCount: number;
+ sampleUnit: string;
+ scope: string;
+}) => ({
+ claimEligible: ineligibleReasons.length === 0,
+ ineligibleReasons,
+ kind,
+ limitations,
+ requiredSampleCount,
+ sampleCount,
+ sampleUnit,
+ scope,
+});
+
+export const getRuntimeMetricEligibility = (input: RuntimeMetricEligibilityInput) => {
+ const provenanceReasons = getRuntimeProvenanceIneligibleReasons(input);
+ const markerReasons = input.requireImplementationMarkers ? [] : ["widget implementation markers are not required"];
+ const pageReasons = [
+ ...provenanceReasons,
+ ...markerReasons,
+ ...(input.pageSampleCount < minimumClaimPageSamples
+ ? [`page sample count ${input.pageSampleCount} is below ${minimumClaimPageSamples}`]
+ : []),
+ ];
+ const lcpReasons = [
+ ...provenanceReasons,
+ ...markerReasons,
+ ...(input.lcpObservationMs < minimumClaimLcpObservationMs
+ ? [`LCP observation ${input.lcpObservationMs}ms is below ${minimumClaimLcpObservationMs}ms`]
+ : []),
+ ...(input.lcpSampleCount < minimumClaimPageSamples
+ ? [`LCP sample count ${input.lcpSampleCount} is below ${minimumClaimPageSamples}`]
+ : []),
+ ];
+ const settleReasons = [
+ ...provenanceReasons,
+ ...markerReasons,
+ ...(input.settleMs < minimumClaimSettleMs
+ ? [`settle duration ${input.settleMs}ms is below ${minimumClaimSettleMs}ms`]
+ : []),
+ ...(input.settleSampleCount < minimumClaimSettleSamples
+ ? [`settle sample count ${input.settleSampleCount} is below ${minimumClaimSettleSamples}`]
+ : []),
+ ];
+ const widgetDataReasons = [
+ ...provenanceReasons,
+ ...markerReasons,
+ ...(input.widgetDataQuietMs < minimumClaimWidgetDataQuietMs
+ ? [`widget data quiet window ${input.widgetDataQuietMs}ms is below ${minimumClaimWidgetDataQuietMs}ms`]
+ : []),
+ ...(input.widgetDataSampleCount < minimumClaimPageSamples
+ ? [`widget data sample count ${input.widgetDataSampleCount} is below ${minimumClaimPageSamples}`]
+ : []),
+ ];
+
+ return {
+ pageLoads: createEligibility({
+ ineligibleReasons: pageReasons,
+ kind: "comparative",
+ limitations: [
+ "samples share one container instance",
+ "application ingress traverses a local TCP proxy sidecar excluded from memory samples",
+ ],
+ requiredSampleCount: minimumClaimPageSamples,
+ sampleCount: input.pageSampleCount,
+ sampleUnit: "fresh authenticated Chromium document",
+ scope: "fresh authenticated dashboard document load timings",
+ }),
+ largestContentfulPaint: createEligibility({
+ ineligibleReasons: lcpReasons,
+ kind: "comparative",
+ limitations: [
+ "samples share one container instance",
+ "application ingress traverses a local TCP proxy sidecar excluded from memory samples",
+ ],
+ requiredSampleCount: minimumClaimPageSamples,
+ sampleCount: input.lcpSampleCount,
+ sampleUnit: "fresh authenticated Chromium document",
+ scope: `dashboard LCP observed for at least ${minimumClaimLcpObservationMs}ms and finalized by trusted input`,
+ }),
+ widgetDataSettled: createEligibility({
+ ineligibleReasons: widgetDataReasons,
+ kind: "comparative",
+ limitations: [
+ "samples share one container instance",
+ "covers observed widget read-side tRPC requests; excludes subscriptions and widget.app.ping",
+ "application ingress traverses a local TCP proxy sidecar excluded from memory samples",
+ ],
+ requiredSampleCount: minimumClaimPageSamples,
+ sampleCount: input.widgetDataSampleCount,
+ sampleUnit: "fresh authenticated Chromium document",
+ scope: `widget tRPC completion followed by a ${minimumClaimWidgetDataQuietMs}ms quiet window and two animation frames`,
+ }),
+ settleStability: createEligibility({
+ ineligibleReasons: settleReasons,
+ kind: "within-run",
+ limitations: [
+ "one container instance",
+ "eligible only for within-run memory stability and trend, not cross-build RAM level claims",
+ ],
+ requiredSampleCount: minimumClaimSettleSamples,
+ sampleCount: input.settleSampleCount,
+ sampleUnit: "time-series checkpoint",
+ scope: `one warmed container observed for at least ${minimumClaimSettleMs}ms`,
+ }),
+ startup: createEligibility({
+ ineligibleReasons: ["single container start is diagnostic only"],
+ kind: "diagnostic",
+ limitations: ["n=1 container start"],
+ requiredSampleCount: null,
+ sampleCount: 1,
+ sampleUnit: "container start",
+ scope: "start-to-ready timing",
+ }),
+ memoryLevels: createEligibility({
+ ineligibleReasons: ["memory levels have one container observation per workload stage"],
+ kind: "diagnostic",
+ limitations: ["n=1 container per workload stage", "settle checkpoints are correlated time samples"],
+ requiredSampleCount: null,
+ sampleCount: 1,
+ sampleUnit: "container instance",
+ scope: "startup, workload, peak, and settled RAM levels",
+ }),
+ coldSpotlightInteraction: createEligibility({
+ ineligibleReasons: ["cold Spotlight interaction has one observation"],
+ kind: "diagnostic",
+ limitations: ["n=1 cold interaction"],
+ requiredSampleCount: null,
+ sampleCount: 1,
+ sampleUnit: "cold browser interaction",
+ scope: "first feedback and full-ready Spotlight latency",
+ }),
+ };
+};
+
+export const getRuntimeClaimIneligibleReasons = (input: RuntimeMetricEligibilityInput) => {
+ const eligibility = getRuntimeMetricEligibility(input);
+ return [
+ ...new Set([
+ ...eligibility.pageLoads.ineligibleReasons,
+ ...eligibility.largestContentfulPaint.ineligibleReasons,
+ ...eligibility.widgetDataSettled.ineligibleReasons,
+ ...eligibility.settleStability.ineligibleReasons,
+ ]),
+ ];
+};
+
+const readNumericLine = (input: string, key: string) => {
+ const match = input.match(new RegExp(`^${key}[=:]\\s*(\\d+)`, "m"));
+ return match ? Number(match[1]) : null;
+};
+
+export const parseRuntimeMemorySnapshot = (name: string, output: string): RuntimeMemoryCheckpoint => {
+ const currentBytes = readNumericLine(output, "memory_current");
+ const anonymousBytes = readNumericLine(output, "cgroup_anon");
+ const fileBytes = readNumericLine(output, "cgroup_file");
+ if (currentBytes === null || anonymousBytes === null || fileBytes === null) {
+ throw new Error(`Incomplete cgroup memory snapshot for ${name}`);
+ }
+
+ const usageUsec = readNumericLine(output, "cpu_usage_usec");
+ const userUsec = readNumericLine(output, "cpu_user_usec");
+ const systemUsec = readNumericLine(output, "cpu_system_usec");
+ const nrThrottled = readNumericLine(output, "cpu_nr_throttled");
+ const throttledUsec = readNumericLine(output, "cpu_throttled_usec");
+ if (
+ usageUsec === null ||
+ userUsec === null ||
+ systemUsec === null ||
+ nrThrottled === null ||
+ throttledUsec === null
+ ) {
+ throw new Error(`Incomplete cgroup CPU snapshot for ${name}`);
+ }
+
+ const processes = output
+ .split("\n")
+ .filter((line) => line.startsWith("process="))
+ .flatMap((line) => {
+ const [pid, parentPid, rssKiB, command, pssKiB] = line.slice("process=".length).split("|");
+ if (!pid || !parentPid || !rssKiB || !command || !pssKiB) return [];
+ return [
+ {
+ pid: Number(pid),
+ parentPid: Number(parentPid),
+ rssBytes: Number(rssKiB) * 1024,
+ pssBytes: Number(pssKiB) * 1024,
+ command,
+ },
+ ];
+ });
+ const redisUsed = readNumericLine(output, "redis_used_memory");
+ const redisPeak = readNumericLine(output, "redis_used_memory_peak");
+
+ return {
+ name,
+ capturedAt: new Date().toISOString(),
+ container: {
+ currentBytes,
+ peakBytes: readNumericLine(output, "memory_peak"),
+ anonymousBytes,
+ fileBytes,
+ },
+ cpu: { usageUsec, userUsec, systemUsec, nrThrottled, throttledUsec },
+ processes,
+ redis: redisUsed === null || redisPeak === null ? null : { usedMemoryBytes: redisUsed, peakMemoryBytes: redisPeak },
+ };
+};
+
+const cpuDelta = (start: RuntimeMemoryCheckpoint | undefined, end: RuntimeMemoryCheckpoint | undefined) =>
+ start && end
+ ? {
+ usageUsec: end.cpu.usageUsec - start.cpu.usageUsec,
+ userUsec: end.cpu.userUsec - start.cpu.userUsec,
+ systemUsec: end.cpu.systemUsec - start.cpu.systemUsec,
+ nrThrottled: end.cpu.nrThrottled - start.cpu.nrThrottled,
+ throttledUsec: end.cpu.throttledUsec - start.cpu.throttledUsec,
+ }
+ : null;
+
+const summarizeSamples = (values: number[]) => {
+ if (values.length === 0) return null;
+ const sorted = values.toSorted((left, right) => left - right);
+ const middle = Math.floor(sorted.length / 2);
+ const median =
+ sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2 : (sorted[middle] ?? 0);
+ const mean = sorted.reduce((total, value) => total + value, 0) / sorted.length;
+ const variance = sorted.reduce((total, value) => total + (value - mean) ** 2, 0) / sorted.length;
+ return {
+ count: sorted.length,
+ minBytes: sorted[0] ?? null,
+ medianBytes: median,
+ p95Bytes: sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)] ?? null,
+ maxBytes: sorted.at(-1) ?? null,
+ standardDeviationBytes: Math.round(Math.sqrt(variance)),
+ };
+};
+
+const getTrendBytesPerHour = (samples: Array<{ capturedAt: string; value: number }>) => {
+ if (samples.length < 2) return null;
+ const originMs = Date.parse(samples[0]?.capturedAt ?? "");
+ const timedSamples = samples.flatMap(({ capturedAt, value }) => {
+ const capturedAtMs = Date.parse(capturedAt);
+ return Number.isFinite(originMs) && Number.isFinite(capturedAtMs)
+ ? [{ elapsedMs: capturedAtMs - originMs, value }]
+ : [];
+ });
+ if (timedSamples.length < 2) return null;
+ const xMean = timedSamples.reduce((total, { elapsedMs }) => total + elapsedMs, 0) / timedSamples.length;
+ const yMean = timedSamples.reduce((total, { value }) => total + value, 0) / timedSamples.length;
+ let numerator = 0;
+ let denominator = 0;
+ for (const { elapsedMs, value } of timedSamples) {
+ numerator += (elapsedMs - xMean) * (value - yMean);
+ denominator += (elapsedMs - xMean) ** 2;
+ }
+ return denominator === 0 ? null : (numerator / denominator) * 3_600_000;
+};
+
+export const summarizeRuntimeMemory = (checkpoints: RuntimeMemoryCheckpoint[]) => {
+ const startup = checkpoints.find(({ name }) => name === "startup");
+ const workload = checkpoints.find(({ name }) => name === "workload");
+ const settled = checkpoints.at(-1);
+ const settleValues = checkpoints
+ .filter(({ name }) => name.startsWith("settle-"))
+ .map(({ container }) => container.currentBytes);
+ const settleCurrentSamples = checkpoints
+ .filter(({ name }) => name.startsWith("settle-"))
+ .map(({ capturedAt, container }) => ({ capturedAt, value: container.currentBytes }));
+ const settleAnonymousValues = checkpoints
+ .filter(({ name }) => name.startsWith("settle-"))
+ .map(({ container }) => container.anonymousBytes);
+ const settleAnonymousSamples = checkpoints
+ .filter(({ name }) => name.startsWith("settle-"))
+ .map(({ capturedAt, container }) => ({ capturedAt, value: container.anonymousBytes }));
+ const settleFileValues = checkpoints
+ .filter(({ name }) => name.startsWith("settle-"))
+ .map(({ container }) => container.fileBytes);
+ const settleFileSamples = checkpoints
+ .filter(({ name }) => name.startsWith("settle-"))
+ .map(({ capturedAt, container }) => ({ capturedAt, value: container.fileBytes }));
+ const settle = summarizeSamples(settleValues);
+ const settleAnonymous = summarizeSamples(settleAnonymousValues);
+ const settleFile = summarizeSamples(settleFileValues);
+ const currentValues = checkpoints.map(({ container }) => container.currentBytes);
+ const peakValues = checkpoints.flatMap(({ container }) =>
+ container.peakBytes === null ? [] : [container.peakBytes],
+ );
+ const growthBytes = workload && settle ? settle.medianBytes - workload.container.currentBytes : null;
+ return {
+ startupBytes: startup?.container.currentBytes ?? null,
+ workloadBytes: workload?.container.currentBytes ?? null,
+ settledBytes: settled?.container.currentBytes ?? null,
+ maximumSampleBytes: currentValues.length > 0 ? Math.max(...currentValues) : null,
+ peakBytes: peakValues.length > 0 ? Math.max(...peakValues) : null,
+ growthBytes,
+ growthPercent:
+ workload && growthBytes !== null && workload.container.currentBytes > 0
+ ? (growthBytes / workload.container.currentBytes) * 100
+ : null,
+ settle: settle
+ ? {
+ ...settle,
+ trendBytesPerHour: getTrendBytesPerHour(settleCurrentSamples),
+ anonymous: settleAnonymous
+ ? {
+ ...settleAnonymous,
+ trendBytesPerHour: getTrendBytesPerHour(settleAnonymousSamples),
+ }
+ : null,
+ file: settleFile ? { ...settleFile, trendBytesPerHour: getTrendBytesPerHour(settleFileSamples) } : null,
+ }
+ : null,
+ cpu: {
+ startupToWorkload: cpuDelta(startup, workload),
+ workloadToSettled: cpuDelta(workload, settled),
+ },
+ };
+};
diff --git a/scripts/benchmarks/docker-runtime-lib.spec.ts b/scripts/benchmarks/docker-runtime-lib.spec.ts
new file mode 100644
index 000000000..c497158af
--- /dev/null
+++ b/scripts/benchmarks/docker-runtime-lib.spec.ts
@@ -0,0 +1,427 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getBrowserNetworkIsolationLaunchArgs,
+ getSpotlightIdleMountFailure,
+ getSpotlightIdleNetworkFailure,
+ getTrpcRequestIdentities,
+ getUnrecoveredWidgetDataRequests,
+ getTrpcProcedures,
+ getRuntimeClaimIneligibleReasons,
+ getRuntimeMetricEligibility,
+ hasBlockingWidgetDataAborts,
+ normalizeDockerArchitecture,
+ isRuntimeWidgetDataProcedure,
+ isRuntimeWidgetDataRequest,
+ inspectTrpcBatchStreamResponse,
+ parseRuntimeMemorySnapshot,
+ recordWidgetDataRequestOutcome,
+ summarizeRuntimeMemory,
+} from "./docker-runtime-lib.mts";
+
+const snapshot = (current: number, peak: number, cpuUsageUsec = 1_000) => `memory_current=${current}
+memory_peak=${peak}
+cgroup_anon=100
+cgroup_file=20
+cpu_usage_usec=${cpuUsageUsec}
+cpu_user_usec=${cpuUsageUsec - 300}
+cpu_system_usec=300
+cpu_nr_throttled=2
+cpu_throttled_usec=50
+process=42|1|200|next-server|180
+process=7|1|10|redis-server|8
+redis_used_memory=30
+redis_used_memory_peak=40`;
+
+const trpcRequestUrl = (input: string) =>
+ `https://homarr.test/api/trpc/widget.weather.atLocation,app.byIds?batch=1&input=${input}`;
+
+describe("Docker runtime memory parsing", () => {
+ it("classifies every procedure in batched tRPC requests", () => {
+ expect(getTrpcProcedures("https://homarr.test/api/trpc/widget.foo,queryCache.setItem?batch=1")).toEqual([
+ "widget.foo",
+ "queryCache.setItem",
+ ]);
+ expect(getTrpcProcedures("https://homarr.test/api/trpc/widget.foo%2Csearch.query?batch=1")).toEqual([
+ "widget.foo",
+ "search.query",
+ ]);
+ expect(getTrpcProcedures("https://homarr.test/api/health/ready")).toEqual([]);
+ expect(isRuntimeWidgetDataProcedure("widget.calendar.findAllEvents")).toBe(true);
+ expect(isRuntimeWidgetDataProcedure("app.byIds")).toBe(true);
+ expect(isRuntimeWidgetDataProcedure("integration.byIds")).toBe(true);
+ expect(isRuntimeWidgetDataProcedure("docker.getContainers")).toBe(true);
+ expect(isRuntimeWidgetDataProcedure("widget.beszel.getSystemStats")).toBe(true);
+ expect(isRuntimeWidgetDataProcedure("widget.beszel.subscribeSystemStats")).toBe(false);
+ expect(isRuntimeWidgetDataProcedure("widget.app.ping")).toBe(false);
+ expect(isRuntimeWidgetDataProcedure("board.getBoardByName")).toBe(false);
+ expect(isRuntimeWidgetDataRequest("GET", "widget.weather.atLocation")).toBe(true);
+ expect(isRuntimeWidgetDataRequest("POST", "widget.options.saveItemOptions")).toBe(false);
+ });
+
+ it("normalizes Node's x64 architecture to Docker's amd64 name", () => {
+ expect(normalizeDockerArchitecture("x64")).toBe("amd64");
+ expect(normalizeDockerArchitecture("arm64")).toBe("arm64");
+ });
+
+ it("keys batched tRPC operations by procedure and canonical serialized input", () => {
+ const first = encodeURIComponent(
+ JSON.stringify({
+ 0: { json: { location: "Paris", units: "metric" } },
+ 1: { json: { ids: ["app-1"] } },
+ }),
+ );
+ const reordered = encodeURIComponent(
+ JSON.stringify({
+ 0: { json: { units: "metric", location: "Paris" } },
+ 1: { json: { ids: ["app-1"] } },
+ }),
+ );
+ const different = encodeURIComponent(
+ JSON.stringify({
+ 0: { json: { location: "Lyon", units: "metric" } },
+ 1: { json: { ids: ["app-1"] } },
+ }),
+ );
+ const identities = getTrpcRequestIdentities(trpcRequestUrl(first));
+ expect(identities.map(({ procedure }) => procedure)).toEqual(["widget.weather.atLocation", "app.byIds"]);
+ expect(identities.map(({ batchIndex }) => batchIndex)).toEqual([0, 1]);
+ expect(identities).toEqual(getTrpcRequestIdentities(trpcRequestUrl(reordered)));
+ expect(getTrpcRequestIdentities(trpcRequestUrl(different))[0]?.identity).not.toBe(identities[0]?.identity);
+ expect(getTrpcRequestIdentities(trpcRequestUrl(different))[1]?.identity).toBe(identities[1]?.identity);
+ expect(identities.every(({ identity }) => /^[^.]+(?:\.[^#]+)+#[a-f0-9]{64}$/.test(identity))).toBe(true);
+ expect(JSON.stringify(identities)).not.toContain("Paris");
+ expect(() =>
+ getTrpcRequestIdentities("https://homarr.test/api/trpc/widget.weather.atLocation?batch=1&input=%7Bmalformed"),
+ ).toThrow("Malformed serialized tRPC request input");
+ });
+
+ it("requires every aborted widget-data request identity to be recovered by an exact successful request", () => {
+ const recoveryBalance = new Map();
+ recordWidgetDataRequestOutcome(recoveryBalance, ["weather#paris", "app#1"], "aborted");
+ recordWidgetDataRequestOutcome(recoveryBalance, ["app#1", "weather#lyon"], "completed");
+
+ expect(getUnrecoveredWidgetDataRequests(recoveryBalance)).toEqual(["weather#paris"]);
+
+ recordWidgetDataRequestOutcome(recoveryBalance, ["weather#paris"], "completed");
+ expect(getUnrecoveredWidgetDataRequests(recoveryBalance)).toEqual([]);
+ });
+
+ it("credits an earlier successful response when a superseding refetch is aborted", () => {
+ const recoveryBalance = new Map();
+ recordWidgetDataRequestOutcome(recoveryBalance, ["widget.weather.atLocation"], "completed");
+ recordWidgetDataRequestOutcome(recoveryBalance, ["widget.weather.atLocation"], "aborted");
+
+ expect(getUnrecoveredWidgetDataRequests(recoveryBalance)).toEqual([]);
+ });
+
+ it("allows unrecovered aborts only for the non-claim login transition", () => {
+ expect(hasBlockingWidgetDataAborts(true, 2)).toBe(false);
+ expect(hasBlockingWidgetDataAborts(false, 0)).toBe(false);
+ expect(hasBlockingWidgetDataAborts(false, 1)).toBe(true);
+ });
+
+ it("allows eager baseline Spotlight work but keeps preload-only preparation network-free", () => {
+ expect(getSpotlightIdleNetworkFailure("mounted", 1)).toBeNull();
+ expect(getSpotlightIdleNetworkFailure("preload-only", 0)).toBeNull();
+ expect(getSpotlightIdleNetworkFailure("preload-only", 1)).toBe(
+ "Idle Spotlight preload-only preparation made 1 non-widget tRPC requests",
+ );
+ expect(getSpotlightIdleMountFailure("mounted", 1)).toBeNull();
+ expect(getSpotlightIdleMountFailure("preload-only", 0)).toBeNull();
+ expect(getSpotlightIdleMountFailure("preload-only", 1)).toBe(
+ "Idle Spotlight preload mounted the UI before user intent",
+ );
+ });
+
+ it("fails closed on HTTP, malformed, missing, and per-operation streamed tRPC errors", async () => {
+ const errorShape = {
+ code: -32603,
+ data: { code: "INTERNAL_SERVER_ERROR", httpStatus: 500 },
+ message: "boom",
+ };
+ const head = `${JSON.stringify({ json: { 0: [[0], [null, 0, 0]] } })}\n`;
+ const errorBody = `${head}${JSON.stringify({ json: [0, 0, [[{ error: errorShape }]]] })}\n`;
+ const rejectedBody = `${head}${JSON.stringify({ json: [0, 1, errorShape] })}\n`;
+ const rejectedDataBody = [
+ head.trimEnd(),
+ JSON.stringify({ json: [0, 0, [[{ result: 0 }], ["result", 0, 1]]] }),
+ JSON.stringify({ json: [1, 0, [[{ data: 0 }], ["data", 0, 2]]] }),
+ JSON.stringify({ json: [2, 1, errorShape] }),
+ "",
+ ].join("\n");
+ const successfulUserDataBody = [
+ head.trimEnd(),
+ JSON.stringify({ json: [0, 0, [[{ result: 0 }], ["result", 0, 1]]] }),
+ JSON.stringify({ json: [1, 0, [[{ data: 0 }], ["data", 0, 2]]] }),
+ JSON.stringify({ json: [2, 0, [[{ error: errorShape }]]] }),
+ "",
+ ].join("\n");
+ const mixedBody = [
+ JSON.stringify({ json: { 0: [[0], [null, 0, 0]], 1: [[0], [null, 0, 1]] } }),
+ JSON.stringify({ json: [0, 0, [[{ result: 0 }], ["result", 0, 2]]] }),
+ JSON.stringify({ json: [1, 0, [[{ error: errorShape }]]] }),
+ JSON.stringify({ json: [2, 0, [[{ data: "ok" }]]] }),
+ "",
+ ].join("\n");
+
+ await expect(inspectTrpcBatchStreamResponse(500, errorBody, 2)).resolves.toEqual({
+ failures: [
+ { batchIndex: 0, kind: "http-status", status: 500 },
+ { batchIndex: 1, kind: "http-status", status: 500 },
+ ],
+ successfulIndexes: [],
+ });
+ for (const body of ["", "not-json\n", "{}\n", head.trimEnd()]) {
+ await expect(inspectTrpcBatchStreamResponse(200, body, 1)).resolves.toEqual({
+ failures: [{ batchIndex: 0, kind: "invalid-jsonl" }],
+ successfulIndexes: [],
+ });
+ }
+ await expect(inspectTrpcBatchStreamResponse(200, head, 2)).resolves.toEqual({
+ failures: [
+ { batchIndex: 0, kind: "index-mismatch" },
+ { batchIndex: 1, kind: "index-mismatch" },
+ ],
+ successfulIndexes: [],
+ });
+ await expect(inspectTrpcBatchStreamResponse(200, errorBody, 1)).resolves.toEqual({
+ failures: [
+ {
+ batchIndex: 0,
+ httpStatus: 500,
+ jsonRpcCode: -32603,
+ kind: "trpc-error",
+ trpcCode: "INTERNAL_SERVER_ERROR",
+ },
+ ],
+ successfulIndexes: [],
+ });
+ for (const body of [rejectedBody, rejectedDataBody]) {
+ await expect(inspectTrpcBatchStreamResponse(200, body, 1)).resolves.toEqual({
+ failures: [{ batchIndex: 0, kind: "stream-error" }],
+ successfulIndexes: [],
+ });
+ }
+ await expect(inspectTrpcBatchStreamResponse(200, successfulUserDataBody, 1)).resolves.toEqual({
+ failures: [],
+ successfulIndexes: [0],
+ });
+ await expect(inspectTrpcBatchStreamResponse(200, mixedBody, 2)).resolves.toEqual({
+ failures: [
+ {
+ batchIndex: 1,
+ httpStatus: 500,
+ jsonRpcCode: -32603,
+ kind: "trpc-error",
+ trpcCode: "INTERNAL_SERVER_ERROR",
+ },
+ ],
+ successfulIndexes: [0],
+ });
+ });
+
+ it("isolates external browser traffic without disabling Chromium's HTTP cache", () => {
+ expect(getBrowserNetworkIsolationLaunchArgs("http://127.0.0.1:43123")).toEqual([
+ "--proxy-server=http://127.0.0.1:43123",
+ "--proxy-bypass-list=127.0.0.1;localhost",
+ "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1, EXCLUDE localhost",
+ ]);
+ expect(getBrowserNetworkIsolationLaunchArgs("http://127.0.0.1:43123").join(" ")).not.toContain("disable-cache");
+ });
+
+ it("only permits claims with native, SHA-pinned images and enough settle samples", () => {
+ expect(
+ getRuntimeClaimIneligibleReasons({
+ expectedSha: "abc123",
+ expectedSourceFingerprint: "source-hash",
+ browserNetworkIsolated: true,
+ serverNetworkIsolated: true,
+ imageArchitecture: "amd64",
+ lcpObservationMs: 5_000,
+ lcpSampleCount: 20,
+ nodeArchitecture: "x64",
+ pageSampleCount: 20,
+ requireImplementationMarkers: true,
+ revision: "abc123",
+ sourceFingerprint: "source-hash",
+ settleMs: 10 * 60 * 1_000,
+ settleSampleCount: 20,
+ widgetDataQuietMs: 500,
+ widgetDataSampleCount: 20,
+ }),
+ ).toEqual([]);
+
+ expect(
+ getRuntimeClaimIneligibleReasons({
+ expectedSha: "abc123",
+ expectedSourceFingerprint: "source-hash",
+ browserNetworkIsolated: false,
+ serverNetworkIsolated: false,
+ imageArchitecture: "arm64",
+ lcpObservationMs: 1_000,
+ lcpSampleCount: 1,
+ nodeArchitecture: "x64",
+ pageSampleCount: 1,
+ requireImplementationMarkers: false,
+ revision: null,
+ sourceFingerprint: null,
+ settleMs: 0,
+ settleSampleCount: 0,
+ widgetDataQuietMs: 100,
+ widgetDataSampleCount: 1,
+ }),
+ ).toEqual([
+ "image architecture arm64 is not host amd64",
+ "image revision label is missing",
+ "image source fingerprint label is missing",
+ "browser external network isolation is not active",
+ "server external network isolation is not active",
+ "widget implementation markers are not required",
+ "page sample count 1 is below 20",
+ "LCP observation 1000ms is below 5000ms",
+ "LCP sample count 1 is below 20",
+ "widget data quiet window 100ms is below 500ms",
+ "widget data sample count 1 is below 20",
+ "settle duration 0ms is below 600000ms",
+ "settle sample count 0 is below 20",
+ ]);
+ });
+
+ it("scopes repeatable, within-run, and single-sample metrics separately", () => {
+ const eligibility = getRuntimeMetricEligibility({
+ expectedSha: "abc123",
+ expectedSourceFingerprint: "source-hash",
+ browserNetworkIsolated: true,
+ serverNetworkIsolated: true,
+ imageArchitecture: "amd64",
+ lcpObservationMs: 5_000,
+ lcpSampleCount: 20,
+ nodeArchitecture: "x64",
+ pageSampleCount: 20,
+ requireImplementationMarkers: true,
+ revision: "abc123",
+ sourceFingerprint: "source-hash",
+ settleMs: 10 * 60 * 1_000,
+ settleSampleCount: 20,
+ widgetDataQuietMs: 500,
+ widgetDataSampleCount: 20,
+ });
+
+ expect(eligibility.pageLoads).toMatchObject({
+ claimEligible: true,
+ kind: "comparative",
+ requiredSampleCount: 20,
+ sampleCount: 20,
+ });
+ expect(eligibility.largestContentfulPaint).toMatchObject({
+ claimEligible: true,
+ kind: "comparative",
+ requiredSampleCount: 20,
+ sampleCount: 20,
+ });
+ expect(eligibility.widgetDataSettled).toMatchObject({
+ claimEligible: true,
+ kind: "comparative",
+ requiredSampleCount: 20,
+ sampleCount: 20,
+ });
+ expect(eligibility.settleStability).toMatchObject({
+ claimEligible: true,
+ kind: "within-run",
+ requiredSampleCount: 20,
+ sampleCount: 20,
+ });
+ expect(eligibility.settleStability.limitations).toContain("one container instance");
+ expect(eligibility.startup).toMatchObject({ claimEligible: false, kind: "diagnostic", sampleCount: 1 });
+ expect(eligibility.memoryLevels).toMatchObject({ claimEligible: false, kind: "diagnostic", sampleCount: 1 });
+ expect(eligibility.coldSpotlightInteraction).toMatchObject({
+ claimEligible: false,
+ kind: "diagnostic",
+ sampleCount: 1,
+ });
+ });
+
+ it("normalizes cgroup, process, and Redis values", () => {
+ expect(parseRuntimeMemorySnapshot("startup", snapshot(1_000, 2_000))).toMatchObject({
+ name: "startup",
+ container: { currentBytes: 1_000, peakBytes: 2_000, anonymousBytes: 100, fileBytes: 20 },
+ cpu: { usageUsec: 1_000, userUsec: 700, systemUsec: 300, nrThrottled: 2, throttledUsec: 50 },
+ processes: [
+ { pid: 42, parentPid: 1, rssBytes: 200 * 1024, pssBytes: 180 * 1024, command: "next-server" },
+ { pid: 7, parentPid: 1, rssBytes: 10 * 1024, pssBytes: 8 * 1024, command: "redis-server" },
+ ],
+ redis: { usedMemoryBytes: 30, peakMemoryBytes: 40 },
+ });
+ });
+
+ it("keeps a failed Redis collection absent instead of reporting zero bytes", () => {
+ const withoutRedis = snapshot(1_000, 2_000)
+ .split("\n")
+ .filter((line) => !line.startsWith("redis_"))
+ .join("\n");
+
+ expect(parseRuntimeMemorySnapshot("startup", withoutRedis).redis).toBeNull();
+ });
+
+ it("reports workload-to-settled growth and the largest sample", () => {
+ const checkpoints = [
+ parseRuntimeMemorySnapshot("startup", snapshot(1_000, 2_000)),
+ parseRuntimeMemorySnapshot("workload", snapshot(1_100, 2_500, 1_500)),
+ parseRuntimeMemorySnapshot("settle-1", snapshot(1_200, 2_500, 1_800)),
+ ];
+ checkpoints.forEach((checkpoint, index) => {
+ checkpoint.capturedAt = new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString();
+ });
+
+ expect(summarizeRuntimeMemory(checkpoints)).toEqual({
+ startupBytes: 1_000,
+ workloadBytes: 1_100,
+ settledBytes: 1_200,
+ maximumSampleBytes: 1_200,
+ peakBytes: 2_500,
+ growthBytes: 100,
+ growthPercent: 100 / 11,
+ settle: {
+ count: 1,
+ minBytes: 1_200,
+ medianBytes: 1_200,
+ p95Bytes: 1_200,
+ maxBytes: 1_200,
+ standardDeviationBytes: 0,
+ trendBytesPerHour: null,
+ anonymous: {
+ count: 1,
+ minBytes: 100,
+ medianBytes: 100,
+ p95Bytes: 100,
+ maxBytes: 100,
+ standardDeviationBytes: 0,
+ trendBytesPerHour: null,
+ },
+ file: {
+ count: 1,
+ minBytes: 20,
+ medianBytes: 20,
+ p95Bytes: 20,
+ maxBytes: 20,
+ standardDeviationBytes: 0,
+ trendBytesPerHour: null,
+ },
+ },
+ cpu: {
+ startupToWorkload: { usageUsec: 500, userUsec: 500, systemUsec: 0, nrThrottled: 0, throttledUsec: 0 },
+ workloadToSettled: { usageUsec: 300, userUsec: 300, systemUsec: 0, nrThrottled: 0, throttledUsec: 0 },
+ },
+ });
+ });
+
+ it("derives memory trends from observed checkpoint timestamps", () => {
+ const first = parseRuntimeMemorySnapshot("settle-1", snapshot(1_000, 2_000));
+ const second = parseRuntimeMemorySnapshot("settle-2", snapshot(1_100, 2_000));
+ first.capturedAt = "2026-01-01T00:00:00.000Z";
+ second.capturedAt = "2026-01-01T02:00:00.000Z";
+
+ expect(summarizeRuntimeMemory([first, second]).settle?.trendBytesPerHour).toBe(50);
+ });
+});
diff --git a/scripts/benchmarks/docker-runtime.mts b/scripts/benchmarks/docker-runtime.mts
new file mode 100644
index 000000000..b3500e70c
--- /dev/null
+++ b/scripts/benchmarks/docker-runtime.mts
@@ -0,0 +1,1656 @@
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { createServer } from "node:http";
+import type { AddressInfo } from "node:net";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { chromium } from "@playwright/test";
+import type { BrowserContext, BrowserContextOptions, Page, Request } from "@playwright/test";
+
+import {
+ getBrowserNetworkIsolationLaunchArgs,
+ getSpotlightIdleMountFailure,
+ getSpotlightIdleNetworkFailure,
+ getRuntimeClaimIneligibleReasons,
+ getRuntimeMetricEligibility,
+ getTrpcProcedures,
+ getTrpcRequestIdentities,
+ getUnrecoveredWidgetDataRequests,
+ hasBlockingWidgetDataAborts,
+ inspectTrpcBatchStreamResponse,
+ isRuntimeWidgetDataRequest,
+ parseRuntimeMemorySnapshot,
+ recordWidgetDataRequestOutcome,
+ summarizeRuntimeMemory,
+} from "./docker-runtime-lib.mts";
+import type { RuntimeMemoryCheckpoint, TrpcBatchInspection, TrpcRequestIdentity } from "./docker-runtime-lib.mts";
+import { summarize } from "./dev-benchmark-lib.mts";
+
+const execFileAsync = promisify(execFile);
+const image = process.env.RUNTIME_BENCHMARK_IMAGE;
+if (!image) throw new Error("RUNTIME_BENCHMARK_IMAGE is required");
+
+const expectedSha = process.env.RUNTIME_BENCHMARK_SHA?.trim() || null;
+const expectedSourceFingerprint = process.env.RUNTIME_BENCHMARK_SOURCE_FINGERPRINT?.trim() || null;
+const settleMs = Number(process.env.RUNTIME_BENCHMARK_SETTLE_MS ?? 10 * 60 * 1_000);
+const sampleIntervalMs = Number(process.env.RUNTIME_BENCHMARK_SAMPLE_INTERVAL_MS ?? 30_000);
+const lcpObservationMs = Number(process.env.RUNTIME_BENCHMARK_LCP_OBSERVATION_MS ?? 5_000);
+const lcpStabilityMs = 500;
+const widgetDataQuietMs = Number(process.env.RUNTIME_BENCHMARK_WIDGET_DATA_QUIET_MS ?? 500);
+const spotlightIdleMs = Number(process.env.RUNTIME_BENCHMARK_SPOTLIGHT_IDLE_MS ?? 2_500);
+const spotlightIdlePolicy = process.env.RUNTIME_BENCHMARK_SPOTLIGHT_IDLE_POLICY;
+const interactionIterations = Number(process.env.RUNTIME_BENCHMARK_INTERACTION_ITERATIONS ?? 7);
+const pageIterations = Number(process.env.RUNTIME_BENCHMARK_PAGE_ITERATIONS ?? 20);
+const readyMarkerPolicy = process.env.RUNTIME_BENCHMARK_REQUIRE_READY_MARKERS;
+const requireImplementationMarkers = readyMarkerPolicy === "true";
+const outputDirectory = path.resolve(process.env.RUNTIME_BENCHMARK_OUTPUT_DIR ?? "benchmark-results/docker-runtime");
+const routePaths = (
+ process.env.RUNTIME_BENCHMARK_ROUTES ?? "/manage,/manage/apps,/manage/integrations,/manage/settings"
+)
+ .split(",")
+ .filter(Boolean);
+const viewport = { height: 720, width: 1280 } as const;
+
+const safeUrlDetails = (rawUrl: string) => {
+ const parsed = new URL(rawUrl);
+ return {
+ origin: parsed.origin,
+ pathname: parsed.pathname,
+ protocol: parsed.protocol,
+ urlHash: createHash("sha256").update(parsed.href).digest("hex"),
+ };
+};
+
+if (!Number.isFinite(settleMs) || settleMs < 0) throw new Error("RUNTIME_BENCHMARK_SETTLE_MS must be non-negative");
+if (!Number.isFinite(sampleIntervalMs) || sampleIntervalMs <= 0) {
+ throw new Error("RUNTIME_BENCHMARK_SAMPLE_INTERVAL_MS must be positive");
+}
+if (!Number.isFinite(lcpObservationMs) || lcpObservationMs < 1_000) {
+ throw new Error("RUNTIME_BENCHMARK_LCP_OBSERVATION_MS must be at least 1000");
+}
+if (!Number.isFinite(spotlightIdleMs) || spotlightIdleMs < 0) {
+ throw new Error("RUNTIME_BENCHMARK_SPOTLIGHT_IDLE_MS must be non-negative");
+}
+if (spotlightIdlePolicy !== "mounted" && spotlightIdlePolicy !== "preload-only") {
+ throw new Error('RUNTIME_BENCHMARK_SPOTLIGHT_IDLE_POLICY must be "mounted" or "preload-only"');
+}
+if (!Number.isFinite(widgetDataQuietMs) || widgetDataQuietMs < 0) {
+ throw new Error("RUNTIME_BENCHMARK_WIDGET_DATA_QUIET_MS must be non-negative");
+}
+if (!Number.isInteger(interactionIterations) || interactionIterations < 7) {
+ throw new Error("RUNTIME_BENCHMARK_INTERACTION_ITERATIONS must be an integer of at least 7");
+}
+if (!Number.isInteger(pageIterations) || pageIterations < 1) {
+ throw new Error("RUNTIME_BENCHMARK_PAGE_ITERATIONS must be a positive integer");
+}
+if (readyMarkerPolicy !== "true" && readyMarkerPolicy !== "false") {
+ throw new Error("RUNTIME_BENCHMARK_REQUIRE_READY_MARKERS must be true or false");
+}
+
+const startExternalDenyProxyAsync = async () => {
+ const attempts = new Map();
+ const recordAttempt = (method: string, target: string) => {
+ let origin: string | null = null;
+ try {
+ origin = new URL(method === "CONNECT" ? `https://${target}` : target).origin;
+ } catch {
+ // Keep the raw target when Chromium sends a non-URL proxy request.
+ }
+ const key = `${method} ${target}`;
+ const existing = attempts.get(key);
+ attempts.set(key, { count: (existing?.count ?? 0) + 1, method, origin, target });
+ };
+ const server = createServer((request, response) => {
+ recordAttempt(request.method ?? "GET", request.url ?? "");
+ response.writeHead(502, {
+ "cache-control": "no-store",
+ connection: "close",
+ "x-homarr-benchmark-blocked": "true",
+ });
+ response.end("External network disabled by Homarr runtime benchmark\n");
+ });
+ server.on("connect", (request, socket) => {
+ recordAttempt("CONNECT", request.url ?? "");
+ socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
+ });
+ server.on("upgrade", (request, socket) => {
+ const rawTarget = request.url ?? "";
+ const target = /^wss?:\/\//.test(rawTarget) ? rawTarget : `ws://${request.headers.host ?? "unknown"}${rawTarget}`;
+ recordAttempt("UPGRADE", target);
+ socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nX-Homarr-Benchmark-Blocked: true\r\n\r\n");
+ });
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ server.off("error", reject);
+ resolve();
+ });
+ });
+ const address = server.address() as AddressInfo;
+ return {
+ closeAsync: async () => {
+ await new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()));
+ });
+ },
+ getAttempts: () => [...attempts.values()].toSorted((left, right) => left.target.localeCompare(right.target)),
+ host: address.address,
+ port: address.port,
+ url: `http://127.0.0.1:${address.port}`,
+ };
+};
+
+const memoryScript = String.raw`
+echo "memory_current=$(cat /sys/fs/cgroup/memory.current)"
+memory_peak="$(cat /sys/fs/cgroup/memory.peak 2>/dev/null || true)"
+case "$memory_peak" in ''|'max') ;; *) echo "memory_peak=$memory_peak" ;; esac
+awk '/^anon / { print "cgroup_anon=" $2 } /^file / { print "cgroup_file=" $2 }' /sys/fs/cgroup/memory.stat
+awk '/^usage_usec / { print "cpu_usage_usec=" $2 } /^user_usec / { print "cpu_user_usec=" $2 } /^system_usec / { print "cpu_system_usec=" $2 } /^nr_throttled / { print "cpu_nr_throttled=" $2 } /^throttled_usec / { print "cpu_throttled_usec=" $2 }' /sys/fs/cgroup/cpu.stat
+for status_file in /proc/[0-9]*/status; do
+ process_dir="\${status_file%/status}"
+ process_values="$(awk '/^Name:/ { name=$2 } /^Pid:/ { pid=$2 } /^PPid:/ { ppid=$2 } /^VmRSS:/ { rss=$2 } END { print pid "|" ppid "|" rss "|" name }' "$status_file")"
+ process_pss="$(awk '/^Pss:/ { print $2 }' "$process_dir/smaps_rollup" 2>/dev/null || echo 0)"
+ echo "process=$process_values|$process_pss"
+done
+redis_info="$(redis-cli INFO memory 2>/dev/null | tr -d '\r' || true)"
+redis_used="$(printf '%s\n' "$redis_info" | awk -F: '/^used_memory:/ { print $2 }')"
+redis_peak="$(printf '%s\n' "$redis_info" | awk -F: '/^used_memory_peak:/ { print $2 }')"
+if [ -n "$redis_used" ] && [ -n "$redis_peak" ]; then
+ echo "redis_used_memory=$redis_used"
+ echo "redis_used_memory_peak=$redis_peak"
+fi
+`;
+
+const waitForReadyAsync = async (baseUrl: string) => {
+ const deadline = Date.now() + 120_000;
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(`${baseUrl}/api/health/ready`);
+ if (response.ok) return;
+ } catch {
+ // The socket is not listening yet.
+ }
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
+ }
+ throw new Error(`Container did not become ready at ${baseUrl}`);
+};
+
+const captureCheckpointAsync = async (containerId: string, name: string): Promise => {
+ const { stdout } = await execFileAsync("docker", ["exec", containerId, "sh", "-c", memoryScript], {
+ maxBuffer: 4 * 1024 * 1024,
+ });
+ await writeFile(path.join(outputDirectory, `${name}.txt`), stdout);
+ return parseRuntimeMemorySnapshot(name, stdout);
+};
+
+const waitForBoardWidgetsAsync = async (page: Page) => {
+ const boardSelector = "[data-homarr-dev-benchmark-board]";
+ await page.locator(boardSelector).waitFor({ state: "visible", timeout: 30_000 });
+ await page.waitForFunction((selector) => {
+ const board = document.querySelector(selector);
+ const items = [...(board?.querySelectorAll('[data-type="item"]') ?? [])];
+ return items.length > 0 && items.every((item) => item.querySelector(".grid-stack-item-content") !== null);
+ }, boardSelector);
+
+ if (requireImplementationMarkers) {
+ await page.waitForFunction((selector) => {
+ const board = document.querySelector(selector);
+ const items = [...(board?.querySelectorAll('[data-type="item"]') ?? [])];
+ return (
+ items.length > 0 &&
+ items.every(
+ (item) =>
+ item.querySelector("[data-homarr-widget-ready]") !== null ||
+ item.querySelector('[data-homarr-widget-error], a[href*="/manage/tools/logs"]') !== null,
+ )
+ );
+ }, boardSelector);
+ } else {
+ await page.waitForLoadState("networkidle");
+ }
+ await page.evaluate(
+ () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
+ );
+ return await readBoardWidgetStateAsync(page);
+};
+
+const waitForSpotlightIdlePreparationAsync = async (page: Page) => {
+ const startedAt = performance.now();
+ const marker =
+ spotlightIdlePolicy === "preload-only"
+ ? "[data-homarr-dev-benchmark-spotlight-preloaded]"
+ : "[data-homarr-dev-benchmark-spotlight-mounted]";
+ await page.locator(marker).waitFor({
+ state: "attached",
+ timeout: Math.max(30_000, spotlightIdleMs + 5_000),
+ });
+ const remainingIdleMs = Math.max(0, spotlightIdleMs - (performance.now() - startedAt));
+ if (remainingIdleMs > 0) await page.waitForTimeout(remainingIdleMs);
+ await page.evaluate(
+ () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
+ );
+ await page.waitForTimeout(100);
+ const mountFailure = getSpotlightIdleMountFailure(
+ spotlightIdlePolicy,
+ await page.locator("[data-homarr-dev-benchmark-spotlight-mounted]").count(),
+ );
+ if (mountFailure) throw new Error(mountFailure);
+};
+
+const readBoardWidgetStateAsync = async (page: Page) =>
+ await page.evaluate((selector) => {
+ const board = document.querySelector(selector);
+ return {
+ widgetCount: board?.querySelectorAll('[data-type="item"]').length ?? 0,
+ implementationMarkerCount: board?.querySelectorAll("[data-homarr-widget-ready]").length ?? 0,
+ widgetErrorCount:
+ board?.querySelectorAll('[data-homarr-widget-error], a[href*="/manage/tools/logs"]').length ?? 0,
+ };
+ }, "[data-homarr-dev-benchmark-board]");
+
+const assertBoardWidgetState = (state: Awaited>, scope: string) => {
+ if (requireImplementationMarkers && state.implementationMarkerCount !== state.widgetCount) {
+ throw new Error(`${scope} has incomplete widget implementation markers`);
+ }
+ if (state.widgetErrorCount > 0) {
+ throw new Error(`${scope} rendered ${state.widgetErrorCount} widget errors`);
+ }
+};
+
+type WidgetDataRequestTracker = {
+ activeRequests: Map;
+ completedOperationCount: number;
+ failedRequests: string[];
+ lastActivityAtMs: number;
+ observedOperationCount: number;
+ recoveryBalance: Map;
+};
+
+const trackWidgetDataRequests = (page: Page): WidgetDataRequestTracker => {
+ const tracker: WidgetDataRequestTracker = {
+ activeRequests: new Map(),
+ completedOperationCount: 0,
+ failedRequests: [],
+ lastActivityAtMs: performance.now(),
+ observedOperationCount: 0,
+ recoveryBalance: new Map(),
+ };
+ page.on("request", (request) => {
+ if (request.method() !== "GET") return;
+ try {
+ const operations = getTrpcRequestIdentities(request.url());
+ const widgetOperations = operations.filter(({ procedure }) =>
+ isRuntimeWidgetDataRequest(request.method(), procedure),
+ );
+ if (widgetOperations.length === 0) return;
+ tracker.activeRequests.set(request, { operationCount: operations.length, widgetOperations });
+ tracker.observedOperationCount += widgetOperations.length;
+ tracker.lastActivityAtMs = performance.now();
+ } catch {
+ if (getTrpcProcedures(request.url()).length === 0) return;
+ tracker.failedRequests.push("malformed serialized tRPC request input");
+ tracker.lastActivityAtMs = performance.now();
+ }
+ });
+ const takeRequest = (request: Request) => {
+ const trackedRequest = tracker.activeRequests.get(request);
+ if (!trackedRequest) return null;
+ tracker.activeRequests.delete(request);
+ tracker.lastActivityAtMs = performance.now();
+ return trackedRequest;
+ };
+ const settleTransportFailure = (request: Request, failure: string) => {
+ const trackedRequest = takeRequest(request);
+ if (!trackedRequest) return;
+ const identities = trackedRequest.widgetOperations.map(({ identity }) => identity);
+ if (failure === "net::ERR_ABORTED") {
+ recordWidgetDataRequestOutcome(tracker.recoveryBalance, identities, "aborted");
+ } else {
+ tracker.failedRequests.push(`transport failure ${failure}: ${identities.join(",")}`);
+ }
+ };
+ const settleInspection = (request: Request, inspection: TrpcBatchInspection) => {
+ const trackedRequest = takeRequest(request);
+ if (!trackedRequest) return;
+ const failuresByIndex = new Map(inspection.failures.map((failure) => [failure.batchIndex, failure]));
+ const successfulIndexes = new Set(inspection.successfulIndexes);
+ for (const operation of trackedRequest.widgetOperations) {
+ const failure = failuresByIndex.get(operation.batchIndex);
+ if (failure) {
+ tracker.failedRequests.push(`${JSON.stringify(failure)}: ${operation.identity}`);
+ } else if (successfulIndexes.has(operation.batchIndex)) {
+ tracker.completedOperationCount += 1;
+ recordWidgetDataRequestOutcome(tracker.recoveryBalance, [operation.identity], "completed");
+ } else {
+ tracker.failedRequests.push(`missing tRPC response outcome: ${operation.identity}`);
+ }
+ }
+ };
+ page.on("requestfinished", (request) => {
+ const trackedRequest = tracker.activeRequests.get(request);
+ if (!trackedRequest) return;
+ void (async () => {
+ try {
+ const response = await request.response();
+ if (!response) {
+ settleTransportFailure(request, "missing HTTP response");
+ return;
+ }
+ settleInspection(
+ request,
+ await inspectTrpcBatchStreamResponse(response.status(), await response.text(), trackedRequest.operationCount),
+ );
+ } catch {
+ settleTransportFailure(request, "unable to inspect response");
+ }
+ })();
+ });
+ page.on("requestfailed", (request) =>
+ settleTransportFailure(request, request.failure()?.errorText ?? "request failed"),
+ );
+ return tracker;
+};
+
+const waitForWidgetDataSettledAsync = async (
+ page: Page,
+ tracker: WidgetDataRequestTracker,
+ { allowUnrecoveredAborts = false } = {},
+) => {
+ const deadline = Date.now() + 30_000;
+ while (Date.now() < deadline) {
+ if (tracker.failedRequests.length > 0) {
+ throw new Error(`Widget data requests failed: ${tracker.failedRequests.join("; ")}`);
+ }
+ const unrecoveredRequests = getUnrecoveredWidgetDataRequests(tracker.recoveryBalance);
+ const remainingQuietMs = widgetDataQuietMs - (performance.now() - tracker.lastActivityAtMs);
+ if (
+ tracker.observedOperationCount > 0 &&
+ tracker.activeRequests.size === 0 &&
+ !hasBlockingWidgetDataAborts(allowUnrecoveredAborts, unrecoveredRequests.length) &&
+ remainingQuietMs <= 0
+ ) {
+ const lastActivityAtMs = tracker.lastActivityAtMs;
+ await page.evaluate(
+ () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
+ );
+ if (tracker.activeRequests.size === 0 && tracker.lastActivityAtMs === lastActivityAtMs) {
+ return {
+ completedOperationCount: tracker.completedOperationCount,
+ observedOperationCount: tracker.observedOperationCount,
+ quietWindowObserved: true,
+ unrecoveredAbortedRequests: unrecoveredRequests,
+ };
+ }
+ }
+ await new Promise((resolve) => setTimeout(resolve, Math.max(10, Math.min(50, remainingQuietMs))));
+ }
+ const unrecoveredRequests = getUnrecoveredWidgetDataRequests(tracker.recoveryBalance);
+ throw new Error(
+ `Widget data did not settle: observed=${tracker.observedOperationCount} active=${tracker.activeRequests.size} unrecoveredAborts=${unrecoveredRequests.join(",") || "none"}`,
+ );
+};
+
+const deferIdleCallbacksForInitialMeasurement = () => {
+ const originalRequest = window.requestIdleCallback?.bind(window);
+ const originalCancel = window.cancelIdleCallback?.bind(window);
+ const queued = new Map();
+ let nextId = 1;
+ Object.defineProperty(window, "requestIdleCallback", {
+ configurable: true,
+ value: (callback: IdleRequestCallback, options?: IdleRequestOptions) => {
+ const id = nextId++;
+ queued.set(id, { callback, options });
+ return id;
+ },
+ });
+ Object.defineProperty(window, "cancelIdleCallback", {
+ configurable: true,
+ value: (id: number) => {
+ queued.delete(id);
+ },
+ });
+ Object.defineProperty(window, "homarrReleaseBenchmarkIdleCallbacks", {
+ configurable: true,
+ value: () => {
+ if (originalRequest)
+ Object.defineProperty(window, "requestIdleCallback", { configurable: true, value: originalRequest });
+ if (originalCancel)
+ Object.defineProperty(window, "cancelIdleCallback", { configurable: true, value: originalCancel });
+ for (const { callback, options } of queued.values()) {
+ if (originalRequest) originalRequest(callback, options);
+ else window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 0);
+ }
+ queued.clear();
+ },
+ });
+};
+
+const initializeBrowserVitals = () => {
+ const state = {
+ cls: 0,
+ lcpElement: null as null | {
+ className: string | null;
+ id: string | null;
+ origin: string | null;
+ pathname: string | null;
+ size: number | null;
+ tagName: string | null;
+ text: string | null;
+ },
+ lcpMs: null as number | null,
+ lcpFinalizedByInputMs: null as number | null,
+ lcpUpdatedAtMs: null as number | null,
+ longTaskCount: 0,
+ longTaskTotalMs: 0,
+ };
+ Object.defineProperty(window, "homarrBenchmarkVitals", { value: state, configurable: true });
+ window.addEventListener(
+ "keydown",
+ (event) => {
+ if (event.isTrusted && state.lcpFinalizedByInputMs === null) state.lcpFinalizedByInputMs = performance.now();
+ },
+ { capture: true },
+ );
+ if (PerformanceObserver.supportedEntryTypes.includes("largest-contentful-paint")) {
+ new PerformanceObserver((list) => {
+ const entry = list.getEntries().at(-1);
+ if (!entry) return;
+ const lcp = entry as PerformanceEntry & { element?: Element; size?: number; url?: string };
+ const element = lcp.element;
+ state.lcpMs = entry.startTime;
+ state.lcpUpdatedAtMs = performance.now();
+ state.lcpElement = {
+ className: element?.getAttribute("class") ?? null,
+ id: element?.id || null,
+ origin: lcp.url ? new URL(lcp.url, window.location.href).origin : null,
+ pathname: lcp.url ? new URL(lcp.url, window.location.href).pathname : null,
+ size: lcp.size ?? null,
+ tagName: element?.tagName ?? null,
+ text: element?.textContent?.trim().slice(0, 120) || null,
+ };
+ }).observe({ type: "largest-contentful-paint", buffered: true });
+ }
+ if (PerformanceObserver.supportedEntryTypes.includes("layout-shift")) {
+ new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ const shift = entry as PerformanceEntry & { hadRecentInput?: boolean; value?: number };
+ if (!shift.hadRecentInput) state.cls += shift.value ?? 0;
+ }
+ }).observe({ type: "layout-shift", buffered: true });
+ }
+ if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
+ new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ state.longTaskCount += 1;
+ state.longTaskTotalMs += entry.duration;
+ }
+ }).observe({ type: "longtask", buffered: true });
+ }
+};
+
+const installMeasurementScriptsAsync = async (context: BrowserContext, options?: { deferIdle?: boolean }) => {
+ if (options?.deferIdle) await context.addInitScript(deferIdleCallbacksForInitialMeasurement);
+ await context.addInitScript(initializeBrowserVitals);
+};
+
+const waitForFinalLcpAsync = async (page: Page) => {
+ await page.waitForFunction(
+ () =>
+ typeof (window as Window & { homarrBenchmarkVitals?: { lcpMs: number | null } }).homarrBenchmarkVitals?.lcpMs ===
+ "number",
+ undefined,
+ { timeout: Math.max(10_000, lcpObservationMs + 5_000) },
+ );
+ while (true) {
+ const { elapsedMs, lastCandidateAtMs } = await page.evaluate(() => {
+ const vitals = (window as Window & { homarrBenchmarkVitals?: { lcpUpdatedAtMs: number | null } })
+ .homarrBenchmarkVitals;
+ return { elapsedMs: performance.now(), lastCandidateAtMs: vitals?.lcpUpdatedAtMs ?? null };
+ });
+ if (lastCandidateAtMs === null) throw new Error("LCP candidate timestamp is missing");
+ const remainingMs = Math.max(0, lcpObservationMs - elapsedMs, lcpStabilityMs - (elapsedMs - lastCandidateAtMs));
+ if (remainingMs === 0) break;
+ await page.waitForTimeout(remainingMs);
+ }
+
+ // LCP remains open to later candidates until the first trusted input. End
+ // the declared observation window with an inert key so the recorded value
+ // is final, while leaving application state and layout untouched.
+ await page.keyboard.press("Shift");
+ await page.waitForFunction(
+ () =>
+ typeof (window as Window & { homarrBenchmarkVitals?: { lcpFinalizedByInputMs: number | null } })
+ .homarrBenchmarkVitals?.lcpFinalizedByInputMs === "number",
+ );
+ await page.waitForTimeout(100);
+};
+
+const readBrowserMetricsAsync = async (page: Page) =>
+ await page.evaluate(() => {
+ const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
+ const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
+ const firstContentfulPaint = performance
+ .getEntriesByType("paint")
+ .find(({ name }) => name === "first-contentful-paint");
+ const vitals = (
+ window as Window & {
+ homarrBenchmarkVitals?: {
+ cls: number;
+ lcpElement: {
+ className: string | null;
+ id: string | null;
+ origin: string | null;
+ pathname: string | null;
+ size: number | null;
+ tagName: string | null;
+ text: string | null;
+ } | null;
+ lcpMs: number | null;
+ lcpFinalizedByInputMs: number | null;
+ lcpUpdatedAtMs: number | null;
+ longTaskCount: number;
+ longTaskTotalMs: number;
+ };
+ }
+ ).homarrBenchmarkVitals;
+ const memory = performance as Performance & { memory?: { usedJSHeapSize: number } };
+ return {
+ cls: Number((vitals?.cls ?? 0).toFixed(4)),
+ decodedScriptBytes: resources
+ .filter((resource) => resource.initiatorType === "script")
+ .reduce((sum, resource) => sum + resource.decodedBodySize, 0),
+ domContentLoadedMs: navigation ? Math.round(navigation.domContentLoadedEventEnd) : null,
+ fcpMs: firstContentfulPaint ? Math.round(firstContentfulPaint.startTime) : null,
+ jsHeapBytes: memory.memory?.usedJSHeapSize ?? null,
+ lcpElement: vitals?.lcpElement ?? null,
+ lcpFinalizedByInputMs:
+ vitals?.lcpFinalizedByInputMs === null || vitals?.lcpFinalizedByInputMs === undefined
+ ? null
+ : Math.round(vitals.lcpFinalizedByInputMs),
+ lcpMs: vitals?.lcpMs === null || vitals?.lcpMs === undefined ? null : Math.round(vitals.lcpMs),
+ lcpUpdatedAtMs:
+ vitals?.lcpUpdatedAtMs === null || vitals?.lcpUpdatedAtMs === undefined
+ ? null
+ : Math.round(vitals.lcpUpdatedAtMs),
+ loadMs: navigation?.loadEventEnd ? Math.round(navigation.loadEventEnd) : null,
+ longTaskCount: vitals?.longTaskCount ?? 0,
+ longTaskTotalMs: Math.round(vitals?.longTaskTotalMs ?? 0),
+ largestScripts: resources
+ .filter((resource) => resource.initiatorType === "script")
+ .toSorted((left, right) => right.decodedBodySize - left.decodedBodySize)
+ .slice(0, 10)
+ .map(({ decodedBodySize, name, transferSize }) => {
+ const url = new URL(name, window.location.href);
+ return { decodedBodySize, origin: url.origin, pathname: url.pathname, transferSize };
+ }),
+ resourceCount: resources.length,
+ slowestResources: resources
+ .toSorted((left, right) => right.duration - left.duration)
+ .slice(0, 10)
+ .map(({ duration, initiatorType, name, transferSize }) => {
+ const url = new URL(name, window.location.href);
+ return {
+ durationMs: Math.round(duration),
+ initiatorType,
+ origin: url.origin,
+ pathname: url.pathname,
+ transferSize,
+ };
+ }),
+ ttfbMs: navigation ? Math.round(navigation.responseStart) : null,
+ transferBytes: resources.reduce((sum, resource) => sum + resource.transferSize, 0),
+ };
+ });
+
+await mkdir(outputDirectory, { recursive: true });
+const appDataDirectory = await mkdtemp(path.join(os.tmpdir(), "homarr-runtime-benchmark-"));
+const containerName = `homarr-runtime-benchmark-${process.pid}`;
+const proxyContainerName = `${containerName}-proxy`;
+const isolationNetworkName = `${containerName}-internal`;
+let containerId: string | undefined;
+let proxyContainerId: string | undefined;
+let isolationNetworkId: string | undefined;
+let browser: Awaited> | undefined;
+let denyProxy: Awaited> | undefined;
+let cleanupPromise: Promise | undefined;
+let lifecycleMutation: ReturnType | undefined;
+let shutdownRequested = false;
+
+const execDockerLifecycleAsync = async (args: string[]) => {
+ const mutation = execFileAsync("docker", args);
+ lifecycleMutation = mutation;
+ try {
+ const result = await mutation;
+ if (shutdownRequested) throw new Error("Runtime benchmark interrupted");
+ return result;
+ } finally {
+ if (lifecycleMutation === mutation) lifecycleMutation = undefined;
+ }
+};
+
+const cleanupAsync = () => {
+ cleanupPromise ??= (async () => {
+ const activeLifecycleMutation = lifecycleMutation;
+ if (activeLifecycleMutation) await activeLifecycleMutation.catch(() => undefined);
+ await browser?.close().catch(() => undefined);
+ await denyProxy?.closeAsync().catch(() => undefined);
+ await execFileAsync("docker", ["rm", "--force", proxyContainerName]).catch(() => undefined);
+ await execFileAsync("docker", ["rm", "--force", containerName]).catch(() => undefined);
+ await execFileAsync("docker", ["network", "rm", isolationNetworkName]).catch(() => undefined);
+ await rm(appDataDirectory, { recursive: true, force: true }).catch(() => undefined);
+ })();
+ return cleanupPromise;
+};
+
+const handleSignal = (signal: "SIGINT" | "SIGTERM") => {
+ shutdownRequested = true;
+ console.error(`Received ${signal}; cleaning up runtime benchmark resources`);
+ void cleanupAsync().finally(() => process.exit(signal === "SIGINT" ? 130 : 143));
+};
+const handleSigint = () => handleSignal("SIGINT");
+const handleSigterm = () => handleSignal("SIGTERM");
+process.once("SIGINT", handleSigint);
+process.once("SIGTERM", handleSigterm);
+
+try {
+ const imageInspect = JSON.parse((await execFileAsync("docker", ["image", "inspect", image])).stdout)[0] as {
+ Architecture: string;
+ Config?: { Labels?: Record };
+ Id: string;
+ RepoDigests?: string[];
+ Size: number;
+ };
+ const revision = imageInspect.Config?.Labels?.["org.opencontainers.image.revision"] ?? null;
+ const sourceFingerprint = imageInspect.Config?.Labels?.["dev.homarr.source-fingerprint"] ?? null;
+ const dockerVersion = JSON.parse(
+ (await execFileAsync("docker", ["version", "--format", "{{json .}}"])).stdout,
+ ) as Record;
+ const dockerInfo = JSON.parse((await execFileAsync("docker", ["info", "--format", "{{json .}}"])).stdout) as {
+ Architecture?: string;
+ CgroupDriver?: string;
+ CgroupVersion?: string;
+ Driver?: string;
+ KernelVersion?: string;
+ MemTotal?: number;
+ NCPU?: number;
+ OSType?: string;
+ OperatingSystem?: string;
+ ServerVersion?: string;
+ };
+
+ const containerStartedAt = performance.now();
+ isolationNetworkId = (
+ await execDockerLifecycleAsync(["network", "create", "--driver", "bridge", "--internal", isolationNetworkName])
+ ).stdout.trim();
+ const runResult = await execDockerLifecycleAsync([
+ "run",
+ "--detach",
+ "--rm",
+ "--name",
+ containerName,
+ "--cpus",
+ "2",
+ "--memory",
+ "1g",
+ "--network",
+ isolationNetworkName,
+ "--volume",
+ `${appDataDirectory}:/appdata`,
+ "--env",
+ "DEMO_MODE=true",
+ "--env",
+ "DEMO_READ_ONLY=false",
+ "--env",
+ "UNSAFE_ENABLE_MOCK_INTEGRATION=true",
+ "--env",
+ "NO_EXTERNAL_CONNECTION=true",
+ "--env",
+ "LOG_LEVEL=warn",
+ "--env",
+ `SECRET_ENCRYPTION_KEY=${"0".repeat(64)}`,
+ imageInspect.Id,
+ ]);
+ containerId = runResult.stdout.trim();
+ proxyContainerId = (
+ await execDockerLifecycleAsync([
+ "run",
+ "--detach",
+ "--rm",
+ "--name",
+ proxyContainerName,
+ "--publish",
+ "127.0.0.1::7575",
+ "--env",
+ `UPSTREAM_HOST=${containerName}`,
+ "--entrypoint",
+ "node",
+ imageInspect.Id,
+ "-e",
+ `const net = require("node:net");
+net.createServer((client) => {
+ const upstream = net.connect(7575, process.env.UPSTREAM_HOST);
+ client.pipe(upstream).pipe(client);
+ const close = () => { client.destroy(); upstream.destroy(); };
+ client.on("error", close);
+ upstream.on("error", close);
+}).listen(7575, "0.0.0.0");`,
+ ])
+ ).stdout.trim();
+ await execDockerLifecycleAsync(["network", "connect", isolationNetworkName, proxyContainerId]);
+ const portOutput = (await execFileAsync("docker", ["port", proxyContainerId, "7575/tcp"])).stdout;
+ const port = Number(portOutput.trim().match(/:(\d+)$/)?.[1]);
+ if (!Number.isInteger(port)) throw new Error(`Unable to resolve mapped port from ${portOutput}`);
+ const baseUrl = `http://127.0.0.1:${port}`;
+ await waitForReadyAsync(baseUrl);
+ const startToReadyMs = Math.round(performance.now() - containerStartedAt);
+ const containerNode = JSON.parse(
+ (
+ await execFileAsync("docker", [
+ "exec",
+ containerId,
+ "node",
+ "-p",
+ "JSON.stringify({architecture:process.arch,platform:process.platform,version:process.version,v8Version:process.versions.v8})",
+ ])
+ ).stdout,
+ ) as { architecture: string; platform: string; version: string; v8Version: string };
+ const serverNetworkIsolationCanary = JSON.parse(
+ (
+ await execFileAsync("docker", [
+ "exec",
+ containerId,
+ "node",
+ "-e",
+ `Promise.all([
+ fetch("https://example.com", { signal: AbortSignal.timeout(3000) }).then((response) => ({ blocked: false, status: response.status }), (error) => ({ blocked: true, code: error.cause?.code ?? error.name })),
+ fetch("http://1.1.1.1", { signal: AbortSignal.timeout(3000) }).then((response) => ({ blocked: false, status: response.status }), (error) => ({ blocked: true, code: error.cause?.code ?? error.name })),
+]).then((result) => console.log(JSON.stringify({ domain: result[0], directIp: result[1] })));`,
+ ])
+ ).stdout,
+ ) as {
+ directIp: { blocked: boolean; code?: string; status?: number };
+ domain: { blocked: boolean; code?: string; status?: number };
+ };
+ const serverNetworkIsolated =
+ serverNetworkIsolationCanary.domain.blocked && serverNetworkIsolationCanary.directIp.blocked;
+ if (!serverNetworkIsolated) {
+ throw new Error(`Server external network isolation canary failed: ${JSON.stringify(serverNetworkIsolationCanary)}`);
+ }
+
+ const checkpoints: RuntimeMemoryCheckpoint[] = [];
+ checkpoints.push(await captureCheckpointAsync(containerId, "startup"));
+
+ denyProxy = await startExternalDenyProxyAsync();
+ const browserLaunchArguments = getBrowserNetworkIsolationLaunchArgs(denyProxy.url);
+ browser = await chromium.launch({ args: browserLaunchArguments });
+ const isolationCanaryUrl = "http://homarr-benchmark.invalid/__network-isolation-canary__";
+ const canaryAttemptsBefore = denyProxy
+ .getAttempts()
+ .filter(({ method, target }) => method === "GET" && target === isolationCanaryUrl)
+ .reduce((total, { count }) => total + count, 0);
+ const canaryContext = await browser.newContext({ viewport });
+ const canaryPage = await canaryContext.newPage();
+ const canaryWebSocketUrls: string[] = [];
+ const canaryWebSocketEvents = new Map<
+ string,
+ { closeCount: number; frameReceivedCount: number; frameSentCount: number; socketErrorCount: number }
+ >();
+ canaryPage.on("websocket", (webSocket) => {
+ const url = webSocket.url();
+ canaryWebSocketUrls.push(url);
+ const events = { closeCount: 0, frameReceivedCount: 0, frameSentCount: 0, socketErrorCount: 0 };
+ canaryWebSocketEvents.set(url, events);
+ webSocket.on("close", () => {
+ events.closeCount += 1;
+ });
+ webSocket.on("framereceived", () => {
+ events.frameReceivedCount += 1;
+ });
+ webSocket.on("framesent", () => {
+ events.frameSentCount += 1;
+ });
+ webSocket.on("socketerror", () => {
+ events.socketErrorCount += 1;
+ });
+ });
+ const canaryResponse = await canaryPage.goto(isolationCanaryUrl, { waitUntil: "domcontentloaded" });
+ const canaryAttemptsAfter = denyProxy
+ .getAttempts()
+ .filter(({ method, target }) => method === "GET" && target === isolationCanaryUrl)
+ .reduce((total, { count }) => total + count, 0);
+ const browserNetworkIsolationCanary = {
+ attemptCount: canaryAttemptsAfter - canaryAttemptsBefore,
+ responseHeader: canaryResponse?.headers()["x-homarr-benchmark-blocked"] ?? null,
+ status: canaryResponse?.status() ?? null,
+ url: isolationCanaryUrl,
+ };
+ const runWebSocketIsolationCanaryAsync = async (url: string, connectTarget: string) => {
+ const getAttemptCount = () =>
+ denyProxy
+ ?.getAttempts()
+ .filter(
+ ({ method, target }) =>
+ (method === "CONNECT" && target === connectTarget) || (method === "UPGRADE" && target === url),
+ )
+ .reduce((total, { count }) => total + count, 0) ?? 0;
+ const attemptsBefore = getAttemptCount();
+ const result = await canaryPage.evaluate(
+ (canaryUrl) =>
+ new Promise<{ closeCode: number | null; errorEvent: boolean; opened: boolean; timedOut: boolean }>(
+ (resolve) => {
+ const webSocket = new WebSocket(canaryUrl);
+ let settled = false;
+ const finish = (value: {
+ closeCode: number | null;
+ errorEvent: boolean;
+ opened: boolean;
+ timedOut: boolean;
+ }) => {
+ if (settled) return;
+ settled = true;
+ window.clearTimeout(timeout);
+ if (webSocket.readyState === WebSocket.OPEN) webSocket.close();
+ resolve(value);
+ };
+ const timeout = window.setTimeout(
+ () => finish({ closeCode: null, errorEvent: false, opened: false, timedOut: true }),
+ 5_000,
+ );
+ webSocket.addEventListener("open", () =>
+ finish({ closeCode: null, errorEvent: false, opened: true, timedOut: false }),
+ );
+ webSocket.addEventListener("error", () =>
+ finish({ closeCode: null, errorEvent: true, opened: false, timedOut: false }),
+ );
+ webSocket.addEventListener("close", (event) =>
+ finish({ closeCode: event.code, errorEvent: false, opened: false, timedOut: false }),
+ );
+ },
+ ),
+ url,
+ );
+ const eventsDeadline = Date.now() + 2_000;
+ while (Date.now() < eventsDeadline) {
+ const events = canaryWebSocketEvents.get(url);
+ if ((events?.closeCount ?? 0) > 0 && (events?.socketErrorCount ?? 0) > 0) break;
+ await canaryPage.waitForTimeout(25);
+ }
+ const events = canaryWebSocketEvents.get(url) ?? {
+ closeCount: 0,
+ frameReceivedCount: 0,
+ frameSentCount: 0,
+ socketErrorCount: 0,
+ };
+ return {
+ ...result,
+ ...events,
+ attemptCount: getAttemptCount() - attemptsBefore,
+ browserEventCount: canaryWebSocketUrls.filter((observedUrl) => observedUrl === url).length,
+ connectTarget,
+ url,
+ };
+ };
+ const browserWebSocketIsolationCanaries = await Promise.all([
+ runWebSocketIsolationCanaryAsync(
+ "ws://homarr-benchmark-ws.invalid/__websocket-network-isolation-canary__",
+ "homarr-benchmark-ws.invalid:80",
+ ),
+ runWebSocketIsolationCanaryAsync(
+ "wss://homarr-benchmark-wss.invalid/__websocket-network-isolation-canary__",
+ "homarr-benchmark-wss.invalid:443",
+ ),
+ ]);
+ await canaryContext.close();
+ const browserNetworkIsolated =
+ browserNetworkIsolationCanary.status === 502 &&
+ browserNetworkIsolationCanary.responseHeader === "true" &&
+ browserNetworkIsolationCanary.attemptCount > 0 &&
+ browserWebSocketIsolationCanaries.every(
+ ({
+ attemptCount,
+ browserEventCount,
+ closeCount,
+ errorEvent,
+ frameReceivedCount,
+ frameSentCount,
+ opened,
+ socketErrorCount,
+ timedOut,
+ }) =>
+ attemptCount > 0 &&
+ browserEventCount > 0 &&
+ closeCount > 0 &&
+ errorEvent &&
+ frameReceivedCount === 0 &&
+ frameSentCount === 0 &&
+ !opened &&
+ socketErrorCount > 0 &&
+ !timedOut,
+ );
+ if (!browserNetworkIsolated) {
+ throw new Error(
+ `Browser network isolation canary failed: ${JSON.stringify({ http: browserNetworkIsolationCanary, webSockets: browserWebSocketIsolationCanaries })}`,
+ );
+ }
+ const browserNetworkObservations = new Map<
+ string,
+ {
+ failedCount: number;
+ failureTexts: Set;
+ methods: Set;
+ origin: string;
+ pathname: string;
+ protocol: string;
+ requestCount: number;
+ resourceTypes: Set;
+ responseStatuses: Set;
+ urlHash: string;
+ }
+ >();
+ const browserWebSocketObservations = new Map<
+ string,
+ {
+ closeCount: number;
+ frameReceivedCount: number;
+ frameSentCount: number;
+ origin: string;
+ pathname: string;
+ protocol: string;
+ requestCount: number;
+ socketErrorCount: number;
+ urlHash: string;
+ }
+ >();
+ const allowedBrowserOrigin = new URL(baseUrl).origin;
+ const allowedBrowserWebSocketUrl = new URL(baseUrl);
+ allowedBrowserWebSocketUrl.protocol = allowedBrowserWebSocketUrl.protocol === "https:" ? "wss:" : "ws:";
+ const allowedBrowserWebSocketOrigin = allowedBrowserWebSocketUrl.origin;
+ const getNetworkObservation = (url: string) => {
+ const safeUrl = safeUrlDetails(url);
+ const existing = browserNetworkObservations.get(safeUrl.urlHash);
+ if (existing) return existing;
+ const observation = {
+ failedCount: 0,
+ failureTexts: new Set(),
+ methods: new Set(),
+ ...safeUrl,
+ requestCount: 0,
+ resourceTypes: new Set(),
+ responseStatuses: new Set(),
+ };
+ browserNetworkObservations.set(safeUrl.urlHash, observation);
+ return observation;
+ };
+ const observeBrowserNetwork = (browserContext: BrowserContext) => {
+ browserContext.on("request", (request) => {
+ const observation = getNetworkObservation(request.url());
+ observation.requestCount += 1;
+ observation.methods.add(request.method());
+ observation.resourceTypes.add(request.resourceType());
+ });
+ browserContext.on("requestfailed", (request) => {
+ const observation = getNetworkObservation(request.url());
+ observation.failedCount += 1;
+ const failureText = request.failure()?.errorText;
+ if (failureText) observation.failureTexts.add(failureText);
+ });
+ browserContext.on("response", (response) => {
+ getNetworkObservation(response.url()).responseStatuses.add(response.status());
+ });
+ browserContext.on("page", (observedPage) => {
+ observedPage.on("websocket", (webSocket) => {
+ const url = webSocket.url();
+ const safeUrl = safeUrlDetails(url);
+ const observation = browserWebSocketObservations.get(safeUrl.urlHash) ?? {
+ closeCount: 0,
+ frameReceivedCount: 0,
+ frameSentCount: 0,
+ ...safeUrl,
+ requestCount: 0,
+ socketErrorCount: 0,
+ };
+ observation.requestCount += 1;
+ browserWebSocketObservations.set(safeUrl.urlHash, observation);
+ webSocket.on("close", () => {
+ observation.closeCount += 1;
+ });
+ webSocket.on("framereceived", () => {
+ observation.frameReceivedCount += 1;
+ });
+ webSocket.on("framesent", () => {
+ observation.frameSentCount += 1;
+ });
+ webSocket.on("socketerror", () => {
+ observation.socketErrorCount += 1;
+ });
+ });
+ });
+ };
+ const createBenchmarkContextAsync = async (options: BrowserContextOptions = {}) => {
+ if (!browser) throw new Error("Chromium is not running");
+ const browserContext = await browser.newContext({
+ deviceScaleFactor: 1,
+ viewport,
+ ...options,
+ });
+ observeBrowserNetwork(browserContext);
+ return browserContext;
+ };
+ const browserProvenance = {
+ engine: "chromium",
+ executablePath: chromium.executablePath(),
+ headless: true,
+ httpCachePolicy: "Chromium default cache enabled within each browser context",
+ launchArguments: browserLaunchArguments,
+ version: browser.version(),
+ viewport: { deviceScaleFactor: 1, ...viewport },
+ };
+ const context = await createBenchmarkContextAsync();
+ await installMeasurementScriptsAsync(context, { deferIdle: true });
+ const page = await context.newPage();
+ await page.goto(`${baseUrl}/auth/login`, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(
+ () => {
+ const form = document.querySelector("form");
+ return form !== null && Object.keys(form).some((key) => key.startsWith("__reactProps$"));
+ },
+ undefined,
+ { timeout: 30_000 },
+ );
+ await page.getByLabel("Username").fill("demo");
+ await page.locator("#password").fill("demo");
+ const loginWidgetDataTracker = trackWidgetDataRequests(page);
+
+ const pageErrors: string[] = [];
+ let legacyQueryCacheWriteBytes = 0;
+ let legacyQueryCacheWriteCount = 0;
+ let trpcRequestCount = 0;
+ let nonWidgetTrpcRequestCount = 0;
+ let widgetRequestCount = 0;
+ let webSocketCount = 0;
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ page.on("websocket", () => {
+ webSocketCount += 1;
+ });
+ page.on("request", (request) => {
+ const procedures = getTrpcProcedures(request.url());
+ const widgetProcedures = procedures.filter((procedure) => procedure.startsWith("widget."));
+ const legacyQueryCacheWrites = procedures.filter((procedure) => procedure === "queryCache.setItem");
+ trpcRequestCount += procedures.length;
+ widgetRequestCount += widgetProcedures.length;
+ nonWidgetTrpcRequestCount += procedures.length - widgetProcedures.length;
+ if (legacyQueryCacheWrites.length > 0) {
+ legacyQueryCacheWriteCount += legacyQueryCacheWrites.length;
+ legacyQueryCacheWriteBytes += request.postDataBuffer()?.byteLength ?? 0;
+ }
+ });
+
+ const loginJourneyBoardStartedAt = performance.now();
+ await page.locator('button[type="submit"]').click();
+ await page.waitForURL(`${baseUrl}/`, { timeout: 30_000, waitUntil: "domcontentloaded" });
+ await page.locator("[data-homarr-dev-benchmark-board]").waitFor({ state: "visible", timeout: 30_000 });
+ const loginJourneyBoardMountedMs = Math.round(performance.now() - loginJourneyBoardStartedAt);
+ const loginBoardState = await waitForBoardWidgetsAsync(page);
+ const { widgetCount, implementationMarkerCount, widgetErrorCount } = loginBoardState;
+ if (requireImplementationMarkers && implementationMarkerCount !== widgetCount) {
+ throw new Error(`Only ${implementationMarkerCount} of ${widgetCount} widget implementations mounted`);
+ }
+ if (widgetErrorCount > 0) throw new Error(`Representative board rendered ${widgetErrorCount} widget errors`);
+ const loginJourneyWidgetImplementationsMountedMs = requireImplementationMarkers
+ ? Math.round(performance.now() - loginJourneyBoardStartedAt)
+ : null;
+ const loginJourneyWidgetData = await waitForWidgetDataSettledAsync(page, loginWidgetDataTracker, {
+ allowUnrecoveredAborts: true,
+ });
+ const loginJourneyWidgetDataSettledMs =
+ loginJourneyWidgetData.completedOperationCount > 0 && loginJourneyWidgetData.unrecoveredAbortedRequests.length === 0
+ ? Math.round(performance.now() - loginJourneyBoardStartedAt)
+ : null;
+ assertBoardWidgetState(await readBoardWidgetStateAsync(page), "Representative board after widget data settled");
+ const authenticatedStorageState = await context.storageState();
+ const loginJourneyNetworkCounts = {
+ trpcRequestCount,
+ widgetRequestCount,
+ webSocketCount,
+ legacyQueryCacheWriteCount,
+ legacyQueryCacheWriteBytes,
+ };
+ const pageLoadSamples = [] as Array<{
+ boardMountedMs: number;
+ fcpMs: number;
+ lcpFinalizedByInputMs: number;
+ lcpMs: number;
+ ttfbMs: number;
+ widgetImplementationsMountedMs: number | null;
+ widgetDataObservedOperationCount: number;
+ widgetDataSettledMs: number;
+ }>;
+
+ // Login uses a soft navigation whose LCP is terminated by the submit click.
+ // Keep that journey timing separate and measure browser vitals in a fresh,
+ // naturally scheduled authenticated document.
+ const initialContext = await createBenchmarkContextAsync({ storageState: authenticatedStorageState });
+ await installMeasurementScriptsAsync(initialContext);
+ const initialPage = await initialContext.newPage();
+ const initialWidgetDataTracker = trackWidgetDataRequests(initialPage);
+ const initialPageErrors: string[] = [];
+ const initialNetworkCounts = {
+ legacyQueryCacheWriteBytes: 0,
+ legacyQueryCacheWriteCount: 0,
+ trpcRequestCount: 0,
+ webSocketCount: 0,
+ widgetRequestCount: 0,
+ };
+ initialPage.on("pageerror", (error) => initialPageErrors.push(error.message));
+ initialPage.on("websocket", () => {
+ initialNetworkCounts.webSocketCount += 1;
+ });
+ initialPage.on("request", (request) => {
+ const procedures = getTrpcProcedures(request.url());
+ const legacyQueryCacheWrites = procedures.filter((procedure) => procedure === "queryCache.setItem");
+ initialNetworkCounts.trpcRequestCount += procedures.length;
+ initialNetworkCounts.widgetRequestCount += procedures.filter((procedure) => procedure.startsWith("widget.")).length;
+ if (legacyQueryCacheWrites.length > 0) {
+ initialNetworkCounts.legacyQueryCacheWriteCount += legacyQueryCacheWrites.length;
+ initialNetworkCounts.legacyQueryCacheWriteBytes += request.postDataBuffer()?.byteLength ?? 0;
+ }
+ });
+ const initialDashboardStartedAt = performance.now();
+ const initialDashboardResponse = await initialPage.goto(baseUrl, { waitUntil: "domcontentloaded" });
+ if (!initialDashboardResponse?.ok()) {
+ throw new Error(`Initial authenticated dashboard returned HTTP ${String(initialDashboardResponse?.status())}`);
+ }
+ const initialAuthenticatedBoardState = await waitForBoardWidgetsAsync(initialPage);
+ if (
+ requireImplementationMarkers &&
+ initialAuthenticatedBoardState.implementationMarkerCount !== initialAuthenticatedBoardState.widgetCount
+ ) {
+ throw new Error("Initial authenticated dashboard has incomplete widget implementation markers");
+ }
+ if (initialAuthenticatedBoardState.widgetErrorCount > 0) {
+ throw new Error(
+ `Initial authenticated dashboard rendered ${initialAuthenticatedBoardState.widgetErrorCount} widget errors`,
+ );
+ }
+ const initialWidgetData = await waitForWidgetDataSettledAsync(initialPage, initialWidgetDataTracker);
+ const initialWidgetDataSettledMs = Math.round(performance.now() - initialDashboardStartedAt);
+ assertBoardWidgetState(
+ await readBoardWidgetStateAsync(initialPage),
+ "Initial authenticated dashboard after widget data settled",
+ );
+ await waitForFinalLcpAsync(initialPage);
+ const initialBrowserMetrics = await readBrowserMetricsAsync(initialPage);
+ if (initialBrowserMetrics.lcpFinalizedByInputMs === null) {
+ throw new Error("Initial authenticated dashboard did not finalize LCP with trusted input");
+ }
+ if (initialPageErrors.length > 0) {
+ throw new Error(`Initial authenticated dashboard page errors: ${initialPageErrors.join("; ")}`);
+ }
+ await initialContext.close();
+
+ const trpcRequestCountBeforeIdleSpotlight = trpcRequestCount;
+ const nonWidgetTrpcRequestCountBeforeIdleSpotlight = nonWidgetTrpcRequestCount;
+ await page.evaluate(() => {
+ (window as Window & { homarrReleaseBenchmarkIdleCallbacks?: () => void }).homarrReleaseBenchmarkIdleCallbacks?.();
+ });
+ await waitForSpotlightIdlePreparationAsync(page);
+ const idleSpotlightNetworkCounts = {
+ trpcRequestCount: trpcRequestCount - trpcRequestCountBeforeIdleSpotlight,
+ nonWidgetTrpcRequestCount: nonWidgetTrpcRequestCount - nonWidgetTrpcRequestCountBeforeIdleSpotlight,
+ };
+ const idleSpotlightNetworkFailure = getSpotlightIdleNetworkFailure(
+ spotlightIdlePolicy,
+ idleSpotlightNetworkCounts.nonWidgetTrpcRequestCount,
+ );
+ if (idleSpotlightNetworkFailure) throw new Error(idleSpotlightNetworkFailure);
+
+ const warmInteractionSamplesMs: number[] = [];
+ for (let iteration = 0; iteration < interactionIterations; iteration += 1) {
+ const warmInteractionStartedAt = performance.now();
+ await page.locator('[data-homarr-dev-benchmark-interaction="search"]:visible').click();
+ await page.locator("[data-homarr-dev-benchmark-spotlight]").waitFor({ state: "visible" });
+ warmInteractionSamplesMs.push(Math.round(performance.now() - warmInteractionStartedAt));
+ await page.keyboard.press("Escape");
+ await page.locator("[data-homarr-dev-benchmark-spotlight]").waitFor({ state: "hidden" });
+ }
+ const postIdleWarmInteractionMs = warmInteractionSamplesMs[0] ?? null;
+ const postIdleWarmInteractionSummary = summarize(warmInteractionSamplesMs);
+
+ const websocketStartedAt = performance.now();
+ await page.evaluate(
+ () =>
+ new Promise((resolve, reject) => {
+ const socket = new WebSocket(
+ `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/websockets`,
+ );
+ const timeout = window.setTimeout(() => {
+ socket.close();
+ reject(new Error("WebSocket readiness probe timed out"));
+ }, 10_000);
+ socket.addEventListener("open", () => {
+ window.clearTimeout(timeout);
+ socket.close();
+ resolve();
+ });
+ socket.addEventListener("error", () => {
+ window.clearTimeout(timeout);
+ reject(new Error("WebSocket readiness probe failed"));
+ });
+ }),
+ );
+ const websocketReadyMs = Math.round(performance.now() - websocketStartedAt);
+
+ checkpoints.push(await captureCheckpointAsync(containerId, "first-board"));
+
+ for (let iteration = 0; iteration < pageIterations; iteration += 1) {
+ const iterationContext = await createBenchmarkContextAsync({ storageState: authenticatedStorageState });
+ await installMeasurementScriptsAsync(iterationContext);
+ const iterationPage = await iterationContext.newPage();
+ const iterationWidgetDataTracker = trackWidgetDataRequests(iterationPage);
+ const iterationErrors: string[] = [];
+ iterationPage.on("pageerror", (error) => iterationErrors.push(error.message));
+ const iterationStartedAt = performance.now();
+ const response = await iterationPage.goto(baseUrl, { waitUntil: "domcontentloaded" });
+ if (!response?.ok()) {
+ throw new Error(`Board iteration ${iteration + 1} returned HTTP ${String(response?.status() ?? "none")}`);
+ }
+ await iterationPage.locator("[data-homarr-dev-benchmark-board]").waitFor({ state: "visible", timeout: 30_000 });
+ const iterationBoardMountedMs = Math.round(performance.now() - iterationStartedAt);
+ const iterationBoardState = await waitForBoardWidgetsAsync(iterationPage);
+ if (
+ requireImplementationMarkers &&
+ iterationBoardState.implementationMarkerCount !== iterationBoardState.widgetCount
+ ) {
+ throw new Error(`Board iteration ${iteration + 1} has incomplete widget implementation markers`);
+ }
+ if (iterationBoardState.widgetErrorCount > 0) {
+ throw new Error(
+ `Board iteration ${iteration + 1} rendered ${iterationBoardState.widgetErrorCount} widget errors`,
+ );
+ }
+ const iterationWidgetImplementationsMountedMs = requireImplementationMarkers
+ ? Math.round(performance.now() - iterationStartedAt)
+ : null;
+ const iterationWidgetData = await waitForWidgetDataSettledAsync(iterationPage, iterationWidgetDataTracker);
+ const iterationWidgetDataSettledMs = Math.round(performance.now() - iterationStartedAt);
+ assertBoardWidgetState(
+ await readBoardWidgetStateAsync(iterationPage),
+ `Board iteration ${iteration + 1} after widget data settled`,
+ );
+ await waitForFinalLcpAsync(iterationPage);
+ const iterationMetrics = await readBrowserMetricsAsync(iterationPage);
+ if (iterationMetrics.fcpMs === null) throw new Error(`Board iteration ${iteration + 1} has no FCP sample`);
+ if (iterationMetrics.lcpFinalizedByInputMs === null) {
+ throw new Error(`Board iteration ${iteration + 1} did not finalize LCP with trusted input`);
+ }
+ if (iterationMetrics.lcpMs === null) throw new Error(`Board iteration ${iteration + 1} has no LCP sample`);
+ if (iterationMetrics.ttfbMs === null) throw new Error(`Board iteration ${iteration + 1} has no TTFB sample`);
+ pageLoadSamples.push({
+ boardMountedMs: iterationBoardMountedMs,
+ fcpMs: iterationMetrics.fcpMs,
+ lcpFinalizedByInputMs: iterationMetrics.lcpFinalizedByInputMs,
+ lcpMs: iterationMetrics.lcpMs,
+ ttfbMs: iterationMetrics.ttfbMs,
+ widgetImplementationsMountedMs: iterationWidgetImplementationsMountedMs,
+ widgetDataObservedOperationCount: iterationWidgetData.observedOperationCount,
+ widgetDataSettledMs: iterationWidgetDataSettledMs,
+ });
+ if (iterationErrors.length > 0) {
+ throw new Error(`Board iteration ${iteration + 1} page errors: ${iterationErrors.join("; ")}`);
+ }
+ await iterationContext.close();
+ }
+
+ const coldContext = await createBenchmarkContextAsync({ storageState: authenticatedStorageState });
+ await coldContext.addInitScript(() => {
+ // Keep the cold-interaction probe cold instead of racing the application's
+ // intentional requestIdleCallback prefetch.
+ Object.defineProperty(window, "requestIdleCallback", { value: () => 0, configurable: true });
+ Object.defineProperty(window, "cancelIdleCallback", { value: () => undefined, configurable: true });
+ });
+ const coldPage = await coldContext.newPage();
+ const coldPageErrors: string[] = [];
+ coldPage.on("pageerror", (error) => coldPageErrors.push(error.message));
+ const coldResponse = await coldPage.goto(`${baseUrl}/`, { waitUntil: "domcontentloaded" });
+ if (!coldResponse?.ok()) {
+ throw new Error(`Cold-interaction board returned HTTP ${String(coldResponse?.status() ?? "none")}`);
+ }
+ await coldPage.locator("[data-homarr-dev-benchmark-board]").waitFor({ state: "visible", timeout: 30_000 });
+ await coldPage.evaluate(() => {
+ const state = {
+ clickedAtMs: null as number | null,
+ feedbackAtMs: null as number | null,
+ observer: null as MutationObserver | null,
+ };
+ const recordFeedback = () => {
+ const element = document.querySelector("[data-homarr-dev-benchmark-spotlight-feedback]");
+ if (
+ state.clickedAtMs !== null &&
+ state.feedbackAtMs === null &&
+ element instanceof HTMLElement &&
+ element.getClientRects().length > 0 &&
+ window.getComputedStyle(element).visibility !== "hidden"
+ ) {
+ state.feedbackAtMs = performance.now();
+ }
+ };
+ document.addEventListener(
+ "click",
+ (event) => {
+ if (
+ !(event.target instanceof Element) ||
+ !event.target.closest('[data-homarr-dev-benchmark-interaction="search"]')
+ )
+ return;
+ state.clickedAtMs = performance.now();
+ state.observer = new MutationObserver(recordFeedback);
+ state.observer.observe(document.documentElement, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ recordFeedback();
+ },
+ { capture: true, once: true },
+ );
+ Object.defineProperty(window, "homarrColdSpotlightProbe", { configurable: true, value: state });
+ });
+ const coldInteractionStartedAt = performance.now();
+ await coldPage.locator('[data-homarr-dev-benchmark-interaction="search"]:visible').click();
+ await coldPage.locator("[data-homarr-dev-benchmark-spotlight]").waitFor({ state: "visible" });
+ const coldSpotlightReadyMs = Math.round(performance.now() - coldInteractionStartedAt);
+ const coldSpotlightFeedbackMs = await coldPage.evaluate(() => {
+ const state = (
+ window as Window & {
+ homarrColdSpotlightProbe?: {
+ clickedAtMs: number | null;
+ feedbackAtMs: number | null;
+ observer: MutationObserver | null;
+ };
+ }
+ ).homarrColdSpotlightProbe;
+ state?.observer?.disconnect();
+ return state?.clickedAtMs !== null && state?.clickedAtMs !== undefined && state.feedbackAtMs !== null
+ ? Math.round(state.feedbackAtMs - state.clickedAtMs)
+ : null;
+ });
+ if (coldPageErrors.length > 0) throw new Error(`Cold-interaction page errors: ${coldPageErrors.join("; ")}`);
+ await coldPage.close();
+ await coldContext.close();
+
+ const routeContext = await createBenchmarkContextAsync({ storageState: authenticatedStorageState });
+ await installMeasurementScriptsAsync(routeContext);
+ const routePage = await routeContext.newPage();
+ const routePageErrors: string[] = [];
+ routePage.on("pageerror", (error) => routePageErrors.push(error.message));
+ const routeTimings = [] as Array<{ path: string; durationMs: number; status: number | null }>;
+ for (const routePath of routePaths) {
+ const startedAt = performance.now();
+ const response = await routePage.goto(`${baseUrl}${routePath}`, { waitUntil: "domcontentloaded" });
+ await routePage.locator("main:visible").waitFor({ state: "visible", timeout: 30_000 });
+ if (!response?.ok()) throw new Error(`Route ${routePath} returned HTTP ${String(response?.status() ?? "none")}`);
+ routeTimings.push({
+ path: routePath,
+ durationMs: Math.round(performance.now() - startedAt),
+ status: response.status(),
+ });
+ }
+
+ await routePage.goto(`${baseUrl}/`, { waitUntil: "domcontentloaded" });
+ const finalBoardState = await waitForBoardWidgetsAsync(routePage);
+ if (requireImplementationMarkers && finalBoardState.implementationMarkerCount !== finalBoardState.widgetCount) {
+ throw new Error("Final board load has incomplete widget implementation markers");
+ }
+ if (finalBoardState.widgetErrorCount > 0) {
+ throw new Error(`Final board load rendered ${finalBoardState.widgetErrorCount} widget errors`);
+ }
+ await waitForSpotlightIdlePreparationAsync(routePage);
+ if (routePageErrors.length > 0) throw new Error(`Route browser page errors: ${routePageErrors.join("; ")}`);
+ checkpoints.push(await captureCheckpointAsync(containerId, "workload"));
+
+ const settleDeadline = Date.now() + settleMs;
+ let sample = 0;
+ while (Date.now() < settleDeadline) {
+ await new Promise((resolve) => setTimeout(resolve, Math.min(sampleIntervalMs, settleDeadline - Date.now())));
+ sample += 1;
+ checkpoints.push(await captureCheckpointAsync(containerId, `settle-${sample}`));
+ }
+
+ if (pageErrors.length > 0) throw new Error(`Browser page errors: ${pageErrors.join("; ")}`);
+ if (widgetRequestCount === 0) throw new Error("Representative board made no widget requests");
+ if (webSocketCount === 0) throw new Error("WebSocket readiness was not observed by the browser");
+
+ const containerLogs = await execFileAsync("docker", ["logs", containerId]);
+ const startupErrors = `${containerLogs.stdout}\n${containerLogs.stderr}`
+ .split("\n")
+ .filter((line) => /Failed to (?:load|start) embedded/.test(line));
+ if (startupErrors.length > 0) throw new Error(`Embedded service startup failed: ${startupErrors.join("; ")}`);
+
+ const browserNetworkRequests = [...browserNetworkObservations.values()]
+ .map((observation) => ({
+ failedCount: observation.failedCount,
+ failureTexts: [...observation.failureTexts].toSorted(),
+ methods: [...observation.methods].toSorted(),
+ origin: observation.origin,
+ pathname: observation.pathname,
+ protocol: observation.protocol,
+ requestCount: observation.requestCount,
+ resourceTypes: [...observation.resourceTypes].toSorted(),
+ responseStatuses: [...observation.responseStatuses].toSorted((left, right) => left - right),
+ urlHash: observation.urlHash,
+ }))
+ .toSorted((left, right) => left.urlHash.localeCompare(right.urlHash));
+ const browserWebSockets = [...browserWebSocketObservations.values()].toSorted((left, right) =>
+ left.urlHash.localeCompare(right.urlHash),
+ );
+ const externalBrowserWebSockets = browserWebSockets.filter(({ origin }) => origin !== allowedBrowserWebSocketOrigin);
+ if (externalBrowserWebSockets.length > 0) {
+ throw new Error(
+ `Application pages attempted external WebSockets: ${externalBrowserWebSockets
+ .map(({ origin, pathname, urlHash }) => `${origin}${pathname}#${urlHash.slice(0, 12)}`)
+ .join("; ")}`,
+ );
+ }
+ const externalBrowserRequests = browserNetworkRequests.filter(
+ ({ origin, protocol }) => (protocol === "http:" || protocol === "https:") && origin !== allowedBrowserOrigin,
+ );
+ const unexpectedExternalResponses = externalBrowserRequests.flatMap(
+ ({ origin, pathname, responseStatuses, urlHash }) =>
+ responseStatuses.filter((status) => status !== 502).map((status) => ({ origin, pathname, status, urlHash })),
+ );
+ if (unexpectedExternalResponses.length > 0) {
+ throw new Error(
+ `External browser responses bypassed the deny proxy: ${unexpectedExternalResponses
+ .map(({ origin, pathname, status, urlHash }) => `${status} ${origin}${pathname}#${urlHash.slice(0, 12)}`)
+ .join("; ")}`,
+ );
+ }
+ const proxyAttempts = denyProxy
+ .getAttempts()
+ .filter(({ method, target }) => !(method === "GET" && target === isolationCanaryUrl));
+ const safeProxyAttempts = proxyAttempts.map(({ count, method, target }) => {
+ const targetHash = createHash("sha256").update(target).digest("hex");
+ try {
+ const safeUrl = safeUrlDetails(method === "CONNECT" ? `https://${target}` : target);
+ return { count, method, ...safeUrl, targetHash };
+ } catch {
+ return { count, method, origin: null, pathname: null, protocol: null, targetHash, urlHash: null };
+ }
+ });
+ const observedOrigins = [...new Set(browserNetworkRequests.map(({ origin }) => origin))].toSorted();
+
+ const settleSampleCount = checkpoints.filter(({ name }) => name.startsWith("settle-")).length;
+ const pageLoadSummary = {
+ boardMountedMs: summarize(pageLoadSamples.map((measurement) => measurement.boardMountedMs)),
+ fcpMs: summarize(pageLoadSamples.map((measurement) => measurement.fcpMs)),
+ lcpFinalizedByInputMs: summarize(pageLoadSamples.map((measurement) => measurement.lcpFinalizedByInputMs)),
+ lcpMs: summarize(pageLoadSamples.map((measurement) => measurement.lcpMs)),
+ ttfbMs: summarize(pageLoadSamples.map((measurement) => measurement.ttfbMs)),
+ widgetImplementationsMountedMs: summarize(
+ pageLoadSamples.flatMap((measurement) =>
+ measurement.widgetImplementationsMountedMs === null ? [] : [measurement.widgetImplementationsMountedMs],
+ ),
+ ),
+ widgetDataSettledMs: summarize(pageLoadSamples.map((measurement) => measurement.widgetDataSettledMs)),
+ };
+ const eligibilityInput = {
+ expectedSha,
+ expectedSourceFingerprint,
+ browserNetworkIsolated,
+ serverNetworkIsolated,
+ imageArchitecture: imageInspect.Architecture,
+ lcpObservationMs,
+ lcpSampleCount: pageLoadSummary.lcpMs.count,
+ nodeArchitecture: process.arch,
+ revision,
+ sourceFingerprint,
+ pageSampleCount: pageLoadSamples.length,
+ requireImplementationMarkers,
+ settleMs,
+ settleSampleCount,
+ widgetDataQuietMs,
+ widgetDataSampleCount: pageLoadSummary.widgetDataSettledMs.count,
+ };
+ const metricEligibility = getRuntimeMetricEligibility(eligibilityInput);
+ const claimIneligibleReasons = getRuntimeClaimIneligibleReasons(eligibilityInput);
+ const claimEligible = claimIneligibleReasons.length === 0;
+ const result = {
+ schemaVersion: 7,
+ claimEligible,
+ claimIneligibleReasons,
+ claimScope: ["pageLoads", "largestContentfulPaint", "widgetDataSettled", "settleStability"],
+ metricEligibility,
+ createdAt: new Date().toISOString(),
+ image: {
+ requested: image,
+ id: imageInspect.Id,
+ digest: imageInspect.RepoDigests?.[0] ?? null,
+ revision,
+ sourceFingerprint,
+ architecture: imageInspect.Architecture,
+ localUncompressedBytes: imageInspect.Size,
+ },
+ host: {
+ architecture: process.arch,
+ cpus: os.cpus().map(({ model }) => model),
+ node: { executable: process.execPath, version: process.version, v8Version: process.versions.v8 },
+ platform: os.platform(),
+ release: os.release(),
+ totalMemoryBytes: os.totalmem(),
+ },
+ provenance: {
+ browser: browserProvenance,
+ container: {
+ cpuLimit: 2,
+ memoryLimitBytes: 1024 ** 3,
+ network: { id: isolationNetworkId, internal: true, name: isolationNetworkName },
+ node: containerNode,
+ },
+ ingressProxy: {
+ containerId: proxyContainerId,
+ excludedFromMemorySamples: true,
+ implementation: "Node.js TCP proxy",
+ },
+ docker: {
+ engine: {
+ architecture: dockerInfo.Architecture ?? null,
+ cgroupDriver: dockerInfo.CgroupDriver ?? null,
+ cgroupVersion: dockerInfo.CgroupVersion ?? null,
+ cpuCount: dockerInfo.NCPU ?? null,
+ kernelVersion: dockerInfo.KernelVersion ?? null,
+ memoryBytes: dockerInfo.MemTotal ?? null,
+ operatingSystem: dockerInfo.OperatingSystem ?? null,
+ osType: dockerInfo.OSType ?? null,
+ serverVersion: dockerInfo.ServerVersion ?? null,
+ storageDriver: dockerInfo.Driver ?? null,
+ },
+ version: dockerVersion,
+ },
+ },
+ browserNetworkIsolation: {
+ active: browserNetworkIsolated,
+ allowedOrigins: [allowedBrowserOrigin, allowedBrowserWebSocketOrigin],
+ blockedProxyAttemptCount: proxyAttempts.reduce((total, { count }) => total + count, 0),
+ externalBrowserRequests,
+ externalBrowserWebSockets,
+ hostResolverPolicy: "deny all DNS except localhost and 127.0.0.1",
+ httpCachePolicy: browserProvenance.httpCachePolicy,
+ observedOrigins,
+ proxy: { host: denyProxy.host, port: denyProxy.port, policy: "deny", attempts: safeProxyAttempts },
+ canaries: { http: browserNetworkIsolationCanary, webSockets: browserWebSocketIsolationCanaries },
+ observedWebSockets: browserWebSockets,
+ unexpectedExternalResponses,
+ },
+ serverNetworkIsolation: {
+ active: serverNetworkIsolated,
+ canary: serverNetworkIsolationCanary,
+ network: { id: isolationNetworkId, internal: true, name: isolationNetworkName },
+ policy: "application container attached only to an internal Docker network",
+ },
+ workload: {
+ baseUrl,
+ startToReadyMs,
+ settleMs,
+ sampleIntervalMs,
+ settleSampleCount,
+ lcpObservationMs,
+ lcpStabilityMs,
+ widgetDataQuietMs,
+ spotlightIdleMs,
+ spotlightIdlePolicy,
+ interactionIterations,
+ widgetCount,
+ implementationMarkerPolicy: readyMarkerPolicy,
+ widgetImplementationMarkerCount: implementationMarkerCount,
+ widgetErrorCount,
+ loginJourneyBoardMountedMs,
+ loginJourneyWidgetImplementationsMountedMs,
+ loginJourneyWidgetData,
+ loginJourneyWidgetDataSettledMs,
+ pageLoadSamples,
+ pageLoadSummary,
+ coldSpotlightFeedbackMs,
+ coldSpotlightReadyMs,
+ idleSpotlightNetworkCounts,
+ postIdleWarmInteractionMs,
+ postIdleWarmInteractionSummary,
+ warmInteractionSamplesMs,
+ routeTimings,
+ trpcRequestCount,
+ widgetRequestCount,
+ webSocketCount,
+ websocketReadyMs,
+ legacyQueryCacheWriteCount,
+ legacyQueryCacheWriteBytes,
+ initialNetworkCounts,
+ loginJourneyNetworkCounts,
+ initialBrowserMetrics,
+ initialWidgetData,
+ initialWidgetDataSettledMs,
+ },
+ checkpoints,
+ summary: summarizeRuntimeMemory(checkpoints),
+ };
+ const outputPath = path.join(outputDirectory, `runtime-${Date.now()}.json`);
+ await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
+ console.log(
+ JSON.stringify({ outputPath, claimEligible, summary: result.summary, workload: result.workload }, null, 2),
+ );
+
+ await page.close();
+ await context.close();
+ await routeContext.close();
+} catch (error) {
+ if (containerId) {
+ const logs = await execFileAsync("docker", ["logs", "--tail", "100", containerId]).catch(() => null);
+ if (logs?.stdout) console.error(logs.stdout);
+ if (logs?.stderr) console.error(logs.stderr);
+ }
+ throw error;
+} finally {
+ process.off("SIGINT", handleSigint);
+ process.off("SIGTERM", handleSigterm);
+ await cleanupAsync();
+}
diff --git a/scripts/benchmarks/request-handler-memory.mts b/scripts/benchmarks/request-handler-memory.mts
new file mode 100644
index 000000000..c867efaa1
--- /dev/null
+++ b/scripts/benchmarks/request-handler-memory.mts
@@ -0,0 +1,107 @@
+import { createIntegrationRequestHandler } from "../../packages/request-handler/src/lib/integration-request-handler.ts";
+import { createRequestHandler } from "../../packages/request-handler/src/lib/request-handler.ts";
+
+const allocations = Number(process.env.REQUEST_MEMORY_ALLOCATIONS ?? 64);
+const allocationBytes = Number(process.env.REQUEST_MEMORY_ALLOCATION_BYTES ?? 1024 * 1024);
+const maxRetainedBytes = Number(process.env.REQUEST_MEMORY_MAX_RETAINED_BYTES ?? 16 * 1024 * 1024);
+const positiveTtlMs = Number(process.env.REQUEST_MEMORY_POSITIVE_TTL_MS ?? 25);
+
+if (!globalThis.gc) throw new Error("Run this benchmark with node --expose-gc");
+if (!Number.isInteger(allocations) || allocations < 2) throw new Error("REQUEST_MEMORY_ALLOCATIONS must be >= 2");
+if (!Number.isInteger(allocationBytes) || allocationBytes < 1) {
+ throw new Error("REQUEST_MEMORY_ALLOCATION_BYTES must be positive");
+}
+
+const collectGarbage = async () => {
+ for (let index = 0; index < 4; index++) {
+ globalThis.gc?.();
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+};
+
+const retainedRoots: unknown[] = [];
+const measureArrayBufferGrowthAsync = async (workload: () => Promise) => {
+ await collectGarbage();
+ const before = process.memoryUsage().arrayBuffers;
+ const retainedRoot = await workload();
+ retainedRoots.push(retainedRoot);
+ await collectGarbage();
+ const growth = process.memoryUsage().arrayBuffers - before;
+ retainedRoots.pop();
+ return growth;
+};
+
+const zeroTtlGrowthBytes = await measureArrayBufferGrowthAsync(async () => {
+ const handler = createRequestHandler({
+ cacheTtlMs: 0,
+ requestAsync: async () => new Uint8Array(allocationBytes),
+ });
+ for (let id = 0; id < allocations; id++) {
+ await handler.handler({ id }).getDataAsync();
+ }
+ return handler;
+});
+
+const integrationGrowthBytes = await measureArrayBufferGrowthAsync(async () => {
+ const handler = createIntegrationRequestHandler>({
+ cacheTtlMs: 0,
+ requestAsync: async (integration) =>
+ (integration as typeof integration & { benchmarkPayload: Uint8Array }).benchmarkPayload.byteLength,
+ });
+ for (let id = 0; id < allocations; id++) {
+ await handler
+ .handler(
+ {
+ id: `benchmark-${id}`,
+ kind: "mock",
+ name: "benchmark",
+ url: "http://benchmark.invalid",
+ externalUrl: null,
+ decryptedSecrets: [],
+ benchmarkPayload: new Uint8Array(allocationBytes),
+ } as never,
+ {},
+ )
+ .getDataAsync();
+ }
+ return handler;
+});
+
+const expiredPositiveTtlGrowthBytes = await measureArrayBufferGrowthAsync(async () => {
+ const handler = createRequestHandler({
+ cacheTtlMs: positiveTtlMs,
+ requestAsync: async () => new Uint8Array(allocationBytes),
+ });
+ for (let id = 0; id < allocations; id++) {
+ await handler.handler({ id }).getDataAsync();
+ }
+ await new Promise((resolve) => setTimeout(resolve, positiveTtlMs + 25));
+ return handler;
+});
+
+const positiveControlGrowthBytes = await measureArrayBufferGrowthAsync(async () =>
+ Array.from({ length: allocations }, () => new Uint8Array(allocationBytes)),
+);
+const positiveControlMinimumBytes = allocations * allocationBytes * 0.8;
+
+const result = {
+ allocations,
+ allocationBytes,
+ allocatedPerScenarioBytes: allocations * allocationBytes,
+ maxRetainedBytes,
+ positiveTtlMs,
+ zeroTtlGrowthBytes,
+ integrationGrowthBytes,
+ expiredPositiveTtlGrowthBytes,
+ positiveControlGrowthBytes,
+ positiveControlMinimumBytes,
+ zeroTtlPass: zeroTtlGrowthBytes <= maxRetainedBytes,
+ integrationPass: integrationGrowthBytes <= maxRetainedBytes,
+ expiredPositiveTtlPass: expiredPositiveTtlGrowthBytes <= maxRetainedBytes,
+ positiveControlPass: positiveControlGrowthBytes >= positiveControlMinimumBytes,
+};
+
+console.log(JSON.stringify(result, null, 2));
+if (!result.zeroTtlPass || !result.integrationPass || !result.expiredPositiveTtlPass || !result.positiveControlPass) {
+ process.exitCode = 1;
+}