perf: optimize dashboard loading and builds
This commit is contained in:
135 files changed
+8521
-1718
No files matched your search
@@ -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();
|
||||
};
|
||||
});
|
||||
@@ -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("."));
|
||||
};
|
||||
@@ -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)),
|
||||
|
||||
@@ -1757,9 +1757,12 @@ const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithW
|
||||
};
|
||||
|
||||
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, 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<unknown>, use
|
||||
items: {
|
||||
with: {
|
||||
integrations: {
|
||||
with: {
|
||||
integration: true,
|
||||
columns: {
|
||||
integrationId: true,
|
||||
},
|
||||
},
|
||||
layouts: true,
|
||||
@@ -1797,7 +1800,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, 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<unknown>, use
|
||||
layoutId: layout.layoutId,
|
||||
sectionId: layout.sectionId,
|
||||
})),
|
||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||
integrationIds: itemIntegrations.map((item) => item.integrationId),
|
||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<typeof throwIfActionForbiddenAsync>[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);
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
@@ -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));
|
||||
@@ -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<string, string>();
|
||||
|
||||
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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
@@ -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);
|
||||
};
|
||||
Reference in new issue
Block a user