refactor(custom-widgets): extract domain package

This commit is contained in:
Thomas Camlong committed 2026-07-14 16:59:06 +02:00
1 parent c5703e7e53
commit 4c6ffcd0fa
167 files changed
+12282 -10033

No files matched your search

+12 -332
View File
@@ -1,342 +1,27 @@
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
import type { ComponentType } from "react";
import {
ActionIcon,
Button,
Card,
Center,
Code,
Flex,
Group,
Loader,
Progress,
ScrollArea,
SimpleGrid,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconAlertTriangle, IconCheck, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { Button, Center, Code, Loader, ScrollArea, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconPlayerPlay } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { createCustomWidgetDisplayComponents } from "@homarr/custom-widgets/runtime";
import type { WidgetComponentProps } from "../definition";
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
const valueSizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg", xl: "xl" };
function SingleValueDisplay({ data }: { data: Record<string, unknown> }) {
const size = valueSizeMap[(data.valueSize as string) ?? "lg"] ?? "lg";
const labelAbove = (data.labelPosition as string) === "above";
const label = data.label ? (
<Text c="dimmed" size="sm">
{String(data.label)}
</Text>
) : null;
return (
<Stack h="100%" align="center" justify="center" gap="xs">
{labelAbove && label}
<Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4}>
{String(data.value ?? "—")}
{data.unit ? ` ${data.unit}` : ""}
</Title>
{!labelAbove && label}
</Stack>
);
}
function KeyValueDisplay({ data }: { data: Record<string, unknown> }) {
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }>) ?? [];
const layout = (data.layout as string) ?? "list";
const columns = (data.columns as number) ?? 2;
if (layout === "grid") {
return (
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
{entries.map((entry, i) => (
<Stack key={i} align="center" gap={2}>
<Text size="sm" fw={600}>
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
<Text size="xs" c="dimmed">
{entry.label}
</Text>
</Stack>
))}
</SimpleGrid>
);
}
return (
<Stack h="100%" justify="center" gap="xs" p="sm">
{entries.map((entry, i) => (
<Group key={i} justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{entry.label}
</Text>
<Text size="sm" fw={600}>
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
</Group>
))}
</Stack>
);
}
function TableDisplay({ data }: { data: Record<string, unknown> }) {
const columns = (data.columns as string[]) ?? [];
const rows = (data.rows as unknown[][]) ?? [];
const striped = (data.striped as boolean) ?? true;
const compact = (data.compact as boolean) ?? false;
return (
<ScrollArea>
<Table striped={striped} highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{columns.map((col, i) => (
<Table.Th key={i} py={compact ? 4 : undefined}>
{col}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row, i) => (
<Table.Tr key={i}>
{row.map((cell, j) => (
<Table.Td key={j} py={compact ? 2 : undefined}>
{String(cell ?? "—")}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
);
}
function StatGridCard({
item,
cardStyle,
}: {
item: { label: string; unit: string; color: string; value: unknown };
cardStyle: string;
}) {
const { ref, height, width } = useElementSize();
const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38;
const bgMap: Record<string, string> = {
filled: `var(--mantine-color-${item.color}-light)`,
outline: "transparent",
subtle: `var(--mantine-color-${item.color}-light)`,
};
return (
<Card
ref={ref}
p="sm"
radius="md"
bg={bgMap[cardStyle] ?? bgMap.filled}
withBorder={cardStyle === "outline"}
h="100%"
style={{ flex: 1 }}
>
<Flex
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
>
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
<Text size="md" fw={700} ta="center" lh={1.1}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
{!hideLabel && (
<Text size="xs" c="dimmed" ta="center" tt="uppercase" lh={1.3} style={{ letterSpacing: "0.03em" }}>
{item.label}
</Text>
)}
</Flex>
</Flex>
</Card>
);
}
function StatGridDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; unit: string; color: string; value: unknown }>) ?? [];
const columns = (data.columns as number) ?? 2;
const cardStyle = (data.cardStyle as string) ?? "filled";
return (
<SimpleGrid
cols={columns}
spacing="xs"
p="xs"
h="100%"
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
>
{items.map((item, i) => (
<StatGridCard key={i} item={item} cardStyle={cardStyle} />
))}
</SimpleGrid>
);
}
function ProgressBarsDisplay({ data }: { data: Record<string, unknown> }) {
const bars = (data.bars as Array<{ label: string; unit: string; color: string; value: number; max?: number }>) ?? [];
const showPercentage = (data.showPercentage as boolean) ?? true;
const barSize = (data.barSize as string) ?? "md";
const sizeMap: Record<string, number> = { sm: 8, md: 14, lg: 22 };
return (
<Stack h="100%" justify="center" gap="sm" p="sm">
{bars.map((bar, i) => {
const max = bar.max ?? 100;
const pct = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
return (
<Stack key={i} gap={4}>
<Group justify="space-between" wrap="nowrap">
<Text size="xs" fw={500}>
{bar.label}
</Text>
<Text size="xs" c="dimmed">
{showPercentage ? `${pct.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`}
{bar.max !== undefined && showPercentage
? ` (${bar.value}/${max}${bar.unit ? ` ${bar.unit}` : ""})`
: ""}
</Text>
</Group>
<Progress value={pct} size={sizeMap[barSize] ?? 14} color={bar.color} radius="sm" />
</Stack>
);
})}
</Stack>
);
}
function StatusIndicatorDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }>) ?? [];
const layout = (data.layout as string) ?? "list";
const dotSize = (data.dotSize as string) ?? "md";
const dotSizeMap: Record<string, number> = { sm: 8, md: 10, lg: 14 };
const size = dotSizeMap[dotSize] ?? 10;
const renderItem = (item: { label: string; value: string; isGood: boolean }, i: number) => (
<Group key={i} gap="xs" wrap="nowrap">
<div
style={{
width: size,
height: size,
borderRadius: "50%",
backgroundColor: item.isGood ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
flexShrink: 0,
}}
/>
<Text size="sm" fw={500}>
{item.label}
</Text>
<Text size="xs" c="dimmed" ml="auto">
{item.value}
</Text>
</Group>
);
if (layout === "grid") {
return (
<SimpleGrid cols={2} spacing="xs" p="sm" h="100%">
{items.map(renderItem)}
</SimpleGrid>
);
}
return (
<Stack h="100%" justify="center" gap="xs" p="sm">
{items.map(renderItem)}
</Stack>
);
}
function CountGridDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; unit: string; value: unknown }>) ?? [];
const columns = (data.columns as number) ?? 2;
const valueSize = (data.valueSize as string) ?? "md";
const sizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg" };
return (
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
{items.map((item, i) => (
<Stack key={i} align="center" justify="center" gap={0}>
<Text size={sizeMap[valueSize] ?? "md"} fw={700} lh={1.2}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" ta="center" lh={1.3} style={{ letterSpacing: "0.03em" }}>
{item.label}
</Text>
</Stack>
))}
</SimpleGrid>
);
}
function openJsonInBrowser(json: unknown) {
const blob = new Blob([JSON.stringify(json, null, 2)], { type: "application/json" });
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl);
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
function RawDisplay({ data }: { data: Record<string, unknown> }) {
const maxHeight = (data.maxHeight as number) ?? 300;
const jsonString = JSON.stringify(data.data, null, 2);
return (
<Stack gap={4} p="xs">
<Group justify="flex-end">
<Tooltip label="Open in browser JSON viewer">
<ActionIcon variant="subtle" size="sm" onClick={() => openJsonInBrowser(data.data)}>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea mah={maxHeight}>
<Code block style={{ fontSize: 12 }}>
{jsonString}
</Code>
</ScrollArea>
</Stack>
);
}
function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("widget.customApi");
const { openConfirmModal } = useConfirmModal();
const executeMutation = clientApi.widget.customApi.executeBaseAction.useMutation();
const [lastSuccess, setLastSuccess] = useState(false);
const buttonLabel = (data.buttonLabel as string) ?? "Execute";
const buttonLabel = (data.buttonLabel as string) ?? t("execute");
const buttonColor = (data.buttonColor as string) ?? "blue";
const confirmText = (data.confirmText as string) || "";
const requiresConfirmation = data.requiresConfirmation === true;
@@ -390,18 +75,12 @@ function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
);
}
export const displayComponents: Record<string, ComponentType<{ data: Record<string, unknown> }>> = {
singleValue: SingleValueDisplay,
keyValue: KeyValueDisplay,
table: TableDisplay,
statGrid: StatGridDisplay,
progressBars: ProgressBarsDisplay,
statusIndicator: StatusIndicatorDisplay,
countGrid: CountGridDisplay,
raw: RawDisplay,
actionButton: ActionButtonDisplay,
customJsx: CustomJsxDisplay,
};
export const createDisplayComponents = (openJsonLabel: string) =>
createCustomWidgetDisplayComponents({
actionButton: ActionButtonDisplay,
customJsx: CustomJsxDisplay,
openJsonLabel,
});
export default function CustomApiWidget({ options, itemId, isEditMode }: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
@@ -444,6 +123,7 @@ function CustomApiWidgetInner({
const t = useScopedI18n("widget.customApi");
const tCustomWidget = useScopedI18n("customWidget");
const tCustomJsx = useScopedI18n("widget.customApi.customJsx");
const displayComponents = useMemo(() => createDisplayComponents(t("openJson")), [t]);
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
const intervalMs = Math.max(1000, safeInterval * 1000);
const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
@@ -9,11 +9,13 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import type { Root } from "react-dom/client";
import { customJsxComponentRegistry, customJsxExamples, enabledCustomJsxComponents } from "@homarr/definitions";
import { customJsxComponentRegistry, customJsxExamples, enabledCustomJsxComponents } from "@homarr/custom-widgets/core";
import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "@homarr/custom-widgets/jsx";
import CustomJsxDisplay from "./custom-jsx-display";
import { SAFE_BINDINGS, WHITELISTED_COMPONENTS } from "./jsx-whitelist";
import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "./safe-jsx-interpreter";
import { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist";
const WHITELISTED_COMPONENTS = createWhitelistedComponents({ copy: "Copy", copied: "Copied" });
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
@@ -377,6 +379,9 @@ describe("Custom JSX component registry", () => {
});
});
const renderTemplate = (template: string, data: Record<string, unknown> = {}) =>
renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings: SAFE_BINDINGS(data) });
describe("new collection/string methods", () => {
let container: HTMLDivElement;
let root: Root;
@@ -396,9 +401,6 @@ describe("new collection/string methods", () => {
return container.textContent ?? "";
};
const render = (template: string, data: Record<string, unknown> = {}) =>
renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings: SAFE_BINDINGS(data) });
it("find returns the first matching item", async () => {
const text = await renderAndGetText(`<Text>{data.items.find((x) => x.id === 2).name}</Text>`, {
items: [
@@ -450,7 +452,7 @@ describe("new collection/string methods", () => {
});
it("flat(2) throws SafeJsxError", () => {
expect(() => render(`<Text>{data.items.flat(2)}</Text>`, { items: [[1, [2]]] })).toThrow(SafeJsxError);
expect(() => renderTemplate(`<Text>{data.items.flat(2)}</Text>`, { items: [[1, [2]]] })).toThrow(SafeJsxError);
});
it("flat(1) flattens one level", async () => {
@@ -1,214 +1,47 @@
"use client";
import type { ErrorInfo, ReactNode } from "react";
import { Component, useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
import { useMemo } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomJsxRenderer, parseRequestCapabilities } from "@homarr/custom-widgets/runtime";
import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
import { renderSafeJsx } from "./safe-jsx-interpreter";
import type { CustomJsxRequestCapability } from "./widget-definition-context";
import { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist";
import { WidgetDefinitionProvider } from "./widget-definition-context";
const MAX_PARSE_ERRORS = 5;
function appendParseError(prev: string[], message: string): string[] {
if (prev.length >= MAX_PARSE_ERRORS) return prev;
if (prev.includes(message)) return prev;
return [...prev, message];
}
export const CUSTOM_JSX_METHOD_COLORS: Record<string, string> = {
GET: "blue",
POST: "orange",
PUT: "yellow",
DELETE: "red",
PATCH: "grape",
};
const CAPABILITY_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
const CAPABILITY_KINDS = new Set(["query", "action"]);
const CAPABILITY_PERMISSIONS = new Set(["view", "modify", "full"]);
function parseRequestCapabilities(value: unknown): CustomJsxRequestCapability[] {
if (!Array.isArray(value)) return [];
return value.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const record = candidate as Record<string, unknown>;
if (
typeof record.id !== "string" ||
typeof record.kind !== "string" ||
!CAPABILITY_KINDS.has(record.kind) ||
typeof record.method !== "string" ||
!CAPABILITY_METHODS.has(record.method) ||
typeof record.minimumBoardPermission !== "string" ||
!CAPABILITY_PERMISSIONS.has(record.minimumBoardPermission)
) {
return [];
}
return [record as unknown as CustomJsxRequestCapability];
});
}
interface RendererErrorBoundaryProps {
children: ReactNode;
fallback: (error: Error) => ReactNode;
onError: (error: Error) => void;
}
interface RendererErrorBoundaryState {
error: Error | null;
}
class RendererErrorBoundary extends Component<RendererErrorBoundaryProps, RendererErrorBoundaryState> {
public state: RendererErrorBoundaryState = { error: null };
public static getDerivedStateFromError(error: Error): RendererErrorBoundaryState {
return { error };
}
public componentDidCatch(error: Error, _info: ErrorInfo): void {
this.props.onError(error);
}
public render(): ReactNode {
return this.state.error ? this.props.fallback(this.state.error) : this.props.children;
}
}
function ErrorAlert({ error }: { error: Error }) {
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{error.message}</Text>
</Alert>
);
}
const renderErrorFallback = (error: Error) => <ErrorAlert error={error} />;
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("widget.customApi.customJsx");
const template = String(data.template ?? "");
const apiData = data.data;
const definitionId = String(data.widgetDefinitionId ?? "");
const itemId = typeof data.widgetItemId === "string" ? data.widgetItemId : undefined;
const previewSessionId = typeof data.previewSessionId === "string" ? data.previewSessionId : undefined;
const previewLiveActions = data.previewLiveActions === true;
const isEditMode = data.isEditMode === true;
const [parseErrors, setParseErrors] = useState<string[]>([]);
const bindings = useMemo(() => SAFE_BINDINGS(apiData), [apiData]);
const requestCapabilities = useMemo(
() => parseRequestCapabilities(data.requestCapabilities),
[data.requestCapabilities],
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
const components = useMemo(() => createWhitelistedComponents({ copy: t("copy"), copied: t("copied") }), [t]);
const renderer = (
<CustomJsxRenderer
template={String(data.template ?? "")}
data={data.data}
requestCapabilities={capabilities}
components={components}
createBindings={SAFE_BINDINGS}
messages={{
noTemplate: t("noTemplate"),
interactive: t("interactive"),
networkCapabilities: t("networkCapabilities"),
templateWarnings: (count) => t("templateWarnings", { count: String(count) }),
}}
/>
);
const rendered = useMemo(() => {
try {
const result = renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings });
return { ...result, error: null };
} catch (error) {
const normalized = error instanceof Error ? error : new Error(String(error));
return { node: null, warnings: [], error: normalized };
}
}, [bindings, template]);
useEffect(() => {
setParseErrors([]);
}, [template, bindings]);
const handleError = useCallback((error: Error) => {
setParseErrors((prev) => appendParseError(prev, error.message));
}, []);
useEffect(() => {
rendered.warnings.forEach((warning) => handleError(new Error(warning)));
if (rendered.error) handleError(rendered.error);
}, [handleError, rendered]);
if (!template.trim()) {
return (
<Alert color="gray" variant="light" p="xs">
<Text size="xs" c="dimmed">
{t("noTemplate")}
</Text>
</Alert>
);
}
return (
const hasRuntime = typeof data.widgetItemId === "string" || typeof data.previewSessionId === "string";
return hasRuntime ? (
<WidgetDefinitionProvider
definitionId={definitionId}
itemId={itemId}
previewSessionId={previewSessionId}
previewLiveActions={previewLiveActions}
isEditMode={isEditMode}
requestCapabilities={requestCapabilities}
definitionId={String(data.widgetDefinitionId ?? "")}
itemId={typeof data.widgetItemId === "string" ? data.widgetItemId : undefined}
previewSessionId={typeof data.previewSessionId === "string" ? data.previewSessionId : undefined}
previewLiveActions={data.previewLiveActions === true}
isEditMode={data.isEditMode === true}
requestCapabilities={capabilities}
>
<Stack gap={0} h="100%">
{requestCapabilities.length > 0 && (
<Group justify="flex-end" mb={4}>
<Popover width={320} position="bottom-end" withinPortal={false} shadow="md">
<Popover.Target>
<Badge
component="button"
type="button"
size="sm"
color="gray"
variant="light"
leftSection={<IconNetwork size={12} />}
style={{ cursor: "pointer" }}
>
{t("interactive")}
</Badge>
</Popover.Target>
<Popover.Dropdown>
<Stack gap={6}>
<Text size="sm" fw={600}>
{t("networkCapabilities")}
</Text>
{requestCapabilities.map((capability) => (
<Group key={capability.id} justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{capability.id}
</Text>
<Group gap={4} wrap="nowrap">
<Badge size="xs" color={CUSTOM_JSX_METHOD_COLORS[capability.method] ?? "gray"} variant="light">
{capability.method}
</Badge>
<Badge size="xs" color="gray" variant="outline">
{capability.minimumBoardPermission}
</Badge>
</Group>
</Group>
))}
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
)}
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
{rendered.error ? (
<ErrorAlert error={rendered.error} />
) : (
<RendererErrorBoundary key={template} onError={handleError} fallback={renderErrorFallback}>
{rendered.node}
</RendererErrorBoundary>
)}
</Box>
{parseErrors.length > 0 && (
<Alert color="yellow" variant="light" p="xs" mt="xs">
<Text size="xs" c="dimmed">
{t("templateWarnings", { count: String(parseErrors.length) })}
</Text>
{parseErrors.map((msg) => (
<Text key={msg} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{msg}
</Text>
))}
</Alert>
)}
</Stack>
{renderer}
</WidgetDefinitionProvider>
) : (
renderer
);
}
@@ -1 +0,0 @@
export { extractDisplayData } from "@homarr/api/custom-widget/display-data";
@@ -0,0 +1,242 @@
import {
IconActivity,
IconAlertCircle,
IconAlertTriangle,
IconApi,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconArrowUp,
IconBell,
IconBluetooth,
IconBolt,
IconBookmark,
IconBug,
IconCalendar,
IconCamera,
IconChartBar,
IconChartLine,
IconChartPie,
IconCheck,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronUp,
IconCircleCheck,
IconCircleX,
IconClipboard,
IconClock,
IconCloud,
IconCloudOff,
IconCode,
IconCompass,
IconCopy,
IconCpu,
IconDatabase,
IconDeviceDesktop,
IconDeviceMobile,
IconDownload,
IconDroplet,
IconEdit,
IconExternalLink,
IconEye,
IconEyeOff,
IconFile,
IconFlag,
IconFlame,
IconFolder,
IconGauge,
IconGitBranch,
IconHeart,
IconHistory,
IconHome,
IconInfoCircle,
IconKey,
IconLink,
IconLoader,
IconLock,
IconLockOpen,
IconMail,
IconMap,
IconMapPin,
IconMinus,
IconMoon,
IconMusic,
IconNetwork,
IconPhoto,
IconPin,
IconPlayerPause,
IconPlayerPlay,
IconPlayerSkipBack,
IconPlayerSkipForward,
IconPlayerStop,
IconPlus,
IconPower,
IconProgress,
IconHeartbeat,
IconRefresh,
IconReload,
IconSearch,
IconServer,
IconSettings,
IconShield,
IconStar,
IconSun,
IconTerminal,
IconThermometer,
IconTrash,
IconTrendingDown,
IconTrendingUp,
IconUpload,
IconUser,
IconUsers,
IconVideo,
IconVolume,
IconVolume2,
IconVolumeOff,
IconWifi,
IconWifiOff,
IconWorld,
IconX,
IconSpeedboat,
} from "@tabler/icons-react";
function tablerIconExportToKey(exportName: string): string {
return exportName
.slice(4)
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
.replace(/([A-Za-z])(\d)/g, "$1-$2")
.toLowerCase();
}
const TABLER_ICON_IMPORTS = {
IconCheck,
IconX,
IconPlus,
IconMinus,
IconArrowUp,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconChevronUp,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconHome,
IconSettings,
IconUser,
IconUsers,
IconSearch,
IconBell,
IconMail,
IconHeart,
IconStar,
IconBookmark,
IconFlag,
IconPin,
IconPlayerPlay,
IconPlayerPause,
IconPlayerStop,
IconPlayerSkipForward,
IconPlayerSkipBack,
IconVolume,
IconVolumeOff,
IconVolume2,
IconWifi,
IconWifiOff,
IconBluetooth,
IconCloud,
IconCloudOff,
IconServer,
IconDatabase,
IconCpu,
IconDeviceDesktop,
IconDeviceMobile,
IconFolder,
IconFile,
IconDownload,
IconUpload,
IconRefresh,
IconReload,
IconPower,
IconBolt,
IconFlame,
IconDroplet,
IconSun,
IconMoon,
IconEye,
IconEyeOff,
IconLock,
IconLockOpen,
IconShield,
IconKey,
IconAlertTriangle,
IconAlertCircle,
IconInfoCircle,
IconCircleCheck,
IconCircleX,
IconTrash,
IconEdit,
IconCopy,
IconClipboard,
IconExternalLink,
IconLink,
IconPhoto,
IconCamera,
IconMusic,
IconVideo,
IconCalendar,
IconClock,
IconHistory,
IconMap,
IconMapPin,
IconCompass,
IconTerminal,
IconCode,
IconBug,
IconGitBranch,
IconNetwork,
IconWorld,
IconApi,
IconChartBar,
IconChartLine,
IconChartPie,
IconTrendingUp,
IconTrendingDown,
IconActivity,
IconProgress,
IconLoader,
IconThermometer,
IconGauge,
IconSpeedboat,
IconHeartbeat,
} as const;
const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
...Object.fromEntries(
Object.entries(TABLER_ICON_IMPORTS).map(([exportName, IconComponent]) => [
tablerIconExportToKey(exportName),
IconComponent,
]),
),
pulse: IconHeartbeat,
};
function isValidIconProp(value: unknown): value is string | number {
return typeof value === "number" || typeof value === "string";
}
export function SafeTablerIcon(props: Record<string, unknown>) {
const name = props.name;
if (typeof name !== "string") return null;
const IconComponent = TABLER_ICON_MAP[name];
if (!IconComponent) return null;
const iconProps: Record<string, unknown> = {};
if (isValidIconProp(props.size)) iconProps.size = props.size;
if (typeof props.color === "string") iconProps.color = props.color;
if (isValidIconProp(props.stroke)) iconProps.stroke = props.stroke;
return createElement(IconComponent as never, iconProps);
}
import { createElement } from "react";
import type { ComponentType } from "react";
@@ -1,228 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { Children, isValidElement, useEffect, useRef, useState } from "react";
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react";
interface PaginatedListProps {
children: ReactNode;
pageSize?: number;
}
export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
const items = Children.toArray(children);
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const [page, setPage] = useState(0);
const prevCountRef = useRef(items.length);
useEffect(() => {
if (items.length !== prevCountRef.current) {
setPage(0);
prevCountRef.current = items.length;
}
}, [items.length]);
const clampedPage = Math.min(page, totalPages - 1);
const start = clampedPage * pageSize;
const visible = items.slice(start, start + pageSize);
return (
<Stack gap="xs">
<div>{visible}</div>
{totalPages > 1 && (
<Group justify="center" gap="xs">
<ActionIcon
variant="subtle"
size="sm"
disabled={clampedPage === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<IconChevronLeft size={14} />
</ActionIcon>
<Text size="xs" c="dimmed">
{clampedPage + 1} / {totalPages}
</Text>
<ActionIcon
variant="subtle"
size="sm"
disabled={clampedPage >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<IconChevronRight size={14} />
</ActionIcon>
</Group>
)}
</Stack>
);
}
interface TabsContainerProps {
children: ReactNode;
defaultTab?: string;
}
function isTabPanel(child: ReactNode): child is React.ReactElement<TabPanelProps> {
return isValidElement<TabPanelProps>(child) && child.type === TabPanel && typeof child.props.value === "string";
}
export function TabsContainer({ children, defaultTab }: TabsContainerProps) {
const panels = Children.toArray(children).filter(isTabPanel);
const firstPanel = panels[0];
const panelValues = new Set(panels.map((p) => p.props.value));
const resolvedDefault = defaultTab && panelValues.has(defaultTab) && defaultTab;
const initialTab = resolvedDefault || firstPanel?.props.value || null;
const [activeTab, setActiveTab] = useState<string | null>(initialTab);
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
{panels.map((panel) => (
<Tabs.Tab key={panel.props.value} value={panel.props.value}>
{panel.props.label ?? panel.props.value}
</Tabs.Tab>
))}
</Tabs.List>
{panels.map((panel) => (
<Tabs.Panel key={panel.props.value} value={panel.props.value} pt="sm">
{panel.props.children}
</Tabs.Panel>
))}
</Tabs>
);
}
interface TabPanelProps {
children: ReactNode;
value: string;
label?: string;
}
export function TabPanel({ children }: TabPanelProps) {
return <>{children}</>;
}
interface CollapsibleProps {
children: ReactNode;
title: string;
defaultOpen?: boolean;
}
const COLLAPSE_CHEVRONS = {
closed: IconChevronDown,
open: IconChevronUp,
} as const;
const COLLAPSE_STATE: Record<0 | 1, keyof typeof COLLAPSE_CHEVRONS> = {
0: "closed",
1: "open",
};
export function Collapsible({ children, title, defaultOpen = false }: CollapsibleProps) {
const [opened, setOpened] = useState(defaultOpen);
const Chevron = COLLAPSE_CHEVRONS[COLLAPSE_STATE[Number(opened) as 0 | 1]];
return (
<Stack gap={0}>
<UnstyledButton onClick={() => setOpened((o) => !o)} py={4}>
<Group gap="xs" wrap="nowrap">
<Chevron size={14} />
<Text size="sm" fw={600}>
{title}
</Text>
</Group>
</UnstyledButton>
<Collapse expanded={opened}>{children}</Collapse>
</Stack>
);
}
interface StatBarProps {
value: number;
max?: number;
label?: string;
color?: string;
}
function calcPct(value: number, max: number): number {
if (max <= 0) return 0;
return Math.min((value / max) * 100, 100);
}
export function StatBar({ value, max = 100, label, color = "blue" }: StatBarProps) {
const pct = calcPct(value, max);
return (
<Group gap="xs" wrap="nowrap">
{label && (
<Text size="xs" w={80} ta="right" c="dimmed">
{label}
</Text>
)}
<div
style={{
flex: 1,
height: 8,
borderRadius: 4,
background: "var(--mantine-color-default-border)",
overflow: "hidden",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 4,
background: `var(--mantine-color-${color}-6)`,
transition: "width 0.3s ease",
}}
/>
</div>
<Text size="xs" w={30} c="dimmed">
{value}
</Text>
</Group>
);
}
interface TypeBadgeProps {
type: string;
size?: "xs" | "sm" | "md" | "lg" | "xl";
}
const TYPE_COLORS: Record<string, string> = {
normal: "gray",
fire: "red",
water: "blue",
electric: "yellow",
grass: "green",
ice: "cyan",
fighting: "orange",
poison: "grape",
ground: "yellow",
flying: "indigo",
psychic: "pink",
bug: "lime",
rock: "orange",
ghost: "violet",
dragon: "indigo",
dark: "dark",
steel: "gray",
fairy: "pink",
};
function normalizeType(type: unknown): string {
if (typeof type === "string") return type;
if (type == null) return "";
return String(type);
}
export function TypeBadge({ type, size = "sm" }: TypeBadgeProps) {
const typeStr = normalizeType(type);
const color = TYPE_COLORS[typeStr.toLowerCase()] ?? "gray";
return (
<Badge color={color} size={size} variant="filled" tt="capitalize">
{typeStr}
</Badge>
);
}
@@ -1,599 +0,0 @@
"use client";
import type { ComponentType, ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Badge, Button, Code, Image, Skeleton, Switch, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconPlayerPlay, IconPower, IconRefresh, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useCustomJsxRuntime } from "./widget-definition-context";
type RuntimeParam = string | number | boolean;
export type CustomJsxRuntimeParams = Record<string, RuntimeParam>;
interface RequestResult {
ok: boolean;
status: number;
statusText?: string;
data: unknown;
error?: string;
simulated?: boolean;
}
export interface SubFetchMetadata {
ok: boolean;
status: number;
statusText?: string;
}
const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
play: IconPlayerPlay,
check: IconCheck,
refresh: IconRefresh,
power: IconPower,
trash: IconTrash,
};
const SubFetchDataContext = createContext<unknown>(undefined);
function parseBool(value: unknown, defaultValue = false): boolean {
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return defaultValue;
}
function normalizeParams(value: unknown): CustomJsxRuntimeParams | null {
if (value === undefined) return {};
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const result: CustomJsxRuntimeParams = {};
for (const [key, param] of Object.entries(value)) {
if (key === "constructor" || key === "prototype" || key === "__proto__") return null;
if (typeof param !== "string" && typeof param !== "number" && typeof param !== "boolean") return null;
result[key] = param;
}
return result;
}
function getByPath(obj: unknown, path?: string): unknown {
if (!path?.trim()) return obj;
return path.split(".").reduce<unknown>((acc, key) => {
if (acc == null || typeof acc !== "object") return undefined;
if (key === "constructor" || key === "prototype" || key === "__proto__") return undefined;
return Object.hasOwn(acc, key) ? (acc as Record<string, unknown>)[key] : undefined;
}, obj);
}
function formatDisplayValue(value: unknown): string {
if (value == null) return "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function MigrationRequiredAlert() {
const t = useScopedI18n("widget.customApi.customJsx");
return (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{t("migrationRequired")}</Text>
</Alert>
);
}
export interface SubFetchProps {
requestId?: string;
params?: CustomJsxRuntimeParams;
refreshInterval?: number;
children?: ReactNode;
render?: (data: unknown, metadata: SubFetchMetadata) => ReactNode;
loadingLabel?: string;
errorMessage?: string;
fallback?: ReactNode;
path?: string;
as?: "json" | "text";
/** @deprecated Inline targets are inert in JSX API v2. */
url?: string;
/** @deprecated The method is defined by the named request manifest. */
method?: string;
/** @deprecated The body is defined by the named request manifest. */
body?: string;
/** @deprecated Headers are defined by the named request manifest. */
headers?: string;
/** Set to manual to wait for an explicit user gesture before the first query. */
trigger?: "auto" | "manual";
}
export function SubFetch({
requestId,
params,
refreshInterval,
children,
render,
loadingLabel,
errorMessage,
fallback,
path,
as,
trigger = "auto",
}: SubFetchProps) {
const t = useScopedI18n("widget.customApi.customJsx");
const { itemId, previewSessionId } = useCustomJsxRuntime();
const [manualRun, setManualRun] = useState(false);
const normalizedParams = useMemo(() => normalizeParams(params), [params]);
const requestKey = useMemo(() => JSON.stringify(normalizedParams), [normalizedParams]);
useEffect(() => setManualRun(false), [requestId, requestKey, trigger]);
const canRun = Boolean((itemId || previewSessionId) && requestId && normalizedParams);
const boardQuery = clientApi.widget.customApi.queryRequest.useQuery(
{
itemId: itemId ?? "",
requestId: requestId ?? "",
params: normalizedParams ?? {},
},
{
enabled: Boolean(itemId) && canRun && (trigger === "auto" || manualRun),
retry: 2,
refetchInterval:
refreshInterval && Number.isFinite(refreshInterval) && refreshInterval > 0
? Math.max(1_000, refreshInterval * 1_000)
: false,
},
);
const previewQuery = clientApi.customWidget.previewQuery.useQuery(
{
sessionId: previewSessionId ?? "",
requestId: requestId ?? "",
params: normalizedParams ?? {},
},
{
enabled: Boolean(previewSessionId && !itemId) && canRun && (trigger === "auto" || manualRun),
retry: 2,
refetchInterval:
refreshInterval && Number.isFinite(refreshInterval) && refreshInterval > 0
? Math.max(1_000, refreshInterval * 1_000)
: false,
},
);
const query = itemId ? boardQuery : previewQuery;
if (!requestId) return <MigrationRequiredAlert />;
if (!itemId && !previewSessionId) {
return (
fallback ?? (
<Text size="xs" c="dimmed">
{t("unsavedPreview")}
</Text>
)
);
}
if (!normalizedParams) {
return (
<Alert color="red" variant="light" p="xs">
<Text size="xs">{t("invalidParams")}</Text>
</Alert>
);
}
if (trigger === "manual" && !manualRun) {
return (
<Button size="compact-sm" variant="light" onClick={() => setManualRun(true)}>
{t("loadRequest")}
</Button>
);
}
const result = query.data as RequestResult | undefined;
const requestError =
(query.error && !result ? t("requestFailed") : null) ??
(!result?.ok ? (result?.error ?? `${t("requestFailed")} (${result?.status ?? 0})`) : null);
if (!result && query.isPending) {
return (
fallback ?? (
<div>
<Skeleton height={8} radius="xl" mb="xs" />
<Text size="xs" c="dimmed">
{loadingLabel ?? t("loading")}
</Text>
</div>
)
);
}
if (requestError) {
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{errorMessage ?? requestError}</Text>
<Button
size="compact-xs"
variant="subtle"
mt={4}
onClick={() => void query.refetch()}
loading={query.isFetching}
>
{t("retry")}
</Button>
</Alert>
);
}
const subData = result?.data;
let content = render
? render(subData, { ok: result?.ok ?? false, status: result?.status ?? 0, statusText: result?.statusText })
: children;
if (!render && (as === "json" || (!children && as !== "text"))) {
content = (
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(path ? getByPath(subData, path) : subData, null, 2)}
</Code>
);
} else if (!render && as === "text") {
content = <Text size="sm">{formatDisplayValue(getByPath(subData, path))}</Text>;
}
return <SubFetchDataContext.Provider value={subData}>{content}</SubFetchDataContext.Provider>;
}
export interface SubDataProps {
path?: string;
as?: string;
order?: number;
size?: string;
color?: string;
variant?: string;
fw?: number;
c?: string;
alt?: string;
fit?: string;
w?: string | number;
h?: string | number;
radius?: string | number;
}
export function SubData({ path, as = "Text", ...props }: SubDataProps) {
const subData = useContext(SubFetchDataContext);
const value = getByPath(subData, path);
if (value === undefined && path) {
return (
<Text size="xs" c="dimmed">
—
</Text>
);
}
const displayValue = as === "Code" ? JSON.stringify(value, null, 2) : formatDisplayValue(value);
if (as === "Title") {
return (
<Title order={(props.order as 1 | 2 | 3 | 4 | 5 | 6) ?? 3} c={props.c}>
{displayValue}
</Title>
);
}
if (as === "Badge") {
return (
<Badge color={props.color} variant={props.variant as never} size={props.size as never}>
{displayValue}
</Badge>
);
}
if (as === "Code") return <Code block>{displayValue}</Code>;
if (as === "Image") {
const src = safeDisplayImageUrl(value);
if (!src) return null;
return (
<Image src={src} alt={props.alt ?? ""} fit={props.fit as never} w={props.w} h={props.h} radius={props.radius} />
);
}
return (
<Text size={props.size as never} fw={props.fw} c={props.c}>
{displayValue}
</Text>
);
}
function safeDisplayImageUrl(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
if (value.startsWith("/") && !value.startsWith("//")) return value;
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
} catch {
return undefined;
}
}
function useActionExecutor() {
const t = useScopedI18n("widget.customApi.customJsx");
const runtime = useCustomJsxRuntime();
const boardMutation = clientApi.widget.customApi.executeAction.useMutation();
const previewMutation = clientApi.customWidget.previewAction.useMutation();
const execute = useCallback(
async (requestId: string, params: CustomJsxRuntimeParams, confirmed: boolean): Promise<RequestResult> => {
if (!runtime.itemId && !runtime.previewSessionId) {
return { ok: false, status: 0, data: null, error: t("widgetItemUnavailable") };
}
if (runtime.isEditMode) {
return { ok: false, status: 0, data: null, error: t("actionsDisabledEditMode") };
}
if (runtime.itemId) {
return boardMutation.mutateAsync({ itemId: runtime.itemId, requestId, params, confirmed });
}
return previewMutation.mutateAsync({
sessionId: runtime.previewSessionId ?? "",
requestId,
params,
confirmed,
});
},
[boardMutation, previewMutation, runtime.isEditMode, runtime.itemId, runtime.previewSessionId, t],
);
return {
execute,
isPending: boardMutation.isPending || previewMutation.isPending,
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
canExecute: Boolean(runtime.itemId || runtime.previewSessionId),
isEditMode: runtime.isEditMode,
requestCapabilities: runtime.requestCapabilities ?? [],
};
}
async function invalidateAfterAction(
utils: ReturnType<typeof clientApi.useUtils>,
itemId: string | undefined,
previewSessionId: string | undefined,
invalidate: readonly string[] | undefined,
) {
if (!invalidate?.length) return;
if (previewSessionId) {
await utils.customWidget.previewQuery.invalidate();
return;
}
if (!itemId) return;
if (invalidate.includes("parent")) await utils.widget.customApi.getData.invalidate({ itemId });
if (invalidate.some((entry) => entry !== "parent")) await utils.widget.customApi.queryRequest.invalidate();
}
export interface ActionButtonProps {
requestId?: string;
params?: CustomJsxRuntimeParams;
label: string;
color?: string;
variant?: string;
size?: string;
confirmMessage?: string;
successMessage?: string;
errorMessage?: string;
icon?: string;
invalidate?: string[];
disabled?: boolean | string;
/** @deprecated Inline targets are inert in JSX API v2. */
url?: string;
/** @deprecated The method is defined by the named request manifest. */
method?: string;
/** @deprecated The body is defined by the named request manifest. */
body?: string;
/** @deprecated Headers are defined by the named request manifest. */
headers?: string;
/** @deprecated Use normal Mantine sizing props. */
fullWidth?: boolean | string;
}
export function ActionButton({
requestId,
params,
label,
color = "blue",
variant = "filled",
size = "sm",
confirmMessage,
successMessage,
errorMessage,
icon,
invalidate,
disabled,
}: ActionButtonProps) {
const t = useScopedI18n("widget.customApi.customJsx");
const { execute, isPending, itemId, previewSessionId, canExecute, isEditMode, requestCapabilities } =
useActionExecutor();
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const Icon = icon ? ICON_MAP[icon.toLowerCase()] : undefined;
const normalizedParams = normalizeParams(params);
const requiresConfirmation = requestCapabilities.some(
(capability) => capability.id === requestId && capability.kind === "action" && capability.method === "DELETE",
);
const runAction = async (confirmed: boolean) => {
if (!requestId || !normalizedParams) return;
try {
const result = await execute(requestId, normalizedParams, confirmed);
if (result.ok) {
showSuccessNotification({
title: label,
message: successMessage ?? (result.simulated ? t("actionSimulated") : t("actionCompleted")),
});
await invalidateAfterAction(utils, itemId, previewSessionId, invalidate);
} else {
showErrorNotification({
title: label,
message: errorMessage ?? result.error ?? `${t("requestFailed")} (${result.status})`,
});
}
} catch {
showErrorNotification({
title: label,
message: errorMessage ?? t("requestFailed"),
});
}
};
const handleClick = () => {
if (confirmMessage || requiresConfirmation) {
openConfirmModal({
title: label,
children: confirmMessage ?? t("confirmDelete"),
onConfirm: () => void runAction(true),
});
} else {
void runAction(false);
}
};
if (!requestId) return <MigrationRequiredAlert />;
return (
<Button
color={color}
variant={variant as never}
size={size as never}
onClick={handleClick}
loading={isPending}
leftSection={Icon ? <Icon size={16} /> : undefined}
disabled={parseBool(disabled) || isEditMode || !canExecute || !normalizedParams}
>
{label}
</Button>
);
}
export interface ToggleSwitchProps {
requestId?: string;
onParams?: CustomJsxRuntimeParams;
offParams?: CustomJsxRuntimeParams;
initialValue?: boolean | string;
label?: string;
color?: string;
size?: string;
errorMessage?: string;
invalidate?: string[];
disabled?: boolean | string;
/** @deprecated Inline targets are inert in JSX API v2. */
url?: string;
/** @deprecated The method is defined by the named request manifest. */
method?: string;
/** @deprecated Use onParams. */
onBody?: string;
/** @deprecated Use offParams. */
offBody?: string;
}
export function ToggleSwitch({
requestId,
onParams,
offParams,
initialValue = false,
label,
color = "blue",
size = "sm",
errorMessage,
invalidate,
disabled,
}: ToggleSwitchProps) {
const t = useScopedI18n("widget.customApi.customJsx");
const { execute, isPending, itemId, previewSessionId, canExecute, isEditMode } = useActionExecutor();
const utils = clientApi.useUtils();
const initial = parseBool(initialValue);
const [checked, setChecked] = useState(initial);
const pendingRef = useRef(false);
const normalizedOnParams = normalizeParams(onParams);
const normalizedOffParams = normalizeParams(offParams);
useEffect(() => {
if (!pendingRef.current) setChecked(initial);
}, [initial]);
const handleChange = async (next: boolean) => {
if (!requestId || pendingRef.current) return;
const requestParams = next ? normalizedOnParams : normalizedOffParams;
if (!requestParams) return;
pendingRef.current = true;
const previous = checked;
setChecked(next);
try {
const result = await execute(requestId, requestParams, false);
if (!result.ok) {
setChecked(previous);
showErrorNotification({
title: label ?? t("toggle"),
message: errorMessage ?? result.error ?? t("requestFailed"),
});
return;
}
await invalidateAfterAction(utils, itemId, previewSessionId, invalidate);
} catch {
setChecked(previous);
showErrorNotification({
title: label ?? t("toggle"),
message: errorMessage ?? t("requestFailed"),
});
} finally {
pendingRef.current = false;
}
};
if (!requestId) return <MigrationRequiredAlert />;
return (
<Switch
label={label}
color={color}
size={size as never}
checked={checked}
onChange={(event) => void handleChange(event.currentTarget.checked)}
disabled={
parseBool(disabled) || isPending || isEditMode || !canExecute || !normalizedOnParams || !normalizedOffParams
}
/>
);
}
export interface RefreshButtonProps {
label?: string;
color?: string;
variant?: string;
size?: string;
}
export function RefreshButton({ label, color = "blue", variant = "light", size = "sm" }: RefreshButtonProps) {
const t = useScopedI18n("widget.customApi.customJsx");
const { itemId, previewSessionId, isEditMode } = useCustomJsxRuntime();
const utils = clientApi.useUtils();
const [loading, setLoading] = useState(false);
const handleRefresh = async () => {
if (!itemId && !previewSessionId) return;
setLoading(true);
try {
if (previewSessionId) await utils.customWidget.previewQuery.invalidate();
else
await Promise.all([
utils.widget.customApi.getData.invalidate({ itemId: itemId ?? "" }),
utils.widget.customApi.queryRequest.invalidate(),
]);
} finally {
setLoading(false);
}
};
return (
<Button
color={color}
variant={variant as never}
size={size as never}
leftSection={<IconRefresh size={16} />}
onClick={() => void handleRefresh()}
loading={loading}
disabled={(!itemId && !previewSessionId) || isEditMode}
>
{label ?? t("refresh")}
</Button>
);
}
@@ -1,775 +1,12 @@
import type { ComponentType, ReactNode } from "react";
import { createElement, useEffect, useRef, useState } from "react";
import {
Accordion,
ActionIcon,
Alert,
Anchor,
AspectRatio,
Avatar,
BackgroundImage,
Badge,
Blockquote,
Box,
Breadcrumbs,
Burger,
Button,
Card,
Center,
Chip,
CloseButton,
Code,
Collapse,
ColorSwatch,
Container,
DataList,
Divider,
EmptyState,
Fieldset,
Flex,
FloatingIndicator,
Grid,
Group,
Highlight,
HoverCard,
Image,
Indicator,
Kbd,
List,
Loader,
LoadingOverlay,
Mark,
Marquee,
Menu,
NavLink,
Notification,
NumberFormatter,
Overlay,
Pagination,
Paper,
Pill,
Progress,
Rating,
RingProgress,
RollingNumber,
ScrollArea,
SegmentedControl,
SemiCircleProgress,
SimpleGrid,
Skeleton,
Slider,
Space,
Spoiler,
Stack,
Stepper,
Switch,
Table,
Tabs,
Text,
ThemeIcon,
Timeline,
Title,
Tooltip,
Transition,
Tree,
Typography,
VisuallyHidden,
} from "@mantine/core";
import {
AreaChart,
BarChart,
BarsList,
BubbleChart,
ChartLegend,
ChartTooltip,
CompositeChart,
DonutChart,
FunnelChart,
Heatmap,
LineChart,
PieChart,
RadarChart,
RadialBarChart,
SankeyChart,
ScatterChart,
Sparkline,
Treemap,
} from "@mantine/charts";
import {
IconActivity,
IconAlertCircle,
IconAlertTriangle,
IconApi,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconArrowUp,
IconBell,
IconBluetooth,
IconBolt,
IconBookmark,
IconBug,
IconCalendar,
IconCamera,
IconChartBar,
IconChartLine,
IconChartPie,
IconCheck,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronUp,
IconCircleCheck,
IconCircleX,
IconClipboard,
IconClock,
IconCloud,
IconCloudOff,
IconCode,
IconCompass,
IconCopy,
IconCpu,
IconDatabase,
IconDeviceDesktop,
IconDeviceMobile,
IconDownload,
IconDroplet,
IconEdit,
IconExternalLink,
IconEye,
IconEyeOff,
IconFile,
IconFlag,
IconFlame,
IconFolder,
IconGauge,
IconGitBranch,
IconHeart,
IconHistory,
IconHome,
IconInfoCircle,
IconKey,
IconLink,
IconLoader,
IconLock,
IconLockOpen,
IconMail,
IconMap,
IconMapPin,
IconMinus,
IconMoon,
IconMusic,
IconNetwork,
IconPhoto,
IconPin,
IconPlayerPause,
IconPlayerPlay,
IconPlayerSkipBack,
IconPlayerSkipForward,
IconPlayerStop,
IconPlus,
IconPower,
IconProgress,
IconHeartbeat,
IconRefresh,
IconReload,
IconSearch,
IconServer,
IconSettings,
IconShield,
IconStar,
IconSun,
IconTerminal,
IconThermometer,
IconTrash,
IconTrendingDown,
IconTrendingUp,
IconUpload,
IconUser,
IconUsers,
IconVideo,
IconVolume,
IconVolume2,
IconVolumeOff,
IconWifi,
IconWifiOff,
IconWorld,
IconX,
IconSpeedboat,
} from "@tabler/icons-react";
import {
Calendar,
CalendarHeader,
DatePicker,
Day,
DecadeLevel,
DecadeLevelGroup,
LevelsGroup,
MiniCalendar,
Month,
MonthLevel,
MonthLevelGroup,
MonthPicker,
MonthsList,
PickerControl,
TimeGrid,
TimeValue,
WeekdaysRow,
YearLevel,
YearLevelGroup,
YearPicker,
YearsList,
} from "@mantine/dates";
import type { ComponentType } from "react";
import { enabledCustomJsxComponents } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { createCustomJsxBindings, createCustomJsxComponents } from "@homarr/custom-widgets/jsx";
import { PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge } from "./jsx-interactive-components";
import { SubFetch, SubData, ActionButton, ToggleSwitch, RefreshButton } from "./jsx-sub-fetch";
import { createSafeCallable } from "./safe-bindings";
import { sanitizeCustomJsxProps } from "./safe-jsx-interpreter";
import { SafeTablerIcon } from "./jsx-icon-adapter";
const SAFE_URL_PATTERN = /^https?:\/\//i;
// Labels are replaced by the localized runtime adapter in the renderer. These
// fallbacks are used only in isolated component-map tests.
export const createWhitelistedComponents = (copyLabels: { copy: string; copied: string }) =>
createCustomJsxComponents({ TablerIcon: SafeTablerIcon as ComponentType<never>, copyLabels });
function isSafeUrl(url: unknown): boolean {
if (typeof url !== "string") return false;
return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#");
}
function SafeAnchor(props: Record<string, unknown>) {
const href = props.href;
const safeHref = href && isSafeUrl(href) ? href : undefined;
const sanitized = sanitizeCustomJsxProps(props);
const opensNewWindow = sanitized.target === "_blank";
return createElement(Anchor as never, {
...sanitized,
href: safeHref,
rel: opensNewWindow ? "noopener noreferrer" : sanitized.rel,
});
}
function SafeNavLink(props: Record<string, unknown>) {
const href = props.href;
const safeHref = href && isSafeUrl(href) ? href : undefined;
const sanitized = sanitizeCustomJsxProps(props);
const opensNewWindow = sanitized.target === "_blank";
return createElement(NavLink as never, {
...sanitized,
href: safeHref,
rel: opensNewWindow ? "noopener noreferrer" : sanitized.rel,
});
}
function SafeButton(props: Record<string, unknown>) {
return createElement(Button as never, sanitizeCustomJsxProps(props));
}
function SafeActionIcon(props: Record<string, unknown>) {
return createElement(ActionIcon as never, sanitizeCustomJsxProps(props));
}
function SafeBurger(props: Record<string, unknown>) {
return createElement(Burger as never, sanitizeCustomJsxProps(props));
}
function SafeCloseButton(props: Record<string, unknown>) {
return createElement(CloseButton as never, sanitizeCustomJsxProps(props));
}
function SafeChip(props: Record<string, unknown>) {
return createElement(Chip as never, sanitizeCustomJsxProps(props));
}
function SafeNotification(props: Record<string, unknown>) {
return createElement(Notification as never, sanitizeCustomJsxProps(props));
}
function SafeRating(props: Record<string, unknown>) {
return createElement(Rating as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
}
function SafeSlider(props: Record<string, unknown>) {
return createElement(Slider as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
}
function SafeSwitch(props: Record<string, unknown>) {
return createElement(Switch as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
}
function SafeSegmentedControl(props: Record<string, unknown>) {
return createElement(SegmentedControl as never, { ...sanitizeCustomJsxProps(props), readOnly: true });
}
function SafePagination(props: Record<string, unknown>) {
return createElement(Pagination as never, { ...sanitizeCustomJsxProps(props), disabled: true });
}
function SafeStepper(props: Record<string, unknown>) {
return createElement(Stepper as never, sanitizeCustomJsxProps(props));
}
function SafeCalendar(props: Record<string, unknown>) {
return createElement(Calendar as never, { ...sanitizeCustomJsxProps(props), static: true });
}
function SafeMiniCalendar(props: Record<string, unknown>) {
return createElement(MiniCalendar as never, {
...sanitizeCustomJsxProps(props),
style: { pointerEvents: "none" },
previousControlProps: { disabled: true, tabIndex: -1 },
nextControlProps: { disabled: true, tabIndex: -1 },
getDayProps: () => ({ disabled: true, tabIndex: -1 }),
});
}
function SafeTree(props: Record<string, unknown>) {
return createElement(Tree as never, sanitizeCustomJsxProps(props));
}
function SafeDatePicker(props: Record<string, unknown>) {
return createElement(DatePicker as never, {
...sanitizeCustomJsxProps(props),
style: { pointerEvents: "none" },
enableKeyboardNavigation: false,
getDayProps: () => ({ disabled: true, tabIndex: -1 }),
});
}
function createSafeDateComponent(component: ComponentType<never>) {
return function SafeDateComponent(props: Record<string, unknown>) {
return createElement(component, { ...sanitizeCustomJsxProps(props), static: true } as never);
};
}
function tablerIconExportToKey(exportName: string): string {
return exportName
.slice(4)
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
.replace(/([A-Za-z])(\d)/g, "$1-$2")
.toLowerCase();
}
const TABLER_ICON_IMPORTS = {
IconCheck,
IconX,
IconPlus,
IconMinus,
IconArrowUp,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconChevronUp,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconHome,
IconSettings,
IconUser,
IconUsers,
IconSearch,
IconBell,
IconMail,
IconHeart,
IconStar,
IconBookmark,
IconFlag,
IconPin,
IconPlayerPlay,
IconPlayerPause,
IconPlayerStop,
IconPlayerSkipForward,
IconPlayerSkipBack,
IconVolume,
IconVolumeOff,
IconVolume2,
IconWifi,
IconWifiOff,
IconBluetooth,
IconCloud,
IconCloudOff,
IconServer,
IconDatabase,
IconCpu,
IconDeviceDesktop,
IconDeviceMobile,
IconFolder,
IconFile,
IconDownload,
IconUpload,
IconRefresh,
IconReload,
IconPower,
IconBolt,
IconFlame,
IconDroplet,
IconSun,
IconMoon,
IconEye,
IconEyeOff,
IconLock,
IconLockOpen,
IconShield,
IconKey,
IconAlertTriangle,
IconAlertCircle,
IconInfoCircle,
IconCircleCheck,
IconCircleX,
IconTrash,
IconEdit,
IconCopy,
IconClipboard,
IconExternalLink,
IconLink,
IconPhoto,
IconCamera,
IconMusic,
IconVideo,
IconCalendar,
IconClock,
IconHistory,
IconMap,
IconMapPin,
IconCompass,
IconTerminal,
IconCode,
IconBug,
IconGitBranch,
IconNetwork,
IconWorld,
IconApi,
IconChartBar,
IconChartLine,
IconChartPie,
IconTrendingUp,
IconTrendingDown,
IconActivity,
IconProgress,
IconLoader,
IconThermometer,
IconGauge,
IconSpeedboat,
IconHeartbeat,
} as const;
const TABLER_ICON_MAP: Record<string, ComponentType<never>> = {
...Object.fromEntries(
Object.entries(TABLER_ICON_IMPORTS).map(([exportName, IconComponent]) => [
tablerIconExportToKey(exportName),
IconComponent,
]),
),
pulse: IconHeartbeat,
};
function isValidIconProp(value: unknown): value is string | number {
return typeof value === "number" || typeof value === "string";
}
function SafeTablerIcon(props: Record<string, unknown>) {
const name = props.name;
if (typeof name !== "string") return null;
const IconComponent = TABLER_ICON_MAP[name];
if (!IconComponent) return null;
const iconProps: Record<string, unknown> = {};
if (isValidIconProp(props.size)) iconProps.size = props.size;
if (typeof props.color === "string") iconProps.color = props.color;
if (isValidIconProp(props.stroke)) iconProps.stroke = props.stroke;
return createElement(IconComponent as never, iconProps);
}
function SafeCopyButton({ value }: { value?: string; children?: ReactNode }) {
const t = useScopedI18n("widget.customApi.customJsx");
const [copied, setCopied] = useState(false);
const resetTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(resetTimer.current), []);
const handleCopy = async () => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
const COPY_STATES = {
idle: { variant: "light", color: "blue", label: t("copy") },
copied: { variant: "filled", color: "teal", label: t("copied") },
} as const;
const state = COPY_STATES[copied ? "copied" : "idle"];
return createElement(
Button as never,
{ size: "xs", variant: state.variant, color: state.color, onClick: () => void handleCopy() },
state.label,
);
}
const RUNTIME_COMPONENTS: Record<string, ComponentType<never>> = {
// Layout
Box,
Stack,
Group,
Flex,
Grid,
"Grid.Col": Grid.Col,
SimpleGrid,
Center,
Space,
Container,
AspectRatio,
Overlay,
ScrollArea,
VisuallyHidden,
Typography,
FloatingIndicator,
// Typography & text
Text,
Title,
Code,
Highlight,
Mark,
Kbd,
Blockquote,
Anchor: SafeAnchor,
NumberFormatter,
Marquee,
RollingNumber,
// Data display
Badge,
Card,
"Card.Section": Card.Section,
Paper,
Alert,
ThemeIcon,
ColorSwatch,
Table,
"Table.Thead": Table.Thead,
"Table.Tbody": Table.Tbody,
"Table.Tfoot": Table.Tfoot,
"Table.Caption": Table.Caption,
"Table.Tr": Table.Tr,
"Table.Th": Table.Th,
"Table.Td": Table.Td,
List,
"List.Item": List.Item,
Timeline,
"Timeline.Item": Timeline.Item,
Accordion,
"Accordion.Item": Accordion.Item,
"Accordion.Control": Accordion.Control,
"Accordion.Panel": Accordion.Panel,
Indicator,
Pill,
Spoiler,
Progress,
"Progress.Section": Progress.Section,
RingProgress,
SemiCircleProgress,
Skeleton,
Loader,
Image,
Avatar,
"Avatar.Group": Avatar.Group,
BackgroundImage,
Tooltip,
Divider,
DataList,
"DataList.Item": DataList.Item,
"DataList.ItemLabel": DataList.ItemLabel,
"DataList.ItemValue": DataList.ItemValue,
EmptyState,
"EmptyState.Indicator": EmptyState.Indicator,
"EmptyState.Title": EmptyState.Title,
"EmptyState.Description": EmptyState.Description,
"EmptyState.Actions": EmptyState.Actions,
Fieldset,
Notification: SafeNotification,
LoadingOverlay,
Rating: SafeRating,
// Navigation & structure
Breadcrumbs,
NavLink: SafeNavLink,
Stepper: SafeStepper,
"Stepper.Step": Stepper.Step,
Tabs,
"Tabs.List": Tabs.List,
"Tabs.Tab": Tabs.Tab,
"Tabs.Panel": Tabs.Panel,
Tree: SafeTree,
// Interactive display (handlers stripped — wired via SubFetch separately)
Button: SafeButton,
ActionIcon: SafeActionIcon,
Burger: SafeBurger,
CloseButton: SafeCloseButton,
Chip: SafeChip,
"Chip.Group": Chip.Group,
Pagination: SafePagination,
SegmentedControl: SafeSegmentedControl,
Slider: SafeSlider,
Switch: SafeSwitch,
// Hover overlays & menus
HoverCard,
"HoverCard.Target": HoverCard.Target,
"HoverCard.Dropdown": HoverCard.Dropdown,
Menu,
"Menu.Target": Menu.Target,
"Menu.Dropdown": Menu.Dropdown,
"Menu.Item": Menu.Item,
"Menu.Label": Menu.Label,
"Menu.Divider": Menu.Divider,
// Utility
CopyButton: SafeCopyButton,
Collapse,
Transition,
// Charts
AreaChart,
BarChart,
LineChart,
DonutChart,
PieChart,
RadarChart,
RadialBarChart,
Sparkline,
BubbleChart,
CompositeChart,
FunnelChart,
Heatmap,
ScatterChart,
SankeyChart,
Treemap,
BarsList,
ChartLegend,
ChartTooltip,
// Dates
Calendar: SafeCalendar,
MiniCalendar: SafeMiniCalendar,
DatePicker: SafeDatePicker,
Day: createSafeDateComponent(Day),
WeekdaysRow: createSafeDateComponent(WeekdaysRow),
Month: createSafeDateComponent(Month),
CalendarHeader: createSafeDateComponent(CalendarHeader),
YearPicker: createSafeDateComponent(YearPicker),
MonthPicker: createSafeDateComponent(MonthPicker),
TimeGrid: createSafeDateComponent(TimeGrid),
PickerControl: createSafeDateComponent(PickerControl),
YearsList: createSafeDateComponent(YearsList),
MonthsList: createSafeDateComponent(MonthsList),
DecadeLevel: createSafeDateComponent(DecadeLevel),
YearLevel: createSafeDateComponent(YearLevel),
MonthLevel: createSafeDateComponent(MonthLevel),
LevelsGroup: createSafeDateComponent(LevelsGroup),
DecadeLevelGroup: createSafeDateComponent(DecadeLevelGroup),
YearLevelGroup: createSafeDateComponent(YearLevelGroup),
MonthLevelGroup: createSafeDateComponent(MonthLevelGroup),
TimeValue,
// Custom interactive components (own state management)
PaginatedList,
TabsContainer,
TabPanel,
Collapsible,
StatBar,
TypeBadge,
TablerIcon: SafeTablerIcon,
// SubFetch — server-proxied HTTP from within templates
SubFetch,
SubData,
ActionButton,
ToggleSwitch,
RefreshButton,
};
const enabledComponentNames = new Set(enabledCustomJsxComponents.map(({ name }) => name));
export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = Object.fromEntries(
Object.entries(RUNTIME_COMPONENTS).filter(([name]) => enabledComponentNames.has(name)),
);
function sanitizeData(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(sanitizeData);
const safe: Record<string, unknown> = Object.create(null);
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (key === "constructor" || key === "__proto__" || key === "prototype") continue;
safe[key] = sanitizeData(value);
}
return safe;
}
const safeMath: Record<string, unknown> = Object.create(null);
safeMath.round = createSafeCallable((v: number) => Math.round(v));
safeMath.floor = createSafeCallable((v: number) => Math.floor(v));
safeMath.ceil = createSafeCallable((v: number) => Math.ceil(v));
safeMath.abs = createSafeCallable((v: number) => Math.abs(v));
safeMath.min = createSafeCallable((...args: number[]) => Math.min(...args));
safeMath.max = createSafeCallable((...args: number[]) => Math.max(...args));
safeMath.pow = createSafeCallable((b: number, e: number) => Math.pow(b, e));
safeMath.sqrt = createSafeCallable((v: number) => Math.sqrt(v));
safeMath.PI = Math.PI;
Object.freeze(safeMath);
const safeJSON: Record<string, unknown> = Object.create(null);
safeJSON.stringify = createSafeCallable((v: unknown) => JSON.stringify(v));
Object.freeze(safeJSON);
const safeArray: Record<string, unknown> = Object.create(null);
safeArray.isArray = createSafeCallable((v: unknown) => Array.isArray(v));
safeArray.from = createSafeCallable((v: ArrayLike<unknown> | Iterable<unknown>) => Array.from(v).slice(0, 2_000));
Object.freeze(safeArray);
const safeObject: Record<string, unknown> = Object.create(null);
safeObject.keys = createSafeCallable((v: object) => Object.keys(v));
safeObject.values = createSafeCallable((v: object) => Object.values(v));
safeObject.entries = createSafeCallable((v: object) => Object.entries(v));
Object.freeze(safeObject);
const safeDate: Record<string, unknown> = Object.create(null);
safeDate.now = createSafeCallable(() => Date.now());
// Date helpers return primitives only; native Date instances never enter the interpreter.
safeDate.create = createSafeCallable((v?: string | number) => (v != null ? new Date(v).getTime() : Date.now()));
safeDate.toISOString = createSafeCallable((v: string | number) => new Date(v).toISOString());
safeDate.toLocaleDateString = createSafeCallable((v: string | number, locale?: string) =>
new Date(v).toLocaleDateString(locale ?? "en-US"),
);
safeDate.toLocaleTimeString = createSafeCallable((v: string | number, locale?: string) =>
new Date(v).toLocaleTimeString(locale ?? "en-US"),
);
safeDate.getTime = createSafeCallable((v: string | number) => new Date(v).getTime());
safeDate.getYear = createSafeCallable((v: string | number) => new Date(v).getFullYear());
safeDate.getMonth = createSafeCallable((v: string | number) => new Date(v).getMonth());
safeDate.getDay = createSafeCallable((v: string | number) => new Date(v).getUTCDay());
Object.freeze(safeDate);
export const SAFE_BINDINGS = (apiData: unknown) => ({
data: sanitizeData(apiData),
String: createSafeCallable((v: unknown) => String(v)),
Number: createSafeCallable((v: unknown) => Number(v)),
Boolean: createSafeCallable((v: unknown) => Boolean(v)),
Math: safeMath,
JSON: safeJSON,
Array: safeArray,
Object: safeObject,
Date: safeDate,
parseInt: createSafeCallable((v: string, radix?: number) => parseInt(v, radix)),
parseFloat: createSafeCallable((v: string) => parseFloat(v)),
encodeURIComponent: createSafeCallable((v: string) => encodeURIComponent(v)),
decodeURIComponent: createSafeCallable((v: string) => decodeURIComponent(v)),
isNaN: createSafeCallable((v: unknown) => Number.isNaN(Number(v))),
isFinite: createSafeCallable((v: unknown) => Number.isFinite(Number(v))),
});
export const SAFE_BINDINGS = createCustomJsxBindings;
@@ -1,16 +0,0 @@
const safeCallables = new WeakSet<(...args: never[]) => unknown>();
/**
* Marks a host function as callable by the Custom JSX interpreter.
*
* The interpreter never calls an arbitrary function obtained from data or a
* prototype. Host helpers must opt in through this module instead.
*/
export function createSafeCallable<T extends (...args: never[]) => unknown>(callable: T): T {
safeCallables.add(callable);
return callable;
}
export function isSafeCallable(value: unknown): value is (...args: never[]) => unknown {
return typeof value === "function" && safeCallables.has(value as (...args: never[]) => unknown);
}
@@ -1,944 +0,0 @@
import type { ComponentType, ReactNode } from "react";
import { createElement, Fragment } from "react";
import { Parser } from "acorn";
import jsx from "acorn-jsx";
import { customJsxSupportedPropsByName } from "@homarr/definitions";
import { isSafeCallable } from "./safe-bindings";
interface AstNode {
type: string;
start?: number;
end?: number;
[key: string]: unknown;
}
interface InterpreterCallback {
readonly kind: typeof INTERPRETER_CALLBACK;
readonly params: readonly string[];
readonly body: AstNode;
readonly environment: Environment;
}
interface EvaluationBudgets {
maxAstDepth: number;
maxOperations: number;
maxCollectionItems: number;
maxRenderedNodes: number;
maxStringLength: number;
}
export interface SafeJsxBudgets extends Partial<EvaluationBudgets> {}
export interface RenderSafeJsxOptions {
template: string;
components: Readonly<Record<string, ComponentType<never>>>;
bindings: Readonly<Record<string, unknown>>;
budgets?: SafeJsxBudgets;
}
export interface SafeJsxRenderResult {
node: ReactNode;
warnings: string[];
}
const JsxParser = Parser.extend(jsx());
const INTERPRETER_CALLBACK = Symbol("custom-jsx-interpreter-callback");
const MAX_TEMPLATE_LENGTH = 50_000;
const DEFAULT_BUDGETS: EvaluationBudgets = {
maxAstDepth: 64,
maxOperations: 25_000,
maxCollectionItems: 2_000,
maxRenderedNodes: 10_000,
maxStringLength: 200_000,
};
const BLOCKED_PROPERTIES = new Set([
"__proto__",
"arguments",
"bind",
"call",
"callee",
"caller",
"constructor",
"prototype",
"apply",
]);
const BLOCKED_TAGS = new Set(["base", "embed", "form", "iframe", "link", "meta", "object", "script", "style"]);
const BLOCKED_PROPS = new Set([
"children",
"className",
"classNames",
"classes",
"component",
"dangerouslySetInnerHTML",
"innerRef",
"portalProps",
"ref",
"renderRoot",
"styles",
"unstyled",
"withinPortal",
]);
const URL_PROPS = new Set(["backgroundImage", "href", "src"]);
const BLOCKED_STYLE_KEYS = new Set([
"backdropFilter",
"behavior",
"bottom",
"clipPath",
"content",
"filter",
"inset",
"left",
"mask",
"pointerEvents",
"position",
"right",
"top",
"zIndex",
]);
export class SafeJsxError extends Error {
public constructor(message: string) {
super(message);
this.name = "SafeJsxError";
}
}
class Environment {
public constructor(
private readonly values: Readonly<Record<string, unknown>>,
private readonly parent?: Environment,
) {}
public get(name: string): unknown {
if (Object.hasOwn(this.values, name)) return this.values[name];
if (this.parent) return this.parent.get(name);
if (name === "undefined") return undefined;
if (name === "NaN") return Number.NaN;
if (name === "Infinity") return Number.POSITIVE_INFINITY;
throw new SafeJsxError(`Unknown binding: ${name}`);
}
}
class Budget {
private operations = 0;
private collectionItems = 0;
private renderedNodes = 0;
public constructor(private readonly limits: EvaluationBudgets) {}
public operation(depth: number): void {
if (depth > this.limits.maxAstDepth) {
throw new SafeJsxError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
}
this.operations += 1;
if (this.operations > this.limits.maxOperations) {
throw new SafeJsxError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
}
}
public collection(count = 1): void {
this.collectionItems += count;
if (this.collectionItems > this.limits.maxCollectionItems) {
throw new SafeJsxError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
}
}
public rendered(): void {
this.renderedNodes += 1;
if (this.renderedNodes > this.limits.maxRenderedNodes) {
throw new SafeJsxError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
}
}
public string(value: string): string {
if (value.length > this.limits.maxStringLength) {
throw new SafeJsxError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
}
return value;
}
}
function asNode(value: unknown, context: string): AstNode {
if (!value || typeof value !== "object" || typeof (value as { type?: unknown }).type !== "string") {
throw new SafeJsxError(`Invalid ${context} node`);
}
return value as AstNode;
}
function asNodeArray(value: unknown, context: string): AstNode[] {
if (!Array.isArray(value)) throw new SafeJsxError(`Invalid ${context}`);
return value.map((item) => asNode(item, context));
}
function normalizedProperty(value: unknown): string {
if (typeof value !== "string" && typeof value !== "number") {
throw new SafeJsxError("Property names must be strings or numbers");
}
const property = String(value);
const normalized = property.normalize("NFKC").toLowerCase();
if (BLOCKED_PROPERTIES.has(normalized)) {
throw new SafeJsxError(`Access to reflective property '${property}' is not allowed`);
}
return property;
}
function isArrayIndex(property: string): boolean {
if (!/^(?:0|[1-9]\d*)$/.test(property)) return false;
const index = Number(property);
return Number.isSafeInteger(index) && index >= 0;
}
function ownProperty(object: unknown, propertyValue: unknown): unknown {
const property = normalizedProperty(propertyValue);
if (object == null) return undefined;
if (Array.isArray(object)) {
if (property === "length") return object.length;
if (!isArrayIndex(property)) return undefined;
return Object.hasOwn(object, property) ? object[Number(property)] : undefined;
}
if (typeof object === "string") {
if (property === "length") return object.length;
if (!isArrayIndex(property)) return undefined;
return object[Number(property)];
}
if (typeof object !== "object") return undefined;
return Object.hasOwn(object, property) ? (object as Record<string, unknown>)[property] : undefined;
}
function isSafeUrl(value: unknown): boolean {
if (typeof value !== "string") return false;
if (value.startsWith("#")) return true;
if (value.startsWith("/") && !value.startsWith("//")) return true;
try {
const parsed = new URL(value);
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !parsed.username && !parsed.password;
} catch {
return false;
}
}
function sanitizeStyle(value: unknown): Record<string, string | number> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const result: Record<string, string | number> = {};
for (const [key, styleValue] of Object.entries(value)) {
if (BLOCKED_STYLE_KEYS.has(key) || key.startsWith("--")) continue;
if (typeof styleValue !== "string" && typeof styleValue !== "number") continue;
if (typeof styleValue === "string" && /(?:url\s*\(|expression\s*\(|javascript:|position\s*:)/i.test(styleValue)) {
continue;
}
result[key] = styleValue;
}
return result;
}
function isInterpreterCallback(value: unknown): value is InterpreterCallback {
return Boolean(value && typeof value === "object" && (value as { kind?: unknown }).kind === INTERPRETER_CALLBACK);
}
export function sanitizeCustomJsxProps(
props: Readonly<Record<string, unknown>>,
componentName?: string,
): Record<string, unknown> {
const safe: Record<string, unknown> = {};
const supportedProps = componentName ? customJsxSupportedPropsByName.get(componentName) : undefined;
for (const [key, value] of Object.entries(props)) {
if ((/^on/i.test(key) && !supportedProps?.has(key)) || BLOCKED_PROPS.has(key)) continue;
if (componentName && !supportedProps?.has(key)) continue;
if (typeof value === "function" || isInterpreterCallback(value)) continue;
if (URL_PROPS.has(key)) {
if (isSafeUrl(value)) safe[key] = value;
continue;
}
if (key === "style") {
const style = sanitizeStyle(value);
if (style) safe.style = style;
continue;
}
safe[key] = value;
}
return safe;
}
function jsxText(value: string): string {
const lines = value.replace(/\r/g, "").split("\n");
let result = "";
for (let index = 0; index < lines.length; index += 1) {
let line = lines[index] ?? "";
line = line.replace(/\t/g, " ");
if (index !== 0) line = line.replace(/^\s+/, "");
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
if (!line) continue;
if (result) result += " ";
result += line;
}
return result;
}
function numericArgument(value: unknown, fallback: number): number {
if (value === undefined) return fallback;
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.trunc(number);
}
function normalizedSliceIndex(value: unknown, length: number, fallback: number): number {
const index = numericArgument(value, fallback);
if (index < 0) return Math.max(length + index, 0);
return Math.min(index, length);
}
class Interpreter {
private readonly budget: Budget;
private readonly warnings = new Set<string>();
public constructor(
private readonly components: Readonly<Record<string, ComponentType<never>>>,
private readonly environment: Environment,
private readonly limits: EvaluationBudgets,
) {
this.budget = new Budget(limits);
}
public render(root: AstNode): SafeJsxRenderResult {
return { node: this.evaluate(root, this.environment, 0) as ReactNode, warnings: [...this.warnings] };
}
private evaluate(node: AstNode, environment: Environment, depth: number): unknown {
this.budget.operation(depth);
switch (node.type) {
case "Literal":
return node.value;
case "Identifier":
return environment.get(String(node.name));
case "JSXElement":
return this.evaluateJsxElement(node, environment, depth + 1);
case "JSXFragment":
return this.evaluateJsxFragment(node, environment, depth + 1);
case "JSXText": {
const text = this.budget.string(jsxText(String(node.value ?? "")));
if (text) this.budget.rendered();
return text || null;
}
case "JSXExpressionContainer": {
const expression = asNode(node.expression, "JSX expression");
if (expression.type === "JSXEmptyExpression") return null;
const value = this.evaluate(expression, environment, depth + 1);
return typeof value === "string" ? this.budget.string(value) : value;
}
case "ArrayExpression":
return this.evaluateArray(node, environment, depth + 1);
case "ObjectExpression":
return this.evaluateObject(node, environment, depth + 1);
case "UnaryExpression":
return this.evaluateUnary(node, environment, depth + 1);
case "BinaryExpression":
return this.evaluateBinary(node, environment, depth + 1);
case "LogicalExpression":
return this.evaluateLogical(node, environment, depth + 1);
case "ConditionalExpression":
return this.evaluate(node.test as AstNode, environment, depth + 1)
? this.evaluate(asNode(node.consequent, "conditional consequent"), environment, depth + 1)
: this.evaluate(asNode(node.alternate, "conditional alternate"), environment, depth + 1);
case "MemberExpression":
return this.evaluateMember(node, environment, depth + 1);
case "CallExpression":
return this.evaluateCall(node, environment, depth + 1);
case "ArrowFunctionExpression":
return this.createCallback(node, environment);
case "TemplateLiteral":
return this.evaluateTemplateLiteral(node, environment, depth + 1);
case "ChainExpression":
return this.evaluate(asNode(node.expression, "chain expression"), environment, depth + 1);
default:
throw new SafeJsxError(`Unsupported expression: ${node.type}`);
}
}
private evaluateJsxFragment(node: AstNode, environment: Environment, depth: number): ReactNode {
const children = asNodeArray(node.children, "fragment children").map((child) =>
this.evaluate(child, environment, depth + 1),
);
this.budget.rendered();
return createElement(Fragment, null, ...(children as ReactNode[]));
}
private evaluateJsxElement(node: AstNode, environment: Environment, depth: number): ReactNode {
const opening = asNode(node.openingElement, "JSX opening element");
const tag = this.jsxTagName(asNode(opening.name, "JSX tag"));
if (BLOCKED_TAGS.has(tag.toLowerCase())) {
this.warnings.add(`Blocked element: ${tag}`);
return null;
}
const component = this.components[tag];
if (!component) {
this.warnings.add(`Unknown or unavailable component: ${tag}`);
return null;
}
const rawProps: Record<string, unknown> = {};
for (const attribute of asNodeArray(opening.attributes, "JSX attributes")) {
if (attribute.type === "JSXSpreadAttribute") {
const spread = this.evaluate(asNode(attribute.argument, "spread attribute"), environment, depth + 1);
if (spread && typeof spread === "object" && !Array.isArray(spread)) {
Object.assign(rawProps, spread);
}
continue;
}
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
const nameNode = asNode(attribute.name, "attribute name");
if (nameNode.type !== "JSXIdentifier") throw new SafeJsxError("Namespaced JSX attributes are not supported");
const name = normalizedProperty(nameNode.name);
if (attribute.value == null) {
rawProps[name] = true;
} else {
const valueNode = asNode(attribute.value, "attribute value");
rawProps[name] =
valueNode.type === "Literal" ? valueNode.value : this.evaluate(valueNode, environment, depth + 1);
}
}
let children = asNodeArray(node.children, "JSX children").map((child) =>
this.evaluate(child, environment, depth + 1),
);
const props = sanitizeCustomJsxProps(rawProps, tag);
if (tag === "SubFetch") {
const callbackChildren = children.filter(isInterpreterCallback);
const callbackProp = rawProps.render;
if (callbackProp !== undefined && !isInterpreterCallback(callbackProp)) {
throw new SafeJsxError("SubFetch render must be an inline arrow callback");
}
if (callbackChildren.length > 1 || (callbackChildren.length === 1 && callbackProp !== undefined)) {
throw new SafeJsxError("SubFetch accepts only one render callback");
}
const renderCallback = isInterpreterCallback(callbackProp) ? callbackProp : callbackChildren[0];
if (renderCallback) {
props.render = (value: unknown, metadata: unknown) =>
new Interpreter(this.components, renderCallback.environment, this.limits).runCallback(
renderCallback,
[value, metadata],
0,
) as ReactNode;
children = children.filter((child) => child !== renderCallback);
}
}
this.budget.rendered();
return createElement(component, props as never, ...(children as ReactNode[]));
}
private jsxTagName(node: AstNode): string {
if (node.type === "JSXIdentifier") return String(node.name);
if (node.type === "JSXMemberExpression") {
return `${this.jsxTagName(asNode(node.object, "JSX member object"))}.${this.jsxTagName(
asNode(node.property, "JSX member property"),
)}`;
}
throw new SafeJsxError(`Unsupported JSX tag: ${node.type}`);
}
private evaluateArray(node: AstNode, environment: Environment, depth: number): unknown[] {
const result: unknown[] = [];
const elements = node.elements;
if (!Array.isArray(elements)) throw new SafeJsxError("Invalid array expression");
for (const elementValue of elements) {
if (elementValue == null) {
result.push(undefined);
continue;
}
const element = asNode(elementValue, "array element");
if (element.type === "SpreadElement") {
const spread = this.evaluate(asNode(element.argument, "array spread"), environment, depth + 1);
if (!Array.isArray(spread)) throw new SafeJsxError("Only arrays can be spread into arrays");
this.budget.collection(spread.length);
result.push(...spread);
} else {
this.budget.collection();
result.push(this.evaluate(element, environment, depth + 1));
}
}
return result;
}
private evaluateObject(node: AstNode, environment: Environment, depth: number): Record<string, unknown> {
const result: Record<string, unknown> = Object.create(null);
for (const property of asNodeArray(node.properties, "object properties")) {
if (property.type === "SpreadElement") {
const spread = this.evaluate(asNode(property.argument, "object spread"), environment, depth + 1);
if (!spread || typeof spread !== "object" || Array.isArray(spread)) {
throw new SafeJsxError("Only objects can be spread into objects");
}
for (const [key, value] of Object.entries(spread)) result[normalizedProperty(key)] = value;
continue;
}
if (property.type !== "Property" || property.kind !== "init" || property.method || property.shorthand) {
throw new SafeJsxError("Only explicit object properties are supported");
}
const keyNode = asNode(property.key, "object key");
const key = normalizedProperty(
property.computed
? this.evaluate(keyNode, environment, depth + 1)
: keyNode.type === "Identifier"
? keyNode.name
: keyNode.value,
);
result[key] = this.evaluate(asNode(property.value, "object value"), environment, depth + 1);
}
return result;
}
private evaluateUnary(node: AstNode, environment: Environment, depth: number): unknown {
const value = this.evaluate(asNode(node.argument, "unary argument"), environment, depth + 1);
switch (node.operator) {
case "!":
return !value;
case "+":
return Number(value);
case "-":
return -Number(value);
case "typeof":
return typeof value;
default:
throw new SafeJsxError(`Unsupported unary operator: ${String(node.operator)}`);
}
}
private evaluateBinary(node: AstNode, environment: Environment, depth: number): unknown {
const left = this.evaluate(asNode(node.left, "binary left operand"), environment, depth + 1);
const right = this.evaluate(asNode(node.right, "binary right operand"), environment, depth + 1);
switch (node.operator) {
case "+":
return typeof left === "string" || typeof right === "string"
? this.budget.string(String(left) + String(right))
: Number(left) + Number(right);
case "-":
return Number(left) - Number(right);
case "*":
return Number(left) * Number(right);
case "/":
return Number(left) / Number(right);
case "%":
return Number(left) % Number(right);
case "**":
return Number(left) ** Number(right);
case "<":
return (left as number) < (right as number);
case "<=":
return (left as number) <= (right as number);
case ">":
return (left as number) > (right as number);
case ">=":
return (left as number) >= (right as number);
case "==":
return String(left) === String(right);
case "!=":
return String(left) !== String(right);
case "===":
return left === right;
case "!==":
return left !== right;
default:
throw new SafeJsxError(`Unsupported binary operator: ${String(node.operator)}`);
}
}
private evaluateLogical(node: AstNode, environment: Environment, depth: number): unknown {
const left = this.evaluate(asNode(node.left, "logical left operand"), environment, depth + 1);
if (node.operator === "&&") {
return left ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
}
if (node.operator === "||") {
return left ? left : this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1);
}
if (node.operator === "??") {
return left == null ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
}
throw new SafeJsxError(`Unsupported logical operator: ${String(node.operator)}`);
}
private memberProperty(node: AstNode, environment: Environment, depth: number): string {
const propertyNode = asNode(node.property, "member property");
return normalizedProperty(
node.computed
? this.evaluate(propertyNode, environment, depth + 1)
: propertyNode.type === "Identifier"
? propertyNode.name
: propertyNode.value,
);
}
private evaluateMember(node: AstNode, environment: Environment, depth: number): unknown {
const object = this.evaluate(asNode(node.object, "member object"), environment, depth + 1);
const property = this.memberProperty(node, environment, depth + 1);
return ownProperty(object, property);
}
private evaluateCall(node: AstNode, environment: Environment, depth: number): unknown {
if (node.optional) throw new SafeJsxError("Optional calls are not supported");
const args = asNodeArray(node.arguments, "call arguments").map((argument) => {
if (argument.type === "SpreadElement") throw new SafeJsxError("Spread call arguments are not supported");
return this.evaluate(argument, environment, depth + 1);
});
const callee = asNode(node.callee, "call target");
if (callee.type === "MemberExpression") {
const receiver = this.evaluate(asNode(callee.object, "method receiver"), environment, depth + 1);
const method = this.memberProperty(callee, environment, depth + 1);
const collectionResult = this.callCollectionMethod(receiver, method, args, depth + 1);
if (collectionResult.handled) return collectionResult.value;
const callable = ownProperty(receiver, method);
if (!isSafeCallable(callable)) throw new SafeJsxError(`Calling method '${method}' is not allowed`);
return this.accountHostResult(callable(...(args as never[])));
}
const callable = this.evaluate(callee, environment, depth + 1);
if (!isSafeCallable(callable)) throw new SafeJsxError("Calling this value is not allowed");
return this.accountHostResult(callable(...(args as never[])));
}
private callCollectionMethod(
receiver: unknown,
method: string,
args: unknown[],
depth: number,
): { handled: boolean; value?: unknown } {
if (Array.isArray(receiver)) {
if (method === "map" || method === "filter") {
const callback = args[0];
if (isSafeCallable(callback)) {
const result: unknown[] = [];
for (let index = 0; index < receiver.length; index += 1) {
this.budget.collection();
const callbackResult = callback(receiver[index] as never);
if (method === "map") result.push(callbackResult);
else if (callbackResult) result.push(receiver[index]);
}
return { handled: true, value: result };
}
if (!isInterpreterCallback(callback)) throw new SafeJsxError(`${method} requires an inline arrow callback`);
const result: unknown[] = [];
for (let index = 0; index < receiver.length; index += 1) {
this.budget.collection();
const callbackResult = this.runCallback(callback, [receiver[index], index, receiver], depth + 1);
if (method === "map" || callbackResult) result.push(method === "map" ? callbackResult : receiver[index]);
}
return { handled: true, value: result };
}
if (method === "pop") {
this.budget.collection();
return { handled: true, value: receiver.length > 0 ? receiver[receiver.length - 1] : undefined };
}
if (method === "slice") {
const start = normalizedSliceIndex(args[0], receiver.length, 0);
const end = normalizedSliceIndex(args[1], receiver.length, receiver.length);
const result: unknown[] = [];
for (let index = start; index < Math.max(start, end); index += 1) {
this.budget.collection();
result.push(receiver[index]);
}
return { handled: true, value: result };
}
if (method === "join") {
this.budget.collection(receiver.length);
const separator = String(args[0] ?? ",");
let result = "";
receiver.forEach((item, index) => {
result += `${index === 0 ? "" : separator}${String(item ?? "")}`;
this.budget.string(result);
});
return { handled: true, value: result };
}
if (method === "includes") {
this.budget.collection(receiver.length);
return { handled: true, value: receiver.some((item) => Object.is(item, args[0])) };
}
if (method === "indexOf") {
this.budget.collection(receiver.length);
return { handled: true, value: receiver.findIndex((item) => Object.is(item, args[0])) };
}
if (method === "find" || method === "findIndex" || method === "some" || method === "every") {
const callback = args[0];
const useSafe = isSafeCallable(callback);
if (!useSafe && !isInterpreterCallback(callback))
throw new SafeJsxError(`${method} requires an inline arrow callback`);
for (let index = 0; index < receiver.length; index += 1) {
this.budget.collection();
const result = useSafe
? (callback as (...a: never[]) => unknown)(receiver[index] as never)
: this.runCallback(callback as InterpreterCallback, [receiver[index], index, receiver], depth + 1);
if (method === "find" && result) return { handled: true, value: receiver[index] };
if (method === "findIndex" && result) return { handled: true, value: index };
if (method === "some" && result) return { handled: true, value: true };
if (method === "every" && !result) return { handled: true, value: false };
}
const defaults: Record<string, unknown> = { find: undefined, findIndex: -1, some: false, every: true };
return { handled: true, value: defaults[method] };
}
if (method === "sort") {
const callback = args[0];
if (!isInterpreterCallback(callback)) throw new SafeJsxError("sort requires an inline arrow callback");
const copy = [...receiver];
copy.sort((left, right) => {
this.budget.collection();
const delta = Number(this.runCallback(callback, [left, right], depth + 1));
if (!Number.isFinite(delta)) return 0;
return delta;
});
return { handled: true, value: copy };
}
if (method === "reduce") {
const callback = args[0];
if (!isInterpreterCallback(callback)) throw new SafeJsxError("reduce requires an inline arrow callback");
if (receiver.length === 0 && args.length < 2) {
throw new SafeJsxError("Reduce of empty array with no initial value");
}
let startIndex = 0;
let accumulator: unknown;
if (args.length < 2) {
accumulator = receiver[0];
startIndex = 1;
} else {
accumulator = args[1];
}
for (let index = startIndex; index < receiver.length; index += 1) {
this.budget.collection();
accumulator = this.runCallback(callback, [accumulator, receiver[index], index, receiver], depth + 1);
}
return { handled: true, value: accumulator };
}
if (method === "at") {
const index = numericArgument(args[0], 0);
let normalizedIndex = index;
if (index < 0) normalizedIndex = receiver.length + index;
this.budget.collection();
const outOfBounds = normalizedIndex < 0 || normalizedIndex >= receiver.length;
return { handled: true, value: outOfBounds ? undefined : receiver[normalizedIndex] };
}
if (method === "flat") {
// ponytail: only depth=1 supported; deeper nesting requires recursive budget tracking
const depth = numericArgument(args[0], 1);
if (depth !== 1) throw new SafeJsxError("flat() only supports depth 1");
const result: unknown[] = [];
for (const item of receiver) {
this.budget.collection();
if (Array.isArray(item)) {
for (const nested of item) {
this.budget.collection();
result.push(nested);
}
} else {
result.push(item);
}
}
return { handled: true, value: result };
}
if (method === "reverse") {
const copy = [...receiver];
const result: unknown[] = [];
for (let index = copy.length - 1; index >= 0; index -= 1) {
this.budget.collection();
result.push(copy[index]);
}
return { handled: true, value: result };
}
}
if (typeof receiver === "string") {
switch (method) {
case "toUpperCase":
return { handled: true, value: this.budget.string(receiver.toUpperCase()) };
case "toLowerCase":
return { handled: true, value: this.budget.string(receiver.toLowerCase()) };
case "trim":
return { handled: true, value: this.budget.string(receiver.trim()) };
case "trimStart":
return { handled: true, value: this.budget.string(receiver.trimStart()) };
case "trimEnd":
return { handled: true, value: this.budget.string(receiver.trimEnd()) };
case "replace": {
const search = String(args[0] ?? "");
const replacement = String(args[1] ?? "");
const matchIndex = receiver.indexOf(search);
if (matchIndex === -1) return { handled: true, value: this.budget.string(receiver) };
return {
handled: true,
value: this.budget.string(
receiver.slice(0, matchIndex) + replacement + receiver.slice(matchIndex + search.length),
),
};
}
case "replaceAll": {
const search = String(args[0] ?? "");
const replacement = String(args[1] ?? "");
if (search.length === 0) return { handled: true, value: this.budget.string(receiver) };
let result = "";
let cursor = 0;
while (cursor < receiver.length) {
const matchIndex = receiver.indexOf(search, cursor);
if (matchIndex === -1) {
result += receiver.slice(cursor);
break;
}
result += receiver.slice(cursor, matchIndex) + replacement;
cursor = matchIndex + search.length;
}
return { handled: true, value: this.budget.string(result) };
}
case "indexOf":
return { handled: true, value: receiver.indexOf(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "lastIndexOf": {
const fromIndex = args[1] !== undefined ? numericArgument(args[1], receiver.length) : undefined;
return { handled: true, value: receiver.lastIndexOf(String(args[0] ?? ""), fromIndex) };
}
case "repeat": {
let maxCount = 0;
if (receiver.length > 0) maxCount = Math.floor(200_001 / receiver.length);
const count = Math.max(0, Math.min(numericArgument(args[0], 0), maxCount));
return { handled: true, value: this.budget.string(receiver.repeat(count).slice(0, 200_001)) };
}
case "slice":
return {
handled: true,
value: this.budget.string(receiver.slice(numericArgument(args[0], 0), args[1] as number | undefined)),
};
case "substring":
return {
handled: true,
value: this.budget.string(receiver.substring(numericArgument(args[0], 0), args[1] as number | undefined)),
};
case "includes":
return { handled: true, value: receiver.includes(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "startsWith":
return { handled: true, value: receiver.startsWith(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "endsWith":
return { handled: true, value: receiver.endsWith(String(args[0] ?? ""), args[1] as number | undefined) };
case "padStart":
return {
handled: true,
value: this.budget.string(
receiver.padStart(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
),
};
case "padEnd":
return {
handled: true,
value: this.budget.string(
receiver.padEnd(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
),
};
case "charAt":
return { handled: true, value: receiver.charAt(numericArgument(args[0], 0)) };
case "split": {
const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 2_000)));
const split = args[0] === undefined ? [receiver] : receiver.split(String(args[0]), limit);
this.budget.collection(split.length);
return { handled: true, value: split };
}
}
}
if (typeof receiver === "number") {
if (method === "toFixed") {
const digits = Math.max(0, Math.min(20, numericArgument(args[0], 0)));
return { handled: true, value: receiver.toFixed(digits) };
}
if (method === "toPrecision") {
const precision = Math.max(1, Math.min(21, numericArgument(args[0], 1)));
return { handled: true, value: receiver.toPrecision(precision) };
}
}
if (
(typeof receiver === "number" || typeof receiver === "boolean" || typeof receiver === "string") &&
method === "toString"
) {
return { handled: true, value: this.budget.string(String(receiver)) };
}
return { handled: false };
}
private accountHostResult(value: unknown): unknown {
if (typeof value === "string") return this.budget.string(value);
if (Array.isArray(value)) this.budget.collection(value.length);
return value;
}
private createCallback(node: AstNode, environment: Environment): InterpreterCallback {
if (node.async || node.generator || node.expression === false) {
throw new SafeJsxError("Only synchronous expression-bodied arrow callbacks are supported");
}
const params = asNodeArray(node.params, "callback parameters").map((param) => {
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
return normalizedProperty(param.name);
});
return {
kind: INTERPRETER_CALLBACK,
params,
body: asNode(node.body, "callback body"),
environment,
};
}
private runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown {
const values: Record<string, unknown> = Object.create(null);
callback.params.forEach((name, index) => {
values[name] = args[index];
});
return this.evaluate(callback.body, new Environment(values, callback.environment), depth + 1);
}
private evaluateTemplateLiteral(node: AstNode, environment: Environment, depth: number): string {
const quasis = asNodeArray(node.quasis, "template literal parts");
const expressions = asNodeArray(node.expressions, "template literal expressions");
let result = "";
quasis.forEach((quasi, index) => {
const value = quasi.value;
if (!value || typeof value !== "object") throw new SafeJsxError("Invalid template literal part");
result += String((value as { cooked?: unknown }).cooked ?? "");
const expression = expressions[index];
if (expression) result += String(this.evaluate(expression, environment, depth + 1));
});
return this.budget.string(result);
}
}
function parseTemplate(template: string): AstNode {
if (template.length > MAX_TEMPLATE_LENGTH) {
throw new SafeJsxError(`Template exceeds the ${MAX_TEMPLATE_LENGTH} character limit`);
}
let program: AstNode;
try {
program = JsxParser.parse(`<>${template}</>`, {
ecmaVersion: "latest",
sourceType: "module",
allowAwaitOutsideFunction: false,
allowReturnOutsideFunction: false,
}) as unknown as AstNode;
} catch (error) {
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
}
const body = asNodeArray(program.body, "program body");
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
throw new SafeJsxError("Template must contain JSX only");
}
return asNode(body[0].expression, "template expression");
}
export function renderSafeJsx({ template, components, bindings, budgets }: RenderSafeJsxOptions): SafeJsxRenderResult {
const limits: EvaluationBudgets = { ...DEFAULT_BUDGETS, ...budgets };
for (const [name, value] of Object.entries(limits)) {
if (!Number.isSafeInteger(value) || value <= 0) throw new SafeJsxError(`Invalid interpreter budget: ${name}`);
}
const root = parseTemplate(template);
return new Interpreter(components, new Environment(bindings), limits).render(root);
}
@@ -1,51 +1,105 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
export interface CustomJsxRuntimeContextValue {
/** The placed board item used for all authorization decisions. */
import { fetchApi, clientApi } from "@homarr/api/client";
import type {
CustomJsxRequestCapability,
CustomWidgetRuntimeMessages,
CustomWidgetRuntimePort,
} from "@homarr/custom-widgets/runtime";
import { CustomWidgetRuntimeProvider } from "@homarr/custom-widgets/runtime";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface WidgetDefinitionProviderProps {
itemId?: string;
/** Kept temporarily for display-only v1 templates. Never used for network authorization. */
definitionId?: string;
/** Short-lived, user-bound session used only by the admin authoring preview. */
previewSessionId?: string;
previewLiveActions?: boolean;
isEditMode: boolean;
isEditMode?: boolean;
requestCapabilities?: readonly CustomJsxRequestCapability[];
children: ReactNode;
}
export interface CustomJsxRequestCapability {
id: string;
kind: "query" | "action";
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
minimumBoardPermission: "view" | "modify" | "full";
}
const CustomJsxRuntimeContext = createContext<CustomJsxRuntimeContextValue>({ isEditMode: false });
export function WidgetDefinitionProvider({
itemId,
definitionId,
previewSessionId,
previewLiveActions = false,
isEditMode = false,
requestCapabilities = [],
children,
}: CustomJsxRuntimeContextValue & { children: ReactNode }) {
export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
const t = useScopedI18n("widget.customApi.customJsx");
const utils = clientApi.useUtils();
const { openConfirmModal } = useConfirmModal();
const messages: CustomWidgetRuntimeMessages = {
migrationRequired: t("migrationRequired"),
unsavedPreview: t("unsavedPreview"),
invalidParams: t("invalidParams"),
loadRequest: t("loadRequest"),
requestFailed: t("requestFailed"),
loading: t("loading"),
retry: t("retry"),
widgetItemUnavailable: t("widgetItemUnavailable"),
actionsDisabledEditMode: t("actionsDisabledEditMode"),
actionSimulated: t("actionSimulated"),
actionCompleted: t("actionCompleted"),
confirmDelete: t("confirmDelete"),
toggle: t("toggle"),
refresh: t("refresh"),
};
const port: CustomWidgetRuntimePort = {
query: async (input, signal) => {
if (input.itemId) {
return fetchApi.widget.customApi.queryRequest.query(
{ itemId: input.itemId, requestId: input.requestId, params: input.params },
{ signal },
);
}
return fetchApi.customWidget.previewQuery.query(
{ sessionId: input.previewSessionId ?? "", requestId: input.requestId, params: input.params },
{ signal },
);
},
executeAction: (input) =>
input.itemId
? fetchApi.widget.customApi.executeAction.mutate({
itemId: input.itemId,
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
})
: fetchApi.customWidget.previewAction.mutate({
sessionId: input.previewSessionId ?? "",
requestId: input.requestId,
params: input.params,
confirmed: input.confirmed,
}),
invalidate: async ({ itemId, previewSessionId, targets }) => {
if (previewSessionId) {
await utils.customWidget.previewQuery.invalidate();
return;
}
if (!itemId) return;
const tasks: Promise<unknown>[] = [];
if (targets.includes("parent")) tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));
if (targets.some((target) => target !== "parent")) tasks.push(utils.widget.customApi.queryRequest.invalidate());
await Promise.all(tasks);
},
confirm: ({ title, message }) =>
new Promise((resolve) => {
openConfirmModal({ title, children: message, onConfirm: () => resolve(true), onCancel: () => resolve(false) });
}),
notify: ({ kind, ...notification }) =>
kind === "success" ? showSuccessNotification(notification) : showErrorNotification(notification),
};
return (
<CustomJsxRuntimeContext.Provider
value={{ itemId, definitionId, previewSessionId, previewLiveActions, isEditMode, requestCapabilities }}
<CustomWidgetRuntimeProvider
itemId={props.itemId}
definitionId={props.definitionId}
previewSessionId={props.previewSessionId}
previewLiveActions={props.previewLiveActions}
isEditMode={props.isEditMode ?? false}
requestCapabilities={props.requestCapabilities ?? []}
port={port}
messages={messages}
>
{children}
</CustomJsxRuntimeContext.Provider>
{props.children}
</CustomWidgetRuntimeProvider>
);
}
export function useWidgetDefinitionId(): string {
return useContext(CustomJsxRuntimeContext).definitionId ?? "";
}
export function useCustomJsxRuntime(): CustomJsxRuntimeContextValue {
return useContext(CustomJsxRuntimeContext);
}