refactor: tighten TypeScript boundaries

This commit is contained in:
ajnart committed 2026-08-24 09:38:30 +02:00
1 parent ad0120fe01
commit 24e6e4a47d
38 files changed
+246 -190

No files matched your search

+2 -2
View File
@@ -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;
@@ -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;
@@ -74,7 +74,8 @@ export function parseSources(value: string): Array<CustomWidgetSource & { id: st
export function parseJson(value: string): unknown {
try {
return JSON.parse(value) as unknown;
const parsed: unknown = JSON.parse(value);
return parsed;
} catch {
return null;
}
@@ -78,13 +78,10 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
() => 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<string, unknown>) : {},
),
[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") },
@@ -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"));
@@ -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({
<Stack gap="sm">
<QueryValuesEditor
value={isRecord(request.query) ? request.query : {}}
optionNames={Object.keys(
isRecord(parseJson(form.values.options))
? (parseJson(form.values.options) as Record<string, unknown>)
: {},
)}
optionNames={optionNames}
allowParams={trigger === "manual" || kind === "action"}
onChange={(query) => update(index, id, { ...request, query })}
/>
<RequestBodyEditor
id={id}
value={request.body}
optionNames={Object.keys(
isRecord(parseJson(form.values.options))
? (parseJson(form.values.options) as Record<string, unknown>)
: {},
)}
optionNames={optionNames}
allowParams={trigger === "manual" || kind === "action"}
onChange={(body) => update(index, id, { ...request, body })}
/>
@@ -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;
}
@@ -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));
};
+5 -4
View File
@@ -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<string, Record<string, unknown>>;
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,
+36 -20
View File
@@ -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 = <TKind extends IntegrationKind>(
action: IntegrationAction,
kinds: readonly TKind[],
@@ -75,21 +83,19 @@ const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
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 = <TKind extends IntegrationKind>(
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 = <TWidgetKind extends WidgetK
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
createOneIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations);
createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
action,
getWidgetIntegrationConfig(widgetKind).supportedIntegrations,
);
export const createManyWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
createManyIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations);
createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
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
+1 -1
View File
@@ -43,7 +43,7 @@ export const calendarRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
},
error: undefined as string | undefined,
error: undefined,
};
},
{
+24 -11
View File
@@ -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<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
) =>
type FirewallIntegration = Parameters<typeof firewallCpuRequestHandler.handler>[0];
interface FirewallHandler<TData> {
handler: (
integration: FirewallIntegration,
input: Record<string, never>,
) => { getDataAsync: () => Promise<{ data: TData; timestamp: Date }> };
}
interface FirewallQueryResult<TData> {
integration: {
id: string;
name: string;
kind: FirewallIntegration["kind"];
updatedAt: Date;
};
summary: TData;
error: string | undefined;
}
const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: () => TData) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(
ctx.integrations,
async (integration) => {
async (integration): Promise<FirewallQueryResult<TData>> => {
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<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
error: undefined as string | undefined,
summary: data,
error: undefined,
};
},
{
@@ -21,7 +21,7 @@ export const indexerManagerRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
indexers,
error: undefined as string | undefined,
error: undefined,
};
},
{
@@ -27,7 +27,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
...data,
error: undefined as string | undefined,
error: undefined,
};
},
{
@@ -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,
};
},
{
@@ -29,7 +29,7 @@ export const mediaServerRouter = createTRPCRouter({
integrationName: integration.name,
integrationKind: integration.kind,
sessions: data,
error: undefined as string | undefined,
error: undefined,
};
},
{
@@ -22,7 +22,7 @@ export const notificationsRouter = createTRPCRouter({
updatedAt: timestamp,
},
data,
error: undefined as string | undefined,
error: undefined,
};
},
{
@@ -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,
};
},
{
+2 -2
View File
@@ -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,
};
},
{
+2 -2
View File
@@ -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,
};
},
{
+1 -1
View File
@@ -18,7 +18,7 @@ export const upsRouter = createTRPCRouter({
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
error: undefined as string | undefined,
error: undefined,
};
},
{
@@ -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,
};
},
{
+2 -2
View File
@@ -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,
};
},
{
+6 -6
View File
@@ -15,18 +15,18 @@ interface IntegrationLike {
kind: string;
}
interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult;
interface Options<TIntegration extends IntegrationLike, TFallback> {
fallback?: (integration: TIntegration, error: unknown) => TFallback;
throwOnAllFailures?: boolean;
}
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult, TFallback = TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>,
): Promise<TResult[]> {
options?: Options<TIntegration, TFallback>,
): 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) => {
@@ -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<string, unknown>)
: {};
const parsed: unknown = JSON.parse(toolCall.function.arguments);
let input: Record<string, unknown> = {};
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" };
@@ -219,10 +219,13 @@ function redactValue(value: unknown, path: Array<string | number> = []): 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<string | number> = []): 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);
}
@@ -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<Record<string, unknown>>;
type Namespace = object;
function resolveExport(namespace: Namespace, name: string): ComponentType<never> | undefined {
let value: unknown = namespace;
@@ -26,7 +26,7 @@ function resolveExport(namespace: Namespace, name: string): ComponentType<never>
if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) {
return undefined;
}
value = (value as Record<string, unknown>)[segment];
value = Reflect.get(value, segment);
}
return typeof value === "function" || (typeof value === "object" && value !== null)
? (value as ComponentType<never>)
@@ -222,9 +222,9 @@ export interface CustomJsxComponentAdapters {
export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record<string, ComponentType<never>> {
const namespaces: Record<string, Namespace> = {
"@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<string, ComponentType<never>> = {};
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",
@@ -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;
}
+25 -13
View File
@@ -27,7 +27,10 @@ export const integrationSecretKindObject = {
slug: { isPublic: true, multiline: false },
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
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<string, integrationDefinition>;
} as const satisfies Record<string, IntegrationDefinition>;
export const integrationKinds = Object.keys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
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<IntegrationKind> = [
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 = <TCategory extends IntegrationCategory>(category: TCategory) => {
return (Object.keys(integrationDefs) as IntegrationKind[]).filter((integration) =>
integrationDefs[integration].category.some((defCategory) => defCategory === category),
) as AtLeastOneOf<IntegrationKindByCategory<TCategory>>;
const matchesCategory = (integration: IntegrationKind): integration is IntegrationKindByCategory<TCategory> =>
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<IntegrationKindByCategory<TCategory>>;
};
/**
@@ -658,10 +672,8 @@ export type IntegrationKindByCategory<TCategory extends IntegrationCategory> = {
[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;
@@ -14,6 +14,8 @@ import { integrationJsonParseErrorHandler, integrationZodParseErrorHandler } fro
const logger = createLogger({ module: "handleIntegrationErrors" });
const loggedIntegrationErrors = new WeakSet<IntegrationError>();
// 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<T = {}> = abstract new (...args: any[]) => T;
@@ -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 });
+51 -41
View File
@@ -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 = <TData>(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 = <TData>(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<TData>(data) : null;
},
};
@@ -68,7 +70,7 @@ export const createListChannel = <TItem>(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<TItem>(item));
},
/**
@@ -76,20 +78,20 @@ export const createListChannel = <TItem>(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 = <TData>(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<TData>(data) : null;
},
/**
@@ -116,23 +118,23 @@ export const createGetSetChannel = <TData>(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<string, string>();
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 = <TData>(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 = <TData>(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 = <TData>(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> = TItem & { _id: string };
export const createQueueChannel = <TItem>(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<WithId<TItem>[]>(data) : [];
};
const setDataAsync = async (data: WithId<TItem>[]) => {
await queueClient.set(queueChannelName, superjson.stringify(data));
await requireRedisConnection(queueClient).set(queueChannelName, superjson.stringify(data));
};
return {
@@ -610,5 +620,5 @@ export const createQueueChannel = <TItem>(name: string) => {
};
export const handshakeAsync = async () => {
await getSetClient.hello();
await requireRedisConnection(getSetClient).hello();
};
+6 -2
View File
@@ -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;
};
@@ -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<string, unknown> =>
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<string, unknown> = {};
for (const key of Object.keys(value).toSorted()) {
result[key] = canonicalize((value as Record<string, unknown>)[key]);
result[key] = canonicalize(value[key]);
}
return result;
};
@@ -46,14 +48,13 @@ const withTimeoutAsync = async <T>(promise: Promise<T>, durationMs: number): Pro
};
const isCacheEntry = (value: unknown): value is CacheEntry<unknown> => {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<CacheEntry<unknown>>;
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 = <TData>({
isShared: initialIsShared = true,
}: RedisSharedCacheOptions): SharedCacheAdapter<TData> => {
const logMetadata = { cacheNamespace: namespace, cacheKind, ...metadata };
const payloadChannel = createGetSetChannel<unknown>(
const payloadChannel = createGetSetChannel<CacheEntry<TData>>(
`${cacheKind}-cache:payload:v1:${namespace}:${generation}:${cacheIdentity}`,
);
const lock = createLockChannel(`${cacheKind}-cache:lock:v1:${namespace}:${generation}:${cacheIdentity}`);
@@ -119,7 +120,7 @@ const createRedisSharedCache = <TData>({
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<TData>;
if (isCacheEntry(value)) return value;
logger.warn("Ignored invalid Redis response cache entry", {
...logMetadata,
+1 -1
View File
@@ -2567,7 +2567,7 @@
}
},
"footer": {
"streams": "",
"streams": "{count, plural, one {# active stream} other {# active streams}}",
"totalBitrate": ""
},
"items": {
+3 -4
View File
@@ -74,10 +74,9 @@ const loadRegisteredWidgetModule = createRetryableLoader(
new Map<WidgetKind, () => Promise<RegisteredWidgetModule>>(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 = <TKind extends WidgetKind, TType extends keyof WidgetManifestValue<TKind>>(
_kind: TKind,
_type: TType,
@@ -425,9 +425,7 @@ export default function MediaServerWidget({
<Group gap={6} wrap="nowrap">
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length,
})}
{t("footer.streams", { count: flatSessions.length })}
</Text>
{totalBitrateLabel && (isAdvanced || width >= 300) && (
<>
+1 -6
View File
@@ -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<unknown>;
};
// 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<WidgetKind, () => Promise<WidgetModule>>;
} satisfies { [TKind in WidgetKind]: () => Promise<WidgetImports[TKind]> };
export type WidgetImports = {
clock: typeof clock;