From 24e6e4a47d651d56bdcea85a69341744863290a4 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 24 Aug 2026 09:38:30 +0200 Subject: [PATCH] refactor: tighten TypeScript boundaries --- apps/docs/src/types.ts | 4 +- .../_custom-widget-advanced-manifest.tsx | 3 +- .../_custom-widget-form-utils.ts | 3 +- .../custom-widgets/_custom-widget-form.tsx | 15 ++- .../_custom-widget-request-value-editors.tsx | 4 +- .../_custom-widget-requests-editor.tsx | 14 +-- .../src/app/api/assistant/chat/route.ts | 3 +- .../items/board-recipe-recommendations.ts | 12 +-- packages/api/src/mcp-tools.ts | 9 +- packages/api/src/middlewares/integration.ts | 56 +++++++---- packages/api/src/router/widgets/calendar.ts | 2 +- packages/api/src/router/widgets/firewall.ts | 35 ++++--- .../api/src/router/widgets/indexer-manager.ts | 2 +- .../api/src/router/widgets/media-organizer.ts | 2 +- .../api/src/router/widgets/media-requests.ts | 6 +- .../api/src/router/widgets/media-server.ts | 2 +- .../api/src/router/widgets/notifications.ts | 2 +- .../src/router/widgets/speedtest-tracker.ts | 4 +- packages/api/src/router/widgets/tracearr.ts | 4 +- packages/api/src/router/widgets/traefik.ts | 4 +- packages/api/src/router/widgets/ups.ts | 2 +- .../api/src/router/widgets/uptime-kuma.ts | 4 +- packages/api/src/router/widgets/vpn.ts | 4 +- packages/api/src/settle-integrations.ts | 12 +-- .../scripts/ai-assistant-evaluation.ts | 10 +- packages/custom-widgets/src/core/ai-prompt.ts | 10 +- .../src/jsx/runtime-components.tsx | 12 +-- .../src/workbench/code-editor.tsx | 3 +- packages/definitions/src/integration.ts | 38 +++++--- .../integrations/src/base/errors/decorator.ts | 2 + .../src/lib/channel-subscription-tracker.ts | 10 +- packages/redis/src/lib/channel.ts | 92 ++++++++++--------- packages/redis/src/lib/connection.ts | 8 +- .../request-handler/src/lib/shared-cache.ts | 23 ++--- packages/translation/src/lang/en-gb.json | 2 +- packages/widgets/src/manifest.ts | 7 +- .../widgets/src/media-server/component.tsx | 4 +- packages/widgets/src/registry.ts | 7 +- 38 files changed, 246 insertions(+), 190 deletions(-) diff --git a/apps/docs/src/types.ts b/apps/docs/src/types.ts index 04b3848b9..48ba6b02b 100644 --- a/apps/docs/src/types.ts +++ b/apps/docs/src/types.ts @@ -1,5 +1,5 @@ -import { TablerIcon } from "@tabler/icons-react"; -import { ReactNode } from "react"; +import type { TablerIcon } from "@tabler/icons-react"; +import type { ReactNode } from "react"; export interface IntegrationDefinition { name: string; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx index 4e558f880..26cf74e6e 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx @@ -43,7 +43,8 @@ export function CustomWidgetAdvancedManifest({ form }: { form: CustomWidgetWorkb onChange={(next) => { setDraft(next); try { - const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown); + const value: unknown = JSON.parse(next); + const parsed = customWidgetDefinitionSchema.safeParse(value); if (!parsed.success) { setError(parsed.error.issues[0]?.message ?? t("invalidWidget")); return; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts index eba48aa67..fd03c965f 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts @@ -74,7 +74,8 @@ export function parseSources(value: string): Array customWidgetOptionsSchema.safeParse(parseJson(form.values.options)), [form.values.options], ); - const requestIds = useMemo( - () => - Object.keys( - isRecord(parseJson(form.values.requests)) ? (parseJson(form.values.requests) as Record) : {}, - ), - [form.values.requests], - ); + const requestIds = useMemo(() => { + const requests = parseJson(form.values.requests); + return Object.keys(isRecord(requests) ? requests : {}); + }, [form.values.requests]); const jsxCompletions = useMemo( () => createCustomWidgetCompletions(form.values, requestIds), [form.values, requestIds], @@ -132,7 +129,9 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi className={classes.paneSwitcher} fullWidth value={mobilePane} - onChange={(value) => setMobilePane(value as typeof mobilePane)} + onChange={(value) => { + if (value === "configure" || value === "preview") setMobilePane(value); + }} data={[ { value: "configure", label: w("configure") }, { value: "preview", label: w("section.preview") }, diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-request-value-editors.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-request-value-editors.tsx index 6a35463b6..15507dd38 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-request-value-editors.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-request-value-editors.tsx @@ -173,7 +173,9 @@ export function RequestBodyEditor({ onChange={(next) => { setDraft(next); try { - onChange(next.trim() ? (JSON.parse(next) as unknown) : undefined); + let parsed: unknown; + if (next.trim()) parsed = JSON.parse(next); + onChange(parsed); setError(undefined); } catch (cause) { setError(cause instanceof Error ? cause.message : t("invalidJson")); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-requests-editor.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-requests-editor.tsx index 9a6975858..1b24d438b 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-requests-editor.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-requests-editor.tsx @@ -24,6 +24,8 @@ export function CustomWidgetRequestsEditor({ const entries = isRecord(parsed) ? Object.entries(parsed) : []; const sources = parseJson(form.values.sources); const sourceIds = isRecord(sources) ? Object.keys(sources) : ["default"]; + const options = parseJson(form.values.options); + const optionNames = isRecord(options) ? Object.keys(options) : []; const commit = (next: Array<[string, unknown]>) => form.setFieldValue("requests", JSON.stringify(Object.fromEntries(next), null, 2)); @@ -129,22 +131,14 @@ export function CustomWidgetRequestsEditor({ ) - : {}, - )} + optionNames={optionNames} allowParams={trigger === "manual" || kind === "action"} onChange={(query) => update(index, id, { ...request, query })} /> ) - : {}, - )} + optionNames={optionNames} allowParams={trigger === "manual" || kind === "action"} onChange={(body) => update(index, id, { ...request, body })} /> diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index f871add83..beb21787e 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -366,7 +366,8 @@ export async function POST(request: Request) { const parsed = requestSchema.safeParse( (() => { try { - return JSON.parse(requestBody) as unknown; + const value: unknown = JSON.parse(requestBody); + return value; } catch { return null; } diff --git a/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts b/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts index dee1edd21..78ab1ef84 100644 --- a/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts +++ b/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts @@ -1,5 +1,5 @@ import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { widgetIntegrationConfigs, widgetKinds } from "@homarr/definitions"; +import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions"; export interface BoardRecipeRecommendation { widgetKind: WidgetKind; @@ -36,10 +36,10 @@ export const getBoardRecipeRecommendations = ({ return widgetKinds .filter((widgetKind) => !existingKinds.has(widgetKind)) .flatMap((widgetKind) => { - const capability = widgetIntegrationConfigs[widgetKind as keyof typeof widgetIntegrationConfigs]; - if (!capability) return []; + const supportedIntegrations = widgetIntegrationSupport[widgetKind]; + if (!supportedIntegrations) return []; - const matches = capability.supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind)); + const matches = supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind)); if (matches.length === 0) return []; const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind); @@ -51,10 +51,10 @@ export const getBoardRecipeRecommendations = ({ widgetKind, integrationKind, isNewlyAvailable: Boolean(preferredMatch), - score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.supportedIntegrations.length, + score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - supportedIntegrations.length, }, ]; }) - .sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind)) + .toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind)) .slice(0, Math.max(0, limit)); }; diff --git a/packages/api/src/mcp-tools.ts b/packages/api/src/mcp-tools.ts index f821c8280..b8ae56deb 100644 --- a/packages/api/src/mcp-tools.ts +++ b/packages/api/src/mcp-tools.ts @@ -47,10 +47,11 @@ const getProcedureInput = (inputs: z.ZodType[]) => { }; const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => { - const properties = (schema.properties ?? {}) as Record>; - const required = ((schema.required as string[] | undefined) ?? []).filter( - (key) => !("default" in (properties[key] ?? {})), - ); + const properties = schema.properties ?? {}; + const required = (schema.required ?? []).filter((key) => { + const property = properties[key]; + return !property || typeof property !== "object" || !("default" in property); + }); return { ...schema, diff --git a/packages/api/src/middlewares/integration.ts b/packages/api/src/middlewares/integration.ts index 953671263..45d119a15 100644 --- a/packages/api/src/middlewares/integration.ts +++ b/packages/api/src/middlewares/integration.ts @@ -32,6 +32,14 @@ interface IntegrationAccessMetadata { allowedIntegrationKinds: readonly IntegrationKind[]; } +const throwUnexpectedIntegrationKind = (kind: IntegrationKind, allowedKinds: readonly IntegrationKind[]): never => { + logger.error("Database returned an integration outside the requested kinds", { + integrationKind: kind, + allowedIntegrationKinds: allowedKinds, + }); + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Integration kind validation failed" }); +}; + const createOneIntegrationMiddlewareForKinds = ( action: IntegrationAction, kinds: readonly TKind[], @@ -75,21 +83,19 @@ const createOneIntegrationMiddlewareForKinds = ( resolvedIntegrationKinds: [integration.kind], }); - const { - secrets, - kind, - items: _ignore1, - groupPermissions: _ignore2, - userPermissions: _ignore3, - ...rest - } = integration; + const narrowedKind = kinds.find((kind) => kind === integration.kind); + if (!narrowedKind) { + return throwUnexpectedIntegrationKind(integration.kind, kinds); + } + + const { secrets, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest } = integration; return next({ ctx: { integration: { ...rest, externalUrl: rest.app?.href ?? null, - kind: kind as TKind, + kind: narrowedKind, decryptedSecrets: secrets.map((secret) => ({ ...secret, value: decryptSecret(secret.value), @@ -176,15 +182,19 @@ const createManyIntegrationMiddlewareForKinds = ( return next({ ctx: { integrations: dbIntegrations.map( - ({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({ - ...rest, - externalUrl: rest.app?.href ?? null, - kind: kind as TKind, - decryptedSecrets: secrets.map((secret) => ({ - ...secret, - value: decryptSecret(secret.value), - })), - }), + ({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => { + const narrowedKind = kinds.find((allowedKind) => allowedKind === kind); + if (!narrowedKind) return throwUnexpectedIntegrationKind(kind, kinds); + return { + ...rest, + externalUrl: rest.app?.href ?? null, + kind: narrowedKind, + decryptedSecrets: secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }; + }, ), }, }); @@ -212,13 +222,19 @@ export const createOneWidgetIntegrationMiddleware = >> => - createOneIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations); + createOneIntegrationMiddlewareForKinds>( + action, + getWidgetIntegrationConfig(widgetKind).supportedIntegrations, + ); export const createManyWidgetIntegrationMiddleware = ( action: IntegrationAction, widgetKind: TWidgetKind, ): ReturnType>> => - createManyIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations); + createManyIntegrationMiddlewareForKinds>( + action, + getWidgetIntegrationConfig(widgetKind).supportedIntegrations, + ); /** * Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index bd54157aa..9d843d9b1 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -43,7 +43,7 @@ export const calendarRouter = createTRPCRouter({ name: integration.name, kind: integration.kind, }, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts index 38d4ea89b..390500830 100644 --- a/packages/api/src/router/widgets/firewall.ts +++ b/packages/api/src/router/widgets/firewall.ts @@ -11,23 +11,36 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall"); -const queryFirewall = < - THandler extends { - handler: (integration: any, input: any) => { getDataAsync: () => Promise<{ data: any; timestamp: Date }> }; - }, ->( - handler: THandler, - createFallback: () => Awaited["getDataAsync"]>>["data"], -) => +type FirewallIntegration = Parameters[0]; + +interface FirewallHandler { + handler: ( + integration: FirewallIntegration, + input: Record, + ) => { getDataAsync: () => Promise<{ data: TData; timestamp: Date }> }; +} + +interface FirewallQueryResult { + integration: { + id: string; + name: string; + kind: FirewallIntegration["kind"]; + updatedAt: Date; + }; + summary: TData; + error: string | undefined; +} + +const queryFirewall = (handler: FirewallHandler, createFallback: () => TData) => publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) => settleIntegrationQueries( ctx.integrations, - async (integration) => { + async (integration): Promise> => { const { data, timestamp } = await handler.handler(integration, {}).getDataAsync(); return { integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, - summary: data as Awaited["getDataAsync"]>>["data"], - error: undefined as string | undefined, + summary: data, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index a433d4f0f..08a8fcb0f 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -21,7 +21,7 @@ export const indexerManagerRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, indexers, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/media-organizer.ts b/packages/api/src/router/widgets/media-organizer.ts index 5d34e1563..2577b332e 100644 --- a/packages/api/src/router/widgets/media-organizer.ts +++ b/packages/api/src/router/widgets/media-organizer.ts @@ -27,7 +27,7 @@ export const mediaOrganizerRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, ...data, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index f0ac9cce0..554dcbb99 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -42,7 +42,7 @@ export const mediaRequestsRouter = createTRPCRouter({ return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data, - error: undefined as string | undefined, + error: undefined, }; }, { @@ -98,8 +98,8 @@ export const mediaRequestsRouter = createTRPCRouter({ const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, - data: { ...data, stats: data.stats as typeof data.stats | null }, - error: undefined as string | undefined, + data, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 619988e1b..7b7b7d294 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -29,7 +29,7 @@ export const mediaServerRouter = createTRPCRouter({ integrationName: integration.name, integrationKind: integration.kind, sessions: data, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts index 94237bb20..eacd66015 100644 --- a/packages/api/src/router/widgets/notifications.ts +++ b/packages/api/src/router/widgets/notifications.ts @@ -22,7 +22,7 @@ export const notificationsRouter = createTRPCRouter({ updatedAt: timestamp, }, data, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/speedtest-tracker.ts b/packages/api/src/router/widgets/speedtest-tracker.ts index 578e4bc7d..3795fc9a5 100644 --- a/packages/api/src/router/widgets/speedtest-tracker.ts +++ b/packages/api/src/router/widgets/speedtest-tracker.ts @@ -18,9 +18,9 @@ export const speedtestTrackerRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, integrationUrl: integration.url, - dashboard: data as typeof data | null, + dashboard: data, updatedAt: timestamp, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/tracearr.ts b/packages/api/src/router/widgets/tracearr.ts index e2e02273a..10e4af250 100644 --- a/packages/api/src/router/widgets/tracearr.ts +++ b/packages/api/src/router/widgets/tracearr.ts @@ -18,9 +18,9 @@ export const tracearrRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, integrationUrl: integration.url, - dashboard: data as typeof data | null, + dashboard: data, updatedAt: timestamp, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/traefik.ts b/packages/api/src/router/widgets/traefik.ts index 6109d25b9..5080dc6e9 100644 --- a/packages/api/src/router/widgets/traefik.ts +++ b/packages/api/src/router/widgets/traefik.ts @@ -25,9 +25,9 @@ export const traefikRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, integrationUrl: integration.url, - dashboard: data as typeof data | null, + dashboard: data, updatedAt: timestamp, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/ups.ts b/packages/api/src/router/widgets/ups.ts index c0eca54ae..0eec9a943 100644 --- a/packages/api/src/router/widgets/ups.ts +++ b/packages/api/src/router/widgets/ups.ts @@ -18,7 +18,7 @@ export const upsRouter = createTRPCRouter({ integrationUrl: integration.url, summaries: data, updatedAt: timestamp, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts index 8d377cd06..fe1450c70 100644 --- a/packages/api/src/router/widgets/uptime-kuma.ts +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -18,9 +18,9 @@ export const uptimeKumaRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, integrationUrl: integration.url, - dashboard: data as typeof data | null, + dashboard: data, updatedAt: timestamp, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts index 72a1bbd3b..a04fdab2c 100644 --- a/packages/api/src/router/widgets/vpn.ts +++ b/packages/api/src/router/widgets/vpn.ts @@ -19,8 +19,8 @@ export const vpnRouter = createTRPCRouter({ kind: integration.kind, updatedAt: timestamp, }, - summary: data as typeof data | null, - error: undefined as string | undefined, + summary: data, + error: undefined, }; }, { diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index c095c7715..9042f8e96 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -15,18 +15,18 @@ interface IntegrationLike { kind: string; } -interface Options { - fallback?: (integration: TIntegration, error: unknown) => TResult; +interface Options { + fallback?: (integration: TIntegration, error: unknown) => TFallback; throwOnAllFailures?: boolean; } -export async function settleIntegrationQueries( +export async function settleIntegrationQueries( integrations: TIntegration[], fn: (integration: TIntegration) => Promise, - options?: Options, -): Promise { + options?: Options, +): Promise<(TResult | TFallback)[]> { const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); - const results: TResult[] = []; + const results: (TResult | TFallback)[] = []; const errors: unknown[] = []; settled.forEach((result, index) => { diff --git a/packages/custom-widgets/scripts/ai-assistant-evaluation.ts b/packages/custom-widgets/scripts/ai-assistant-evaluation.ts index 6df79652f..7b11e6ac3 100644 --- a/packages/custom-widgets/scripts/ai-assistant-evaluation.ts +++ b/packages/custom-widgets/scripts/ai-assistant-evaluation.ts @@ -585,11 +585,11 @@ async function runAssistantAttempt(args: { process.stdout.write(` tool: ${toolCall.function.name}\n`); let output: unknown; try { - const parsed = JSON.parse(toolCall.function.arguments) as unknown; - const input = - parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : {}; + const parsed: unknown = JSON.parse(toolCall.function.arguments); + let input: Record = {}; + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + input = Object.fromEntries(Object.entries(parsed)); + } output = executeAssistantEvaluationTool(args.testCase, state, toolCall.function.name, input); } catch (error) { output = { error: error instanceof Error ? error.message : "Tool input was not valid JSON" }; diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts index 4d2d771db..f279f0684 100644 --- a/packages/custom-widgets/src/core/ai-prompt.ts +++ b/packages/custom-widgets/src/core/ai-prompt.ts @@ -219,10 +219,13 @@ function redactValue(value: unknown, path: Array = []): unknown }), ); if (typeof value === "string") { - const key = typeof path.at(-1) === "string" ? (path.at(-1) as string) : ""; + const lastPathPart = path.at(-1); + let key = ""; + if (typeof lastPathPart === "string") key = lastPathPart; if (["sources", "requests", "options"].includes(key)) { try { - return redactValue(JSON.parse(value) as unknown, path); + const parsed: unknown = JSON.parse(value); + return redactValue(parsed, path); } catch { /* Keep invalid editor JSON useful. */ } @@ -236,7 +239,8 @@ function redactValue(value: unknown, path: Array = []): unknown function redactResponse(value: string) { try { - return JSON.stringify(redactValue(JSON.parse(value) as unknown), null, 2); + const parsed: unknown = JSON.parse(value); + return JSON.stringify(redactValue(parsed), null, 2); } catch { return truncatePromptText(redactText(value), 1_500); } diff --git a/packages/custom-widgets/src/jsx/runtime-components.tsx b/packages/custom-widgets/src/jsx/runtime-components.tsx index 414afb0c5..12a7a825a 100644 --- a/packages/custom-widgets/src/jsx/runtime-components.tsx +++ b/packages/custom-widgets/src/jsx/runtime-components.tsx @@ -18,7 +18,7 @@ import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy"; import { sanitizeCustomJsxProps } from "./safe-properties"; -type Namespace = Readonly>; +type Namespace = object; function resolveExport(namespace: Namespace, name: string): ComponentType | undefined { let value: unknown = namespace; @@ -26,7 +26,7 @@ function resolveExport(namespace: Namespace, name: string): ComponentType if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) { return undefined; } - value = (value as Record)[segment]; + value = Reflect.get(value, segment); } return typeof value === "function" || (typeof value === "object" && value !== null) ? (value as ComponentType) @@ -222,9 +222,9 @@ export interface CustomJsxComponentAdapters { export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record> { const namespaces: Record = { - "@mantine/core": Core as unknown as Namespace, - "@mantine/charts": Charts as unknown as Namespace, - "@mantine/dates": Dates as unknown as Namespace, + "@mantine/core": Core, + "@mantine/charts": Charts, + "@mantine/dates": Dates, }; const components: Record> = {}; for (const descriptor of enabledCustomJsxComponents) { @@ -242,7 +242,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): components[descriptor.name] = wrap(descriptor.name, component, additions); } } - const core = Core as unknown as Namespace; + const core: Namespace = Core; for (const name of [ "Notification", "LoadingOverlay", diff --git a/packages/custom-widgets/src/workbench/code-editor.tsx b/packages/custom-widgets/src/workbench/code-editor.tsx index ee0f64c09..91f522aba 100644 --- a/packages/custom-widgets/src/workbench/code-editor.tsx +++ b/packages/custom-widgets/src/workbench/code-editor.tsx @@ -260,7 +260,8 @@ function EditorLoader() { function formatCode(value: string, language: "jsx" | "json" | "css") { if (language === "json") { try { - return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`; + const parsed: unknown = JSON.parse(value); + return `${JSON.stringify(parsed, null, 2)}\n`; } catch { return value; } diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index c22f25db7..68b27ad59 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -27,7 +27,10 @@ export const integrationSecretKindObject = { slug: { isPublic: true, multiline: false }, } satisfies Record; -export const integrationSecretKinds = Object.keys(integrationSecretKindObject) as IntegrationSecretKind[]; +const isIntegrationSecretKind = (value: string): value is IntegrationSecretKind => + Object.hasOwn(integrationSecretKindObject, value); + +export const integrationSecretKinds = Object.keys(integrationSecretKindObject).filter(isIntegrationSecretKind); interface IntegrationFeatureMetadata { docker?: { @@ -40,7 +43,7 @@ interface IntegrationFeatureMetadata { }; } -interface integrationDefinition { +interface IntegrationDefinition { name: string; iconUrl: string; secretKinds: readonly [readonly IntegrationSecretKind[], ...(readonly IntegrationSecretKind[])[]]; @@ -570,9 +573,17 @@ export const integrationDefs = { onboarding: { hidden: true }, }, }, -} as const satisfies Record; +} as const satisfies Record; -export const integrationKinds = Object.keys(integrationDefs) as AtLeastOneOf; +const isIntegrationKind = (value: string): value is IntegrationKind => Object.hasOwn(integrationDefs, value); +const integrationKindValues = Object.keys(integrationDefs).filter(isIntegrationKind); +const firstIntegrationKind = integrationKindValues[0]; +if (!firstIntegrationKind) throw new Error("At least one integration definition is required"); + +export const integrationKinds: AtLeastOneOf = [ + firstIntegrationKind, + ...integrationKindValues.slice(1), +]; export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl; @@ -607,7 +618,7 @@ export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | }; export const getIntegrationDockerMetadata = (kind: IntegrationKind) => { - const definition: integrationDefinition = integrationDefs[kind]; + const definition: IntegrationDefinition = integrationDefs[kind]; return { aliases: definition.features?.docker?.aliases ?? [], discoverable: definition.features?.docker?.discoverable ?? true, @@ -615,7 +626,7 @@ export const getIntegrationDockerMetadata = (kind: IntegrationKind) => { }; export const getIntegrationOnboardingMetadata = (kind: IntegrationKind) => { - const definition: integrationDefinition = integrationDefs[kind]; + const definition: IntegrationDefinition = integrationDefs[kind]; return { featuredOrder: definition.features?.onboarding?.featuredOrder ?? null, hidden: definition.features?.onboarding?.hidden ?? false, @@ -646,9 +657,12 @@ export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: Integratio * @returns Partial list of integration kinds */ export const getIntegrationKindsByCategory = (category: TCategory) => { - return (Object.keys(integrationDefs) as IntegrationKind[]).filter((integration) => - integrationDefs[integration].category.some((defCategory) => defCategory === category), - ) as AtLeastOneOf>; + const matchesCategory = (integration: IntegrationKind): integration is IntegrationKindByCategory => + integrationDefs[integration].category.some((definitionCategory) => definitionCategory === category); + const matches = integrationKinds.filter(matchesCategory); + const firstMatch = matches[0]; + if (!firstMatch) throw new Error(`No integration definition uses the ${category} category`); + return [firstMatch, ...matches.slice(1)] satisfies AtLeastOneOf>; }; /** @@ -658,10 +672,8 @@ export type IntegrationKindByCategory = { [Key in keyof typeof integrationDefs]: TCategory extends (typeof integrationDefs)[Key]["category"][number] ? Key : never; -}[keyof typeof integrationDefs] extends infer U - ? //Needed to simplify the type when using it - U - : never; +}[keyof typeof integrationDefs] & + IntegrationKind; export type IntegrationSecretKind = keyof typeof integrationSecretKindObject; export type IntegrationKind = keyof typeof integrationDefs; diff --git a/packages/integrations/src/base/errors/decorator.ts b/packages/integrations/src/base/errors/decorator.ts index 9b9148da7..0ecbccc05 100644 --- a/packages/integrations/src/base/errors/decorator.ts +++ b/packages/integrations/src/base/errors/decorator.ts @@ -14,6 +14,8 @@ import { integrationJsonParseErrorHandler, integrationZodParseErrorHandler } fro const logger = createLogger({ module: "handleIntegrationErrors" }); const loggedIntegrationErrors = new WeakSet(); +// ponytail: TypeScript requires `any[]` for mixin constructors (TS2545). The +// decorator returns the same constructor type T, so callers retain exact args. // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any type AbstractConstructor = abstract new (...args: any[]) => T; diff --git a/packages/redis/src/lib/channel-subscription-tracker.ts b/packages/redis/src/lib/channel-subscription-tracker.ts index fb70633a2..4eaee5e16 100644 --- a/packages/redis/src/lib/channel-subscription-tracker.ts +++ b/packages/redis/src/lib/channel-subscription-tracker.ts @@ -3,7 +3,7 @@ import { randomUUID } from "crypto"; import type { MaybePromise } from "@homarr/common/types"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { createRedisConnection } from "./connection"; +import { createRedisConnection, requireRedisConnection } from "./connection"; const logger = createLogger({ module: "channelSubscriptionTracker" }); @@ -63,7 +63,9 @@ export class ChannelSubscriptionTracker { // Subscribe when this channel has no active or pending Redis subscription. if (!ready) { logger.debug("Subscribing to redis channel", { channel: channelName }); - ready = this.redis.subscribe(channelName).then(() => undefined); + ready = requireRedisConnection(this.redis) + .subscribe(channelName) + .then(() => undefined); this.subscriptionReadiness.set(channelName, ready); void ready.catch(() => { if (this.subscriptionReadiness.get(channelName) === ready) { @@ -91,7 +93,7 @@ export class ChannelSubscriptionTracker { } logger.debug("Unsubscribing from redis channel", { channel: channelName }); - void this.redis.unsubscribe(channelName); + void requireRedisConnection(this.redis).unsubscribe(channelName); this.subscriptions.delete(channelName); this.subscriptionReadiness.delete(channelName); }; @@ -104,7 +106,7 @@ export class ChannelSubscriptionTracker { */ private static activateListener() { logger.debug("Activating listener"); - this.redis.on("message", (channel, message) => { + requireRedisConnection(this.redis).on("message", (channel, message) => { const channelSubscriptions = this.subscriptions.get(channel); if (!channelSubscriptions) { logger.warn("Received message on unknown channel", { channel }); diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 942599e89..15f5405ae 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -5,7 +5,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ChannelSubscriptionTracker } from "./channel-subscription-tracker"; -import { createRedisConnection } from "./connection"; +import { createRedisConnection, requireRedisConnection } from "./connection"; const publisher = createRedisConnection(); const lastDataClient = createRedisConnection(); @@ -26,11 +26,13 @@ export const createSubPubChannel = (name: string, { persist }: { persist: */ subscribe: (callback: (data: TData) => void) => { if (persist) { - void lastDataClient.get(lastChannelName).then((data) => { - if (data) { - callback(superjson.parse(data)); - } - }); + void requireRedisConnection(lastDataClient) + .get(lastChannelName) + .then((data) => { + if (data) { + callback(superjson.parse(data)); + } + }); } return ChannelSubscriptionTracker.subscribe(channelName, (message) => { callback(superjson.parse(message)); @@ -42,12 +44,12 @@ export const createSubPubChannel = (name: string, { persist }: { persist: */ publishAsync: async (data: TData) => { if (persist) { - await lastDataClient.set(lastChannelName, superjson.stringify(data)); + await requireRedisConnection(lastDataClient).set(lastChannelName, superjson.stringify(data)); } - await publisher.publish(channelName, superjson.stringify(data)); + await requireRedisConnection(publisher).publish(channelName, superjson.stringify(data)); }, getLastDataAsync: async () => { - const data = await lastDataClient.get(lastChannelName); + const data = await requireRedisConnection(lastDataClient).get(lastChannelName); return data ? superjson.parse(data) : null; }, }; @@ -68,7 +70,7 @@ export const createListChannel = (name: string) => { * @returns an array of all items */ getAllAsync: async () => { - const items = await getSetClient.lrange(listChannelName, 0, -1); + const items = await requireRedisConnection(getSetClient).lrange(listChannelName, 0, -1); return items.map((item) => superjson.parse(item)); }, /** @@ -76,20 +78,20 @@ export const createListChannel = (name: string) => { * @param item item to remove */ removeAsync: async (item: TItem) => { - await getSetClient.lrem(listChannelName, 0, superjson.stringify(item)); + await requireRedisConnection(getSetClient).lrem(listChannelName, 0, superjson.stringify(item)); }, /** * Clear all items from the channels list */ clearAsync: async () => { - await getSetClient.del(listChannelName); + await requireRedisConnection(getSetClient).del(listChannelName); }, /** * Add an item to the channels list * @param item item to add */ addAsync: async (item: TItem) => { - await getSetClient.lpush(listChannelName, superjson.stringify(item)); + await requireRedisConnection(getSetClient).lpush(listChannelName, superjson.stringify(item)); }, }; }; @@ -105,7 +107,7 @@ export const createGetSetChannel = (name: string) => { * @returns data or null if not found */ getAsync: async () => { - const data = await getSetClient.get(name); + const data = await requireRedisConnection(getSetClient).get(name); return data ? superjson.parse(data) : null; }, /** @@ -116,23 +118,23 @@ export const createGetSetChannel = (name: string) => { setAsync: async (data: TData, options?: { ttlSeconds?: number; ttlMs?: number }) => { if (options?.ttlMs !== undefined) { if (options.ttlMs <= 0) { - await getSetClient.del(name); + await requireRedisConnection(getSetClient).del(name); return; } - await getSetClient.set(name, superjson.stringify(data), "PX", options.ttlMs); + await requireRedisConnection(getSetClient).set(name, superjson.stringify(data), "PX", options.ttlMs); return; } if (options?.ttlSeconds) { - await getSetClient.set(name, superjson.stringify(data), "EX", options.ttlSeconds); + await requireRedisConnection(getSetClient).set(name, superjson.stringify(data), "EX", options.ttlSeconds); return; } - await getSetClient.set(name, superjson.stringify(data)); + await requireRedisConnection(getSetClient).set(name, superjson.stringify(data)); }, /** * Remove data from the channel */ removeAsync: async () => { - await getSetClient.del(name); + await requireRedisConnection(getSetClient).del(name); }, }; }; @@ -147,14 +149,14 @@ export const createLockChannel = (name: string) => { return { acquireAsync: async (ttlSeconds: number) => { const token = createId(); - const client = getSetClient as typeof getSetClient | null; + const client = getSetClient; if (!client) return token; const result = await client.set(name, token, "EX", ttlSeconds, "NX"); return result === "OK" ? token : null; }, releaseAsync: async (token: string) => { - const client = getSetClient as typeof getSetClient | null; + const client = getSetClient; if (!client) return; await client.eval( @@ -286,7 +288,7 @@ const getCachedGenerationAsync = async ({ }; const advanceIntegrationCacheGenerationAsync = async (integrationId: string) => { - const client = getSetClient as typeof getSetClient | null; + const client = getSetClient; if (!client) return null; const results = await withCacheGenerationRedisTimeoutAsync( @@ -304,7 +306,7 @@ const advanceIntegrationCacheGenerationAsync = async (integrationId: string) => }; const advanceIntegrationResponseCacheGenerationAsync = async (integrationId: string) => { - const client = getSetClient as typeof getSetClient | null; + const client = getSetClient; if (!client) return null; return String(await withCacheGenerationRedisTimeoutAsync(client.incr(integrationCacheGenerationKey(integrationId)))); @@ -354,7 +356,7 @@ export const getIntegrationCacheGenerationAsync = async ( return generation; } - const client = getSetClient as typeof getSetClient | null; + const client = getSetClient; return await getCachedGenerationAsync({ cacheKey, readAsync: async () => { @@ -418,7 +420,7 @@ const pendingWidgetCacheInvalidations = new Map(); const MAX_PENDING_WIDGET_CACHE_INVALIDATIONS = 1000; const advanceWidgetCacheGenerationAsync = async (namespace: string) => { - const client = getSetClient as typeof getSetClient | null; + const client = getSetClient; if (!client) return null; return String(await withCacheGenerationRedisTimeoutAsync(client.incr(widgetCacheGenerationKey(namespace)))); @@ -446,7 +448,7 @@ export const getWidgetCacheGenerationAsync = async (namespace: string): Promise< return generation; } - const client = getSetClient as typeof getSetClient | null; + const client = getSetClient; return await getCachedGenerationAsync({ cacheKey, readAsync: async () => { @@ -488,11 +490,11 @@ export const invalidateWidgetCache = (namespace: string): void => { */ export const createChannelEventHistoryOld = (channelName: string, maxElements = 15) => { const popElementsOverMaxAsync = async () => { - const length = await getSetClient.llen(channelName); + const length = await requireRedisConnection(getSetClient).llen(channelName); if (length <= maxElements) { return; } - await getSetClient.ltrim(channelName, 0, maxElements - 1); + await requireRedisConnection(getSetClient).ltrim(channelName, 0, maxElements - 1); }; return { @@ -502,33 +504,41 @@ export const createChannelEventHistoryOld = (channelName: string, maxElem }); }, publishAndPushAsync: async (data: TData) => { - await publisher.publish(channelName, superjson.stringify(data)); - await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() })); + await requireRedisConnection(publisher).publish(channelName, superjson.stringify(data)); + await requireRedisConnection(getSetClient).lpush( + channelName, + superjson.stringify({ data, timestamp: new Date() }), + ); await popElementsOverMaxAsync(); }, pushAsync: async (data: TData) => { - await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() })); + await requireRedisConnection(getSetClient).lpush( + channelName, + superjson.stringify({ data, timestamp: new Date() }), + ); await popElementsOverMaxAsync(); }, clearAsync: async () => { - await getSetClient.del(channelName); + await requireRedisConnection(getSetClient).del(channelName); }, getLastAsync: async () => { - const length = await getSetClient.llen(channelName); - const data = await getSetClient.lrange(channelName, length - 1, length); + const client = requireRedisConnection(getSetClient); + const length = await client.llen(channelName); + const data = await client.lrange(channelName, length - 1, length); if (data.length !== 1) return null; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return superjson.parse<{ data: TData; timestamp: Date }>(data[0]!); }, getSliceAsync: async (startIndex: number, endIndex: number) => { - const range = await getSetClient.lrange(channelName, startIndex, endIndex); + const range = await requireRedisConnection(getSetClient).lrange(channelName, startIndex, endIndex); return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item)); }, getSliceUntilTimeAsync: async (time: Date) => { - const length = await getSetClient.llen(channelName); + const client = requireRedisConnection(getSetClient); + const length = await client.llen(channelName); const items: TData[] = []; - const itemsInCollection = await getSetClient.lrange(channelName, 0, length - 1); + const itemsInCollection = await client.lrange(channelName, 0, length - 1); for (let i = 0; i < length - 1; i++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -541,7 +551,7 @@ export const createChannelEventHistoryOld = (channelName: string, maxElem return items; }, getLengthAsync: async () => { - return await getSetClient.llen(channelName); + return await requireRedisConnection(getSetClient).llen(channelName); }, name: channelName, }; @@ -559,11 +569,11 @@ type WithId = TItem & { _id: string }; export const createQueueChannel = (name: string) => { const queueChannelName = `queue:${name}`; const getDataAsync = async () => { - const data = await queueClient.get(queueChannelName); + const data = await requireRedisConnection(queueClient).get(queueChannelName); return data ? superjson.parse[]>(data) : []; }; const setDataAsync = async (data: WithId[]) => { - await queueClient.set(queueChannelName, superjson.stringify(data)); + await requireRedisConnection(queueClient).set(queueChannelName, superjson.stringify(data)); }; return { @@ -610,5 +620,5 @@ export const createQueueChannel = (name: string) => { }; export const handshakeAsync = async () => { - await getSetClient.hello(); + await requireRedisConnection(getSetClient).hello(); }; diff --git a/packages/redis/src/lib/connection.ts b/packages/redis/src/lib/connection.ts index ba945de33..a97d0572a 100644 --- a/packages/redis/src/lib/connection.ts +++ b/packages/redis/src/lib/connection.ts @@ -7,9 +7,13 @@ import { createRedisClient } from "@homarr/core/infrastructure/redis"; */ export const createRedisConnection = () => { if (Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS)) { - // Return null if we are in CI as we don't want to connect to Redis - return null as unknown as RedisClient; + return null; } return createRedisClient(); }; + +export const requireRedisConnection = (client: RedisClient | null): RedisClient => { + if (!client) throw new Error("Redis is unavailable in this process"); + return client; +}; diff --git a/packages/request-handler/src/lib/shared-cache.ts b/packages/request-handler/src/lib/shared-cache.ts index d90032924..99e734570 100644 --- a/packages/request-handler/src/lib/shared-cache.ts +++ b/packages/request-handler/src/lib/shared-cache.ts @@ -18,14 +18,16 @@ const logger = createLogger({ module: "sharedRequestCache" }); const REDIS_OPERATION_TIMEOUT_MS = 250; const REFRESH_LOCK_TTL_SECONDS = 15; const namespacePattern = /^[a-z0-9][a-z0-9:-]*$/; +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); const canonicalize = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(canonicalize); - if (!value || typeof value !== "object") return value; + if (!isRecord(value)) return value; const result: Record = {}; for (const key of Object.keys(value).toSorted()) { - result[key] = canonicalize((value as Record)[key]); + result[key] = canonicalize(value[key]); } return result; }; @@ -46,14 +48,13 @@ const withTimeoutAsync = async (promise: Promise, durationMs: number): Pro }; const isCacheEntry = (value: unknown): value is CacheEntry => { - if (!value || typeof value !== "object") return false; - const entry = value as Partial>; + if (!isRecord(value)) return false; return ( - entry.timestamp instanceof Date && - typeof entry.expiresAt === "number" && - Number.isFinite(entry.expiresAt) && - typeof entry.staleUntil === "number" && - Number.isFinite(entry.staleUntil) + value.timestamp instanceof Date && + typeof value.expiresAt === "number" && + Number.isFinite(value.expiresAt) && + typeof value.staleUntil === "number" && + Number.isFinite(value.staleUntil) ); }; @@ -94,7 +95,7 @@ const createRedisSharedCache = ({ isShared: initialIsShared = true, }: RedisSharedCacheOptions): SharedCacheAdapter => { const logMetadata = { cacheNamespace: namespace, cacheKind, ...metadata }; - const payloadChannel = createGetSetChannel( + const payloadChannel = createGetSetChannel>( `${cacheKind}-cache:payload:v1:${namespace}:${generation}:${cacheIdentity}`, ); const lock = createLockChannel(`${cacheKind}-cache:lock:v1:${namespace}:${generation}:${cacheIdentity}`); @@ -119,7 +120,7 @@ const createRedisSharedCache = ({ getAsync: async () => { const value = await runRedisOperationAsync("payload-read", () => payloadChannel.getAsync(), undefined); if (value === undefined || value === null) return value; - if (isCacheEntry(value)) return value as CacheEntry; + if (isCacheEntry(value)) return value; logger.warn("Ignored invalid Redis response cache entry", { ...logMetadata, diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index 908465523..099909374 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -2567,7 +2567,7 @@ } }, "footer": { - "streams": "", + "streams": "{count, plural, one {# active stream} other {# active streams}}", "totalBitrate": "" }, "items": { diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index c2e365f6c..bd5f00e00 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -74,10 +74,9 @@ const loadRegisteredWidgetModule = createRetryableLoader( new Map Promise>(objectEntries(widgetModuleLoaders)), ); -// ponytail: TypeScript cannot express a Map whose value depends on its key. This -// private assertion restores only the fixed shapes above; the manifest alignment -// test checks module.definition.kind. Its ceiling is runtime-only mismatch -// detection, so remove it if TypeScript gains dependent Map value types. +// ponytail: TypeScript cannot correlate a generic key with the matching value +// in a Map. The registry itself is checked against WidgetImports, so this +// assertion only restores that already-verified relationship after caching. const correlateWidgetPromise = >( _kind: TKind, _type: TType, diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 2900d1043..185460dd6 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -425,9 +425,7 @@ export default function MediaServerWidget({ - {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { - count: flatSessions.length, - })} + {t("footer.streams", { count: flatSessions.length })} {totalBitrateLabel && (isAdvanced || width >= 300) && ( <> diff --git a/packages/widgets/src/registry.ts b/packages/widgets/src/registry.ts index be5ab761f..8a9a6e3d0 100644 --- a/packages/widgets/src/registry.ts +++ b/packages/widgets/src/registry.ts @@ -60,11 +60,6 @@ import type * as vpn from "./vpn"; import type * as weather from "./weather"; import type * as wud from "./wud"; -type WidgetModule = { - definition: unknown; - componentLoader: () => Promise; -}; - // Keep these imports explicit so Next.js and Turbopack can discover every widget // module without loading any widget definition or component eagerly. export const widgetModuleLoaders = { @@ -127,7 +122,7 @@ export const widgetModuleLoaders = { customApi: () => import("./custom-api"), weather: () => import("./weather"), wud: () => import("./wud"), -} satisfies Record Promise>; +} satisfies { [TKind in WidgetKind]: () => Promise }; export type WidgetImports = { clock: typeof clock;