feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-15 14:06:02 +02:00
1 parent 0a0b3a12c9
commit 3b2fc2527e
181 files changed
+18472 -4364

No files matched your search

@@ -0,0 +1,168 @@
import { customJsxExamples } from "./examples";
import { enabledCustomJsxComponents } from "./component-registry";
const PROMPT_HEADER = `You are configuring a Homarr custom widget. Ask a concise clarifying question only when the requested behavior is ambiguous.
For Custom JSX, return a Homarr authoring bundle with exactly two fenced code blocks:
1. A \`json\` block containing the complete import object. Set \`displayConfig.template\` to \`"__HOMARR_TEMPLATE__"\`.
2. A \`jsx\` block containing the readable, multiline template with no JSON escaping.
The user can paste both blocks together into Homarr. Homarr combines and validates them before import. For non-Custom-JSX display types, return only the complete JSON block.
## Rules
- Use \`"$schema": "homarr-custom-widget-v3"\`.
- \`displayType\` and \`displayConfig.type\` must match.
- Prefer \`customJsx\` with \`jsxApiVersion: 2\` for custom layouts.
- The base widget request must use GET. Mutations belong in named action requests.
- JSX cannot call \`fetch\` or use event handlers. Use SubFetch, ActionButton, ToggleSwitch, and RefreshButton.
- Version 2 network components reference a literal \`requestId\` and pass only declared \`params\`.
- SubFetch can reference query requests only. ActionButton and ToggleSwitch can reference action requests only.
- Prefer the SubFetch function-as-children API for fetched data. The callback receives \`(response, metadata)\` and may return any safe JSX tree. Use SubData only for very small path-based displays.
- SubFetch owns loading, failure, retry, cancellation, and stale-response handling. The render callback runs only when response data is available.
- Named request paths are same-origin paths beginning with \`/\`. Do not put credentials in a template or export.
- Query requests use GET and view permission. POST, PUT, and PATCH actions default to modify. DELETE requires full permission and confirmation.
- Use only components in the generated component reference below.
- Template output must fit inside a dashboard widget and remain usable at narrow widths.
- Return the complete import object, not a template fragment. Preserve unrelated fields from the current configuration.
- Include stable keys for rendered collections, accessible labels for controls and images, useful empty states, and concise content that works in light and dark themes.
- Prefer one clear information hierarchy over nested cards. Use ActionButton and ToggleSwitch only when the named action and required permission are declared.
## Named request shape
\`\`\`ts
interface CustomJsxRequest {
id: string;
kind: "query" | "action";
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
pathTemplate: \`/\${string}\`;
parameters: Record<string, "string" | "number" | "boolean">;
bodyTemplate?: unknown;
staticHeaders?: Record<string, string>;
auth: "inherit" | "none";
minimumBoardPermission: "view" | "modify" | "full";
cacheTtlSeconds?: number;
}
\`\`\`
Path parameters use \`{name}\`. JSON body parameters use \`{ "$param": "name" }\` and preserve the declared type.
## SubFetch rendering
Use a render child when a named query needs its own layout:
\`\`\`jsx
<SubFetch requestId="service-detail" params={{ id: data.serviceId }}>
{(detail, meta) =>
<Stack gap="xs">
<Group justify="space-between">
<Text fw={700}>{detail.name}</Text>
<Badge color={meta.ok ? "green" : "red"}>HTTP {meta.status}</Badge>
</Group>
{detail.metrics.map((metric) =>
<Group key={metric.name} justify="space-between">
<Text size="sm">{metric.name}</Text>
<Text fw={600}>{metric.value}</Text>
</Group>
)}
</Stack>
}
</SubFetch>
\`\`\`
The callback is an interpreter-managed inline arrow function. Do not use native callbacks, event handlers, global state, or direct network access. Keep list keys stable and design for narrow and wide widget sizes.
`;
const PROMPT_NO_RESPONSE = `No sample response is available. Leave this marker in your reasoning and ask the user for a representative response if its shape is required:
\`\`\`json
PASTE_API_RESPONSE_HERE
\`\`\`
`;
function buildComponentReference() {
const categories = new Map<string, string[]>();
for (const component of enabledCustomJsxComponents) {
const names = categories.get(component.category) ?? [];
names.push(component.name);
categories.set(component.category, names);
}
return [...categories.entries()].map(([category, names]) => `- ${category}: ${names.join(", ")}`).join("\n");
}
function buildExamples() {
return customJsxExamples
.map(
(example) => `### ${example.title}
${example.description}
\`\`\`json
${JSON.stringify(
{
displayType: "customJsx",
displayConfig: {
type: "customJsx",
jsxApiVersion: 2,
networkScope: "public",
template: "__HOMARR_TEMPLATE__",
requests: example.requests,
},
},
null,
2,
)}
\`\`\`
\`\`\`jsx
${example.template}
\`\`\``,
)
.join("\n\n");
}
export function buildCustomWidgetAiPrompt(
jsonSchema: unknown,
rawResponse?: string | null,
currentConfig?: Record<string, unknown> | null,
) {
const responseSection = rawResponse
? `## API response\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
: `## API response\n\n${PROMPT_NO_RESPONSE}`;
const currentDisplayConfig = currentConfig?.displayConfig;
const currentTemplate =
currentDisplayConfig && typeof currentDisplayConfig === "object" && "template" in currentDisplayConfig
? currentDisplayConfig.template
: null;
const readableCurrentConfig = currentConfig
? {
...currentConfig,
...(currentTemplate && typeof currentTemplate === "string"
? {
displayConfig: {
...(currentDisplayConfig as Record<string, unknown>),
template: "__HOMARR_TEMPLATE__",
},
}
: {}),
}
: null;
const configSection = readableCurrentConfig
? `## Current configuration\n\nUse this as the starting point and preserve unrelated settings.\n\n\`\`\`json\n${JSON.stringify(readableCurrentConfig, null, 2)}\n\`\`\`\n${typeof currentTemplate === "string" ? `\n\`\`\`jsx\n${currentTemplate}\n\`\`\`\n` : ""}`
: "";
return `${PROMPT_HEADER}
## Import schema
The \`__HOMARR_TEMPLATE__\` value is an authoring transport placeholder. Homarr replaces it with the following JSX block before schema and AST validation.
\`\`\`json
${JSON.stringify(jsonSchema, null, 2)}
\`\`\`
## Generated component reference
${buildComponentReference()}
## Tested examples
${buildExamples()}
${responseSection}
${configSection}
## Requested widget
Describe what the widget should show and which interactions it needs:
`;
}
@@ -0,0 +1,235 @@
import { z } from "zod/v4";
export const singleValueDisplayConfigSchema = z.object({
type: z
.literal("singleValue")
.describe("Display type discriminator — must be 'singleValue' when displayType is singleValue"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the value from the API response (e.g. $.data.count, $.items[0].name, $.status). Use $ for the root object.",
),
label: z
.string()
.describe("Human-readable label shown alongside the extracted value (e.g. 'CPU Temperature', 'Total Users')"),
unit: z
.string()
.describe("Unit suffix displayed after the value (e.g. '°C', '%', 'GB'). Leave empty string if no unit is needed."),
valueSize: z
.enum(["sm", "md", "lg", "xl"])
.optional()
.describe(
"Font size of the displayed value: 'sm' (small), 'md' (medium), 'lg' (large, default), 'xl' (extra large)",
),
labelPosition: z
.enum(["above", "below"])
.optional()
.describe("Position of the label relative to the value: 'above' or 'below' (default: below)"),
});
const keyValueMappingSchema = z.object({
label: z.string().min(1).describe("Label for this key-value pair (e.g. 'CPU', 'Memory', 'Uptime')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract this value from the API response (e.g. $.cpu.percent, $.memory.used). Use $ for the root object.",
),
unit: z
.string()
.describe("Unit suffix for this value (e.g. '%', 'GB', 'ms'). Leave empty string if no unit is needed."),
});
export const keyValueDisplayConfigSchema = z.object({
type: z.literal("keyValue").describe("Display type discriminator — must be 'keyValue' when displayType is keyValue"),
mappings: z
.array(keyValueMappingSchema)
.min(1)
.describe("Array of label/jsonPath/unit mappings — each entry becomes one labeled value in the widget"),
layout: z
.enum(["list", "grid"])
.optional()
.describe("Layout style: 'list' (vertical stack, default) or 'grid' (multi-column grid)"),
columns: z
.number()
.int()
.min(1)
.max(3)
.optional()
.describe("Number of columns when layout is 'grid' (1–3, default: 2)"),
});
const tableColumnSchema = z.object({
header: z.string().min(1).describe("Column header text shown in the table (e.g. 'Name', 'Status', 'Size')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression relative to each row object to extract the cell value (e.g. $.name, $.status, $.size). Do not include the array path here — that goes in tablePath.",
),
});
export const tableDisplayConfigSchema = z.object({
type: z.literal("table").describe("Display type discriminator — must be 'table' when displayType is table"),
tablePath: z
.string()
.min(1)
.describe(
"JSONPath expression to the array of row objects in the API response (e.g. $.data.items, $.results, $.users[*])",
),
columns: z
.array(tableColumnSchema)
.min(1)
.describe("Column definitions — each column has a header and a jsonPath relative to each row"),
striped: z
.boolean()
.optional()
.describe("Whether to alternate row background colors for readability (default: true)"),
compact: z.boolean().optional().describe("Whether to use compact row spacing (default: false)"),
});
const statGridItemSchema = z.object({
label: z.string().min(1).describe("Label for this stat card (e.g. 'Movies', 'Series', 'Episodes')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the stat value from the API response (e.g. $.library.movies, $.counts.series)",
),
unit: z.string().describe("Unit suffix for the stat (e.g. '', 'GB', '%'). Leave empty string if no unit is needed."),
color: z
.string()
.optional()
.describe("Mantine color name for the stat card (e.g. 'blue', 'green', 'red', 'orange', 'violet'). Default: blue"),
});
export const statGridDisplayConfigSchema = z.object({
type: z.literal("statGrid").describe("Display type discriminator — must be 'statGrid' when displayType is statGrid"),
items: z
.array(statGridItemSchema)
.min(1)
.describe("Array of stat cards — each item shows a labeled value in a colored card"),
columns: z.number().int().min(1).max(4).optional().describe("Number of columns in the stat grid (1–4, default: 2)"),
cardStyle: z
.enum(["filled", "outline", "subtle"])
.optional()
.describe(
"Visual style of stat cards: 'filled' (solid background, default), 'outline' (border only), 'subtle' (light tint)",
),
});
const progressBarItemSchema = z.object({
label: z.string().min(1).describe("Label for this progress bar (e.g. 'Disk Usage', 'Memory', 'Quota')"),
valuePath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the current value (numerator) from the API response (e.g. $.disk.used, $.memory.used_bytes)",
),
maxPath: z
.string()
.optional()
.describe(
"JSONPath expression to extract the maximum value (denominator). If omitted, defaults to 100. Use for ratios like used/total (e.g. $.disk.total)",
),
unit: z
.string()
.describe("Unit suffix shown alongside values (e.g. 'GB', 'MB', '%'). Leave empty string if no unit is needed."),
color: z
.string()
.optional()
.describe("Mantine color name for the progress bar fill (e.g. 'blue', 'green', 'red'). Default: blue"),
});
export const progressBarsDisplayConfigSchema = z.object({
type: z
.literal("progressBars")
.describe("Display type discriminator — must be 'progressBars' when displayType is progressBars"),
bars: z
.array(progressBarItemSchema)
.min(1)
.describe("Array of progress bar definitions — each bar shows value/max as a visual progress indicator"),
showPercentage: z.boolean().optional().describe("Whether to display the percentage next to each bar (default: true)"),
barSize: z
.enum(["sm", "md", "lg"])
.optional()
.describe("Height of progress bars: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
});
const statusIndicatorItemSchema = z.object({
label: z.string().min(1).describe("Label for this status indicator (e.g. 'Web Server', 'Database', 'API')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the status value from the API response (e.g. $.services.web.status, $.health.database)",
),
goodValues: z
.array(z.string())
.min(1)
.describe(
"Array of string values considered 'healthy/good' — case-insensitive match (e.g. ['online', 'up', 'true', 'healthy', 'ok'])",
),
});
export const statusIndicatorDisplayConfigSchema = z.object({
type: z
.literal("statusIndicator")
.describe("Display type discriminator — must be 'statusIndicator' when displayType is statusIndicator"),
items: z
.array(statusIndicatorItemSchema)
.min(1)
.describe("Array of status indicators — each shows a green dot if the value matches goodValues, red otherwise"),
layout: z
.enum(["list", "grid"])
.optional()
.describe("Layout style: 'list' (vertical, default) or 'grid' (multi-column)"),
dotSize: z
.enum(["sm", "md", "lg"])
.optional()
.describe("Size of status dots: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
});
const countGridItemSchema = z.object({
label: z.string().min(1).describe("Label for this count (e.g. 'Movies', 'Shows', 'Albums')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the count value from the API response (e.g. $.counts.movies, $.stats.total)",
),
unit: z.string().describe("Unit suffix for the count (e.g. '', 'items'). Leave empty string if no unit is needed."),
});
export const countGridDisplayConfigSchema = z.object({
type: z
.literal("countGrid")
.describe("Display type discriminator — must be 'countGrid' when displayType is countGrid"),
items: z
.array(countGridItemSchema)
.min(1)
.describe("Array of count items — each shows a number with a label in a simple grid (no colors)"),
columns: z.number().int().min(2).max(4).optional().describe("Number of columns in the count grid (2–4, default: 2)"),
valueSize: z
.enum(["sm", "md", "lg"])
.optional()
.describe("Font size of count values: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
});
export const rawDisplayConfigSchema = z.object({
type: z.literal("raw").describe("Display type discriminator — must be 'raw' when displayType is raw"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the data to display (e.g. $ for entire response, $.data, $.result). The extracted value is pretty-printed as JSON.",
),
maxHeight: z
.number()
.int()
.min(50)
.max(1000)
.optional()
.describe("Maximum height of the raw JSON display area in pixels (50–1000, default: 300)"),
});
@@ -0,0 +1,91 @@
import { categorySafeProps, commonSafeProps } from "./component-props";
import type {
CustomJsxComponentCategory,
CustomJsxComponentPackage,
CustomJsxComponentSafety,
} from "./component-types";
export type * from "./component-types";
export interface CustomJsxComponentDescriptor {
name: string;
package: CustomJsxComponentPackage;
category: CustomJsxComponentCategory;
safety: CustomJsxComponentSafety;
supportedProps: readonly string[];
subcomponents: readonly string[];
accessibilityRequirements: readonly string[];
documentationUrl: string;
reason?: string;
}
export const subcomponentsByName: Readonly<Record<string, readonly string[]>> = {
Accordion: ["Accordion.Item", "Accordion.Control", "Accordion.Panel"],
Avatar: ["Avatar.Group"],
Card: ["Card.Section"],
Chip: ["Chip.Group"],
DataList: ["DataList.Item", "DataList.ItemLabel", "DataList.ItemValue"],
EmptyState: ["EmptyState.Indicator", "EmptyState.Title", "EmptyState.Description", "EmptyState.Actions"],
Grid: ["Grid.Col"],
List: ["List.Item"],
Menu: ["Menu.Target", "Menu.Dropdown", "Menu.Item", "Menu.Label", "Menu.Divider"],
Progress: ["Progress.Section"],
Table: ["Table.Thead", "Table.Tbody", "Table.Tfoot", "Table.Caption", "Table.Tr", "Table.Th", "Table.Td"],
Tabs: ["Tabs.List", "Tabs.Tab", "Tabs.Panel"],
Timeline: ["Timeline.Item"],
};
export const accessibilityByCategory: Record<CustomJsxComponentCategory, readonly string[]> = {
layout: ["Keep visual order consistent with reading order."],
content: ["Provide meaningful text alternatives for non-text content."],
feedback: ["Do not rely on color alone to communicate status."],
navigation: ["Provide an accessible label for controls without visible text."],
charts: ["Include a nearby text summary of the chart data."],
dates: ["Include a textual date when the visual calendar carries meaning."],
interaction: ["Provide a visible label or aria-label."],
network: ["Action labels must describe their effect; destructive actions require confirmation."],
blocked: [],
};
const componentSafeProps: Readonly<Record<string, readonly string[]>> = {
"Tabs.List": ["grow"],
ScrollArea: ["offsetScrollbars"],
SubData: ["alt", "fit"],
};
export function documentationUrl(packageName: CustomJsxComponentPackage, name: string): string {
if (packageName === "@homarr/widgets") return "https://homarr.dev/docs/management/custom-widgets/";
const section = packageName === "@mantine/core" ? "core" : packageName === "@mantine/charts" ? "charts" : "dates";
const slug = (name.split(".")[0] ?? "")
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
.toLowerCase();
return `https://mantine.dev/${section}/${slug}/`;
}
export const describe = (
packageName: CustomJsxComponentPackage,
category: CustomJsxComponentCategory,
safety: CustomJsxComponentSafety,
names: readonly string[],
reason?: string,
): CustomJsxComponentDescriptor[] =>
names.map((name) => ({
name,
package: packageName,
category,
safety,
supportedProps:
safety === "denied"
? []
: [
...new Set<string>([
...commonSafeProps,
...categorySafeProps[category],
...(componentSafeProps[name] ?? []),
]),
],
subcomponents: subcomponentsByName[name] ?? [],
accessibilityRequirements: accessibilityByCategory[category],
documentationUrl: documentationUrl(packageName, name),
reason,
}));
@@ -0,0 +1,230 @@
import type { CustomJsxComponentCategory } from "./component-types";
export const commonSafeProps = [
"key",
"id",
"style",
"title",
"role",
"aria-label",
"aria-describedby",
"aria-hidden",
"tabIndex",
"w",
"h",
"miw",
"maw",
"mih",
"mah",
"m",
"mx",
"my",
"mt",
"mr",
"mb",
"ml",
"p",
"px",
"py",
"pt",
"pr",
"pb",
"pl",
"bg",
"c",
"opacity",
"display",
"hiddenFrom",
"visibleFrom",
"bd",
"bdrs",
"flex",
"fz",
"fw",
"lh",
"lts",
"ta",
"td",
"tt",
"radius",
"shadow",
"variant",
"color",
"size",
"withBorder",
] as const;
export const categorySafeProps: Record<CustomJsxComponentCategory, readonly string[]> = {
layout: [
"gap",
"align",
"justify",
"wrap",
"direction",
"grow",
"cols",
"spacing",
"verticalSpacing",
"gutter",
"span",
"offset",
"order",
"type",
"orientation",
"withBorder",
"shadow",
"radius",
],
content: [
"alt",
"src",
"href",
"target",
"rel",
"size",
"fw",
"fs",
"td",
"tt",
"ta",
"lts",
"lh",
"lineClamp",
"truncate",
"inherit",
"gradient",
"variant",
"color",
"value",
"prefix",
"suffix",
"decimalScale",
"thousandSeparator",
"striped",
"highlightOnHover",
"withTableBorder",
"withColumnBorders",
"type",
"icon",
"image",
"order",
"fit",
"fallbackSrc",
"loading",
"referrerPolicy",
"crossOrigin",
"decoding",
"draggable",
"center",
"spacing",
"listStyleType",
],
feedback: ["color", "variant", "radius", "size", "value", "sections", "label", "animated", "striped"],
navigation: [
"value",
"defaultValue",
"active",
"label",
"description",
"leftSection",
"rightSection",
"opened",
"disabled",
"multiple",
"chevronPosition",
"orientation",
"keepMounted",
"transitionDuration",
],
charts: [
"data",
"dataKey",
"series",
"type",
"curveType",
"color",
"colors",
"withLegend",
"withTooltip",
"withXAxis",
"withYAxis",
"withDots",
"gridAxis",
"unit",
"valueFormatter",
"strokeWidth",
"fillOpacity",
"connectNulls",
"tooltipAnimationDuration",
],
dates: [
"date",
"defaultDate",
"value",
"minDate",
"maxDate",
"locale",
"firstDayOfWeek",
"weekendDays",
"hideOutsideDates",
"hideWeekdays",
"showWeekNumbers",
"numberOfColumns",
"level",
"size",
],
interaction: [
"label",
"value",
"values",
"data",
"checked",
"defaultChecked",
"disabled",
"readOnly",
"color",
"variant",
"size",
"radius",
"leftSection",
"rightSection",
"loading",
"min",
"max",
"step",
"total",
"page",
"pageSize",
"defaultTab",
"title",
"defaultOpen",
"type",
"fullWidth",
],
network: [
"requestId",
"params",
"onParams",
"offParams",
"initialValue",
"label",
"loadingLabel",
"successMessage",
"errorMessage",
"confirmMessage",
"invalidate",
"refreshInterval",
"trigger",
"render",
"path",
"as",
"fallback",
"icon",
"color",
"variant",
"size",
"radius",
"disabled",
],
blocked: [],
};
@@ -0,0 +1,218 @@
import { describe } from "./component-descriptor";
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
export const customJsxDeniedComponentRegistry: readonly CustomJsxComponentDescriptor[] = [
...describe(
"@mantine/core",
"blocked",
"denied",
[
"AppShell",
"Affix",
"ComboboxPopover",
"Dialog",
"Drawer",
"FloatingWindow",
"FocusTrap",
"Menubar",
"Modal",
"ModalBase",
"Portal",
"Popover",
"RemoveScroll",
],
"Escapes the widget layout or focus boundary",
),
...describe(
"@mantine/core",
"blocked",
"denied",
[
"AngleSlider",
"Autocomplete",
"Checkbox",
"ColorInput",
"ColorPicker",
"Combobox",
"FileButton",
"FileInput",
"Input",
"InputBase",
"JsonInput",
"MaskInput",
"MultiSelect",
"NativeSelect",
"NumberInput",
"PasswordInput",
"PillsInput",
"PinInput",
"Radio",
"Select",
"TagsInput",
"Textarea",
"TextInput",
"TreeSelect",
"UnstyledButton",
],
"Arbitrary form controls are not connected to the declarative action model",
),
...describe(
"@mantine/core",
"blocked",
"denied",
["OverflowList", "Scroller", "Splitter", "TableOfContents"],
"Requires callback, document, or layout capabilities that are not exposed to templates",
),
...describe(
"@mantine/core",
"blocked",
"denied",
[
"AccordionChevron",
"ActionIconGroup",
"ActionIconGroupSection",
"AlphaSlider",
"AppShellAside",
"AppShellFooter",
"AppShellHeader",
"AppShellMain",
"AppShellNavbar",
"AppShellSection",
"ButtonGroup",
"ButtonGroupSection",
"CheckIcon",
"CheckboxCard",
"CheckboxGroup",
"CheckboxIndicator",
"CloseIcon",
"ColorSchemeScript",
"ComboboxChevron",
"ComboboxClearButton",
"ComboboxDropdown",
"ComboboxDropdownTarget",
"ComboboxEmpty",
"ComboboxEventsTarget",
"ComboboxFooter",
"ComboboxGroup",
"ComboboxHeader",
"ComboboxHiddenInput",
"ComboboxOption",
"ComboboxOptions",
"ComboboxPopoverTarget",
"ComboboxSearch",
"ComboboxTarget",
"DirectionProvider",
"DrawerBody",
"DrawerCloseButton",
"DrawerContent",
"DrawerHeader",
"DrawerOverlay",
"DrawerRoot",
"DrawerStack",
"DrawerTitle",
"FlatTreeNode",
"FloatingArrow",
"FocusTrapInitialFocus",
"HeadlessMantineProvider",
"HoverCardGroup",
"HueSlider",
"InlineStyles",
"InputClearButton",
"InputClearSection",
"InputDescription",
"InputError",
"InputLabel",
"InputPlaceholder",
"InputSuccess",
"InputWrapper",
"MantineProvider",
"MantineThemeProvider",
"MenuCheckboxGroup",
"MenuCheckboxItem",
"MenuContextMenu",
"MenuRadioGroup",
"MenuRadioItem",
"MenuSearch",
"MenuSub",
"MenuSubDropdown",
"MenuSubItem",
"MenuSubTarget",
"MenubarDropdown",
"MenubarMenu",
"MenubarTarget",
"ModalBaseBody",
"ModalBaseCloseButton",
"ModalBaseContent",
"ModalBaseHeader",
"ModalBaseOverlay",
"ModalBaseTitle",
"ModalBody",
"ModalCloseButton",
"ModalContent",
"ModalHeader",
"ModalOverlay",
"ModalRoot",
"ModalStack",
"ModalTitle",
"NativeScrollArea",
"OptionalPortal",
"OptionsDropdown",
"PaginationControl",
"PaginationDots",
"PaginationFirst",
"PaginationItems",
"PaginationLabel",
"PaginationLast",
"PaginationNext",
"PaginationPrevious",
"PaginationRoot",
"PillGroup",
"PillsInputField",
"PopoverContextMenu",
"PopoverDropdown",
"PopoverTarget",
"ProgressLabel",
"ProgressRoot",
"RadioCard",
"RadioGroup",
"RadioIcon",
"RadioIndicator",
"RangeSlider",
"ScrollAreaAutosize",
"SplitterPane",
"StepperCompleted",
"SwitchGroup",
"TableScrollContainer",
"TooltipFloating",
"TooltipGroup",
"TreeSelectOption",
],
"Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface",
),
...describe(
"@mantine/charts",
"blocked",
"denied",
["AreaGradient"],
"Low-level chart implementation helper that is not a standalone declarative display component",
),
...describe(
"@mantine/dates",
"blocked",
"denied",
[
"DatesProvider",
"HiddenDatesInput",
"TimeInput",
"TimePicker",
"PickerInputBase",
"DateInput",
"DateTimePicker",
"InlineDateTimePicker",
"YearPickerInput",
"MonthPickerInput",
"DatePickerInput",
],
"Input and provider components are outside the read-only template surface",
),
];
@@ -0,0 +1,192 @@
/**
* The source of truth for the Custom JSX public component surface.
*
* Components marked `wrapped` require a runtime wrapper that removes unsafe props
* or supplies inert/read-only behavior. `denied` entries are deliberately kept in
* the registry so Mantine upgrades cannot silently expand the template surface.
*/
export const customJsxEnabledComponentRegistry: readonly CustomJsxComponentDescriptor[] = [
...describe("@mantine/core", "layout", "allowed", [
"Box",
"Stack",
"Group",
"Flex",
"Grid",
"Grid.Col",
"SimpleGrid",
"Center",
"Space",
"Container",
"AspectRatio",
"ScrollArea",
"Paper",
"Card",
"Card.Section",
"Divider",
"Fieldset",
"VisuallyHidden",
"Typography",
"FloatingIndicator",
]),
...describe("@mantine/core", "content", "allowed", [
"Text",
"Title",
"Code",
"Highlight",
"Mark",
"Kbd",
"Blockquote",
"NumberFormatter",
"Badge",
"ThemeIcon",
"ColorSwatch",
"Table",
"Table.Thead",
"Table.Tbody",
"Table.Tfoot",
"Table.Caption",
"Table.Tr",
"Table.Th",
"Table.Td",
"List",
"List.Item",
"Timeline",
"Timeline.Item",
"Indicator",
"Pill",
"Image",
"Avatar",
"Avatar.Group",
"BackgroundImage",
"DataList",
"DataList.Item",
"DataList.ItemLabel",
"DataList.ItemValue",
"EmptyState",
"EmptyState.Indicator",
"EmptyState.Title",
"EmptyState.Description",
"EmptyState.Actions",
"Marquee",
"RollingNumber",
]),
...describe("@mantine/core", "feedback", "allowed", [
"Alert",
"Progress",
"Progress.Section",
"RingProgress",
"SemiCircleProgress",
"Skeleton",
"Loader",
]),
...describe("@mantine/core", "feedback", "wrapped", ["Notification", "LoadingOverlay", "Overlay"]),
...describe("@mantine/core", "navigation", "allowed", [
"Accordion",
"Accordion.Item",
"Accordion.Control",
"Accordion.Panel",
"Tabs",
"Tabs.List",
"Tabs.Tab",
"Tabs.Panel",
"Stepper.Step",
"HoverCard",
"HoverCard.Target",
"HoverCard.Dropdown",
"Menu",
"Menu.Target",
"Menu.Dropdown",
"Menu.Item",
"Menu.Label",
"Menu.Divider",
"Tooltip",
"Spoiler",
"Collapse",
"Transition",
]),
...describe("@mantine/core", "navigation", "wrapped", ["Anchor", "NavLink", "Breadcrumbs", "Stepper", "Tree"]),
...describe("@mantine/core", "interaction", "wrapped", [
"Button",
"ActionIcon",
"Burger",
"CloseButton",
"Chip",
"Chip.Group",
"CopyButton",
"Pagination",
"Rating",
"SegmentedControl",
"Slider",
"Switch",
]),
...describe("@mantine/charts", "charts", "allowed", [
"AreaChart",
"BarChart",
"BarsList",
"BubbleChart",
"ChartLegend",
"ChartTooltip",
"CompositeChart",
"DonutChart",
"FunnelChart",
"Heatmap",
"LineChart",
"PieChart",
"RadarChart",
"RadialBarChart",
"SankeyChart",
"ScatterChart",
"Sparkline",
"Treemap",
]),
...describe("@mantine/dates", "dates", "wrapped", [
"Calendar",
"MiniCalendar",
"DatePicker",
"Day",
"WeekdaysRow",
"Month",
"CalendarHeader",
"YearPicker",
"MonthPicker",
"TimeGrid",
"PickerControl",
"YearsList",
"MonthsList",
"DecadeLevel",
"YearLevel",
"MonthLevel",
"LevelsGroup",
"DecadeLevelGroup",
"YearLevelGroup",
"MonthLevelGroup",
]),
...describe("@mantine/dates", "dates", "allowed", ["TimeValue"]),
...describe("@homarr/widgets", "interaction", "allowed", [
"PaginatedList",
"TabsContainer",
"TabPanel",
"Collapsible",
"StatBar",
"TypeBadge",
]),
...describe("@homarr/widgets", "network", "wrapped", [
"SubFetch",
"SubData",
"ActionButton",
"ToggleSwitch",
"RefreshButton",
]),
{
name: "TablerIcon",
package: "@homarr/widgets",
category: "content",
safety: "wrapped",
supportedProps: ["name", "size", "color", "stroke"],
subcomponents: [],
accessibilityRequirements: accessibilityByCategory.content,
documentationUrl: documentationUrl("@homarr/widgets", "TablerIcon"),
},
];
import { accessibilityByCategory, describe, documentationUrl } from "./component-descriptor";
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
@@ -0,0 +1,19 @@
export type {
CustomJsxComponentCategory,
CustomJsxComponentDescriptor,
CustomJsxComponentPackage,
CustomJsxComponentSafety,
} from "./component-descriptor";
import { customJsxDeniedComponentRegistry } from "./component-registry-denied";
import { customJsxEnabledComponentRegistry } from "./component-registry-enabled";
export const customJsxComponentRegistry = [
...customJsxEnabledComponentRegistry,
...customJsxDeniedComponentRegistry,
] as const;
export const enabledCustomJsxComponents = customJsxEnabledComponentRegistry;
export const customJsxComponentByName = new Map(customJsxComponentRegistry.map((entry) => [entry.name, entry]));
export const customJsxSupportedPropsByName = new Map(
customJsxComponentRegistry.map((entry) => [entry.name, new Set(entry.supportedProps)]),
);
@@ -0,0 +1,12 @@
export type CustomJsxComponentPackage = "@mantine/core" | "@mantine/charts" | "@mantine/dates" | "@homarr/widgets";
export type CustomJsxComponentSafety = "allowed" | "wrapped" | "denied";
export type CustomJsxComponentCategory =
| "layout"
| "content"
| "feedback"
| "navigation"
| "charts"
| "dates"
| "interaction"
| "network"
| "blocked";
@@ -0,0 +1,263 @@
import { z } from "zod/v4";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { customWidgetMethods } from "./schema-types";
export const customJsxNetworkScopes = ["public", "private", "loopback"] as const;
export type CustomJsxNetworkScope = (typeof customJsxNetworkScopes)[number];
export const customJsxRequestParameterTypes = ["string", "number", "boolean"] as const;
export type CustomJsxRequestParameterType = (typeof customJsxRequestParameterTypes)[number];
const requestIdSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-z][a-z0-9_-]*$/,
"Request IDs must start with a letter and contain only lowercase letters, numbers, - or _",
);
const requestParametersSchema = z
.record(requestIdSchema, z.enum(customJsxRequestParameterTypes))
.refine((parameters) => Object.keys(parameters).length <= 32, "A request can declare at most 32 parameters");
const reservedRequestHeaders = new Set([
"authorization",
"connection",
"content-length",
"cookie",
"expect",
"forwarded",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"via",
]);
const staticHeadersSchema = z
.record(z.string().min(1).max(128), z.string().max(8192))
.refine((headers) => Object.keys(headers).length <= 32, "A request can declare at most 32 static headers")
.refine(
(headers) =>
Object.keys(headers).every((name) => {
const normalized = name.trim().toLowerCase();
return (
!reservedRequestHeaders.has(normalized) &&
!normalized.startsWith("proxy-") &&
!normalized.startsWith("sec-") &&
!normalized.startsWith("x-forwarded-")
);
}),
"Static headers cannot override authentication, cookies, routing, forwarding, proxy, or hop-by-hop headers",
);
const collectBodyParameterNames = (value: unknown, result = new Set<string>()): Set<string> => {
if (Array.isArray(value)) {
value.forEach((entry) => collectBodyParameterNames(entry, result));
return result;
}
if (value === null || typeof value !== "object") return result;
const entries = Object.entries(value);
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
result.add(entries[0][1]);
return result;
}
entries.forEach(([, entry]) => collectBodyParameterNames(entry, result));
return result;
};
export const customJsxRequestSchema = z
.object({
id: requestIdSchema,
kind: z.enum(["query", "action"]),
method: z.enum(customWidgetMethods),
pathTemplate: z
.string()
.min(1)
.max(2048)
.startsWith("/")
.refine((path) => !path.startsWith("//"), "Path templates must be same-origin paths")
.refine((path) => !path.includes("\\"), "Path templates cannot contain backslashes")
.refine((path) => !path.includes("#"), "Path templates cannot contain URL fragments"),
parameters: requestParametersSchema,
bodyTemplate: z.json().optional(),
staticHeaders: staticHeadersSchema.optional(),
auth: z.enum(["inherit", "none"]),
minimumBoardPermission: z.enum(["view", "modify", "full"]),
cacheTtlSeconds: z.number().int().min(0).max(3600).optional(),
allowViewerExecution: z.literal(true).optional(),
})
.superRefine((request, ctx) => {
if (request.kind === "query" && request.method !== "GET") {
ctx.addIssue({
code: "custom",
path: ["method"],
message: "Query requests must use GET",
});
}
if (request.kind === "action" && request.method === "GET") {
ctx.addIssue({
code: "custom",
path: ["method"],
message: "Action requests must use POST, PUT, PATCH, or DELETE",
});
}
if (request.kind === "query" && request.minimumBoardPermission !== "view") {
ctx.addIssue({
code: "custom",
path: ["minimumBoardPermission"],
message: "Query requests use view permission",
});
}
if (request.method === "DELETE" && request.minimumBoardPermission !== "full") {
ctx.addIssue({
code: "custom",
path: ["minimumBoardPermission"],
message: "DELETE actions require full board permission",
});
}
if (
request.kind === "action" &&
request.method !== "DELETE" &&
request.minimumBoardPermission === "view" &&
request.allowViewerExecution !== true
) {
ctx.addIssue({
code: "custom",
path: ["allowViewerExecution"],
message: "Viewer actions require explicit allowViewerExecution approval",
});
}
if (request.kind === "query" && request.bodyTemplate !== undefined) {
ctx.addIssue({
code: "custom",
path: ["bodyTemplate"],
message: "GET query requests cannot define a request body",
});
}
if (request.kind === "action" && request.cacheTtlSeconds !== undefined && request.cacheTtlSeconds !== 0) {
ctx.addIssue({
code: "custom",
path: ["cacheTtlSeconds"],
message: "Actions cannot be cached",
});
}
const declaredParameters = new Set(Object.keys(request.parameters));
const pathParameters = [...request.pathTemplate.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1] ?? "");
for (const parameter of [...pathParameters, ...collectBodyParameterNames(request.bodyTemplate)]) {
if (!declaredParameters.has(parameter)) {
ctx.addIssue({
code: "custom",
path: [request.bodyTemplate === undefined ? "pathTemplate" : "parameters"],
message: `Placeholder '${parameter}' is not declared in request parameters`,
});
}
}
});
export type CustomJsxRequest = z.infer<typeof customJsxRequestSchema>;
const customJsxTemplateSchema = z
.string()
.min(1)
.max(50000)
.superRefine((template, ctx) => {
for (const diagnostic of validateCustomJsxTemplate(template)) {
if (diagnostic.severity === "error") {
ctx.addIssue({
code: "custom",
message: `${diagnostic.message} (line ${diagnostic.line}, column ${diagnostic.column})`,
});
}
}
})
.describe(
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Direct fetch and XMLHttpRequest access are unavailable; v2 templates reference named requests.",
);
export const customJsxDisplayConfigV1Schema = z.object({
type: z
.literal("customJsx")
.describe("Display type discriminator — must be 'customJsx' when displayType is customJsx"),
template: customJsxTemplateSchema,
jsxApiVersion: z.never().optional(),
networkScope: z.never().optional(),
requests: z.never().optional(),
});
export const customJsxDisplayConfigV2Schema = z
.object({
type: z.literal("customJsx"),
jsxApiVersion: z.literal(2),
template: customJsxTemplateSchema,
networkScope: z.enum(customJsxNetworkScopes),
requests: z.array(customJsxRequestSchema).max(64),
})
.superRefine((config, ctx) => {
const requestsById = new Map<string, CustomJsxRequest>();
for (const [index, request] of config.requests.entries()) {
if (requestsById.has(request.id)) {
ctx.addIssue({
code: "custom",
path: ["requests", index, "id"],
message: `Duplicate request ID: ${request.id}`,
});
}
requestsById.set(request.id, request);
}
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g;
for (const match of config.template.matchAll(networkComponentPattern)) {
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
const attributes = match[2] ?? "";
const idMatch = attributes.match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
const requestId = idMatch?.[1] ?? idMatch?.[2];
if (!requestId) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} must use a literal requestId`,
});
continue;
}
const request = requestsById.get(requestId);
const expectedKind = component === "SubFetch" ? "query" : "action";
if (!request) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} references unknown request '${requestId}'`,
});
} else if (request.kind !== expectedKind) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} requires a ${expectedKind} request, but '${requestId}' is ${request.kind}`,
});
}
if (/\b(?:url|method|headers|body)\s*=/.test(attributes)) {
ctx.addIssue({
code: "custom",
path: ["template"],
message: `${component} cannot declare inline network settings`,
});
}
}
});
export type CustomJsxDisplayConfigV2 = z.infer<typeof customJsxDisplayConfigV2Schema>;
@@ -0,0 +1,184 @@
import { z } from "zod/v4";
import type { CustomJsxDisplayConfigV2 } from "./custom-jsx-schema";
import { displayConfigSchema } from "./display-config-schema";
import type { DisplayConfig } from "./display-config-schema";
import {
customWidgetAuthTypes,
customWidgetDisplayTypes,
customWidgetMethods,
customWidgetSecretKinds,
} from "./schema-types";
import type { CustomWidgetMethod } from "./schema-types";
const displayTypesMatch = (displayType?: string, configType?: string): boolean => {
if (!displayType) return true;
if (!configType) return true;
return displayType === configType;
};
const displayTypeMatchMessage = {
message: "displayType must match displayConfig.type",
path: ["displayConfig", "type"],
};
const isCustomJsxV2 = (config: DisplayConfig | undefined): config is CustomJsxDisplayConfigV2 =>
config?.type === "customJsx" && "jsxApiVersion" in config && config.jsxApiVersion === 2;
const validateDefinitionDisplay = (
definition: { method?: CustomWidgetMethod; displayType?: string; displayConfig?: DisplayConfig },
ctx: z.RefinementCtx,
) => {
if (!displayTypesMatch(definition.displayType, definition.displayConfig?.type)) {
ctx.addIssue({
code: "custom",
message: displayTypeMatchMessage.message,
path: displayTypeMatchMessage.path,
});
}
if (isCustomJsxV2(definition.displayConfig) && definition.method !== undefined && definition.method !== "GET") {
ctx.addIssue({
code: "custom",
message: "Custom JSX v2 base data requests must use GET; mutations belong in named actions",
path: ["method"],
});
}
};
const baseDefinitionSchema = z.object({
name: z
.string()
.min(1)
.max(128)
.describe("Human-readable name for the widget, displayed in the dashboard and management UI"),
description: z
.string()
.max(512)
.optional()
.describe("Optional longer description of what this widget shows, shown in tooltips and management UI"),
iconUrl: z
.string()
.url()
.optional()
.describe(
"Optional URL to an icon image for the widget (e.g. https://example.com/icon.png). Shown in widget picker.",
),
url: z
.string()
.min(1)
.describe(
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
),
authType: z
.enum(customWidgetAuthTypes)
.describe(
"Authentication method: 'none' for public APIs, 'bearer' for Bearer token, 'basic' for Basic auth, 'apiKeyHeader' for API key sent as a custom header, 'apiKeyQuery' for API key as query parameter. Secrets are configured separately in the UI.",
),
headerName: z
.string()
.max(256)
.optional()
.describe(
"Custom header or query parameter name for API key auth. For apiKeyHeader: the HTTP header name (default: X-API-Key). For apiKeyQuery: the query param name (default: api_key). Ignored for other auth types.",
),
method: z
.enum(customWidgetMethods)
.describe(
"HTTP method for the API request: 'GET' (fetch data, most common), 'POST'/'PUT'/'PATCH' (with optional requestBody), 'DELETE' (destructive actions)",
),
requestBody: z
.string()
.optional()
.describe(
'JSON string body sent with POST/PUT/PATCH requests. Must be valid JSON as a string (e.g. \'{"action":"restart"}\'). Omit for GET requests.',
),
displayType: z
.enum(customWidgetDisplayTypes)
.describe(
"How to render the API response: 'singleValue' (one big number/text), 'keyValue' (labeled pairs), 'table' (rows/columns), 'statGrid' (colored stat cards), 'progressBars' (usage bars), 'statusIndicator' (health dots), 'countGrid' (simple counts), 'raw' (JSON debug view), 'actionButton' (clickable action), 'customJsx' (custom JSX template with Mantine components)",
),
displayConfig: displayConfigSchema,
});
const secretsInputSchema = z.array(
z.object({
kind: z
.enum(customWidgetSecretKinds)
.describe(
"Secret type: 'apiKey' (token/key for bearer/apiKey auth), 'username' (for basic auth), 'password' (for basic auth)",
),
value: z
.string()
.min(1)
.describe("The secret value. Never include in exported JSON — configure in the UI after import."),
}),
);
export const customWidgetCreateSchema = baseDefinitionSchema
.extend({ secrets: secretsInputSchema })
.superRefine(validateDefinitionDisplay);
export const customWidgetUpdateSchema = baseDefinitionSchema
.partial()
.extend({ id: z.string(), secrets: secretsInputSchema.optional() })
.superRefine(validateDefinitionDisplay);
const customWidgetImportFieldsSchema = z.object({
name: z
.string()
.min(1)
.max(128)
.describe("Human-readable name for the widget, displayed in the dashboard and management UI"),
description: z
.string()
.max(512)
.nullish()
.describe("Optional longer description of what this widget shows. Use null or omit if not needed."),
iconUrl: z
.string()
.url()
.nullish()
.describe("Optional URL to an icon image for the widget. Use null or omit if not needed."),
authType: z
.enum(customWidgetAuthTypes)
.describe(
"Authentication method: 'none' for public APIs, 'bearer' for Bearer token, 'basic' for Basic auth, 'apiKeyHeader' for API key sent as a custom header, 'apiKeyQuery' for API key as query parameter",
),
headerName: z
.string()
.max(256)
.nullish()
.describe(
"Custom header or query parameter name for API key auth. For apiKeyHeader: HTTP header name (default: X-API-Key). For apiKeyQuery: query param name (default: api_key).",
),
method: z
.enum(customWidgetMethods)
.describe(
"HTTP method: 'GET' (fetch data), 'POST'/'PUT'/'PATCH' (with requestBody), 'DELETE' (destructive). Use GET unless the API requires otherwise.",
),
requestBody: z.string().nullish().describe("JSON string body for POST/PUT/PATCH. Use null or omit for GET requests."),
displayType: z
.enum(customWidgetDisplayTypes)
.describe(
"How to render the API response: 'singleValue', 'keyValue', 'table', 'statGrid', 'progressBars', 'statusIndicator', 'countGrid', 'raw', 'actionButton', or 'customJsx'",
),
displayConfig: displayConfigSchema,
});
export const customWidgetImportSchema = customWidgetImportFieldsSchema
.extend({
$schema: z
.enum(["homarr-custom-widget-v2", "homarr-custom-widget-v3"])
.optional()
.describe("Schema version identifier. New exports use 'homarr-custom-widget-v3'; v2 remains importable."),
url: z
.string()
.min(1)
.describe(
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
),
})
.superRefine(validateDefinitionDisplay);
export type CustomWidgetImport = z.infer<typeof customWidgetImportSchema>;
@@ -0,0 +1,57 @@
import { z } from "zod/v4";
import {
countGridDisplayConfigSchema,
keyValueDisplayConfigSchema,
progressBarsDisplayConfigSchema,
rawDisplayConfigSchema,
singleValueDisplayConfigSchema,
statGridDisplayConfigSchema,
statusIndicatorDisplayConfigSchema,
tableDisplayConfigSchema,
} from "./built-in-display-schemas";
import { customJsxDisplayConfigV1Schema, customJsxDisplayConfigV2Schema } from "./custom-jsx-schema";
const actionButtonDisplayConfigSchema = z.object({
type: z
.literal("actionButton")
.describe("Display type discriminator — must be 'actionButton' when displayType is actionButton"),
buttonLabel: z
.string()
.min(1)
.describe("Text shown on the action button (e.g. 'Restart Service', 'Clear Cache', 'Run Backup')"),
buttonColor: z
.string()
.optional()
.describe("Mantine color name for the button (e.g. 'blue', 'red', 'green'). Default: blue"),
confirmText: z
.string()
.optional()
.describe(
"If set, shows a confirmation dialog with this message before executing the API request. Omit for immediate execution.",
),
successMessage: z
.string()
.optional()
.describe("Notification message shown after successful API execution. Omit for a generic success message."),
});
export const displayConfigSchema = z
.union([
singleValueDisplayConfigSchema,
keyValueDisplayConfigSchema,
tableDisplayConfigSchema,
statGridDisplayConfigSchema,
progressBarsDisplayConfigSchema,
statusIndicatorDisplayConfigSchema,
countGridDisplayConfigSchema,
rawDisplayConfigSchema,
actionButtonDisplayConfigSchema,
customJsxDisplayConfigV2Schema,
customJsxDisplayConfigV1Schema,
])
.describe(
"Configuration for how API response data is displayed. The 'type' field MUST match the top-level 'displayType' field exactly. Choose the variant that matches your displayType.",
);
export type DisplayConfig = z.infer<typeof displayConfigSchema>;
@@ -0,0 +1,123 @@
import { JSONPath } from "jsonpath-plus";
import type { DisplayConfig } from "./display-config-schema";
import type { CustomWidgetDisplayType } from "./schema-types";
export type ExtractedDisplayData = Record<string, unknown>;
const query = (json: unknown, path: string) => JSONPath({ path, json: json as object, wrap: false }) as unknown;
const queryRows = (json: unknown, path: string): unknown[] => {
const rows = JSONPath({ path, json: json as object, wrap: true }) as unknown[];
return Array.isArray(rows[0]) ? (rows[0] as unknown[]) : rows;
};
const toNumber = (value: unknown, fallback: number): number => Number(value) || fallback;
export function extractDisplayData(
json: unknown,
_displayType: CustomWidgetDisplayType,
config: DisplayConfig,
): ExtractedDisplayData {
switch (config.type) {
case "singleValue":
return {
...config,
value: query(json, config.jsonPath),
valueSize: config.valueSize ?? "lg",
labelPosition: config.labelPosition ?? "below",
};
case "keyValue":
return {
type: config.type,
entries: config.mappings.map((mapping) => ({ ...mapping, value: query(json, mapping.jsonPath) })),
layout: config.layout ?? "list",
columns: config.columns ?? 2,
};
case "table": {
const rows = queryRows(json, config.tablePath);
return {
type: config.type,
columns: config.columns.map((column) => column.header),
rows: rows.map((row) => config.columns.map((column) => query(row, column.jsonPath))),
striped: config.striped ?? true,
compact: config.compact ?? false,
};
}
case "statGrid":
return {
type: config.type,
items: config.items.map((item) => ({
...item,
color: item.color ?? "blue",
value: query(json, item.jsonPath),
})),
columns: config.columns ?? 2,
cardStyle: config.cardStyle ?? "filled",
};
case "progressBars":
return {
type: config.type,
bars: config.bars.map((bar) => ({
...bar,
color: bar.color ?? "blue",
value: toNumber(query(json, bar.valuePath), 0),
max: bar.maxPath ? toNumber(query(json, bar.maxPath), 100) : undefined,
})),
showPercentage: config.showPercentage ?? true,
barSize: config.barSize ?? "md",
};
case "statusIndicator":
return {
type: config.type,
items: config.items.map((item) => {
const value = query(json, item.jsonPath);
return {
label: item.label,
value: String(value ?? "unknown"),
isGood: item.goodValues.some((good) => String(value).toLowerCase() === good.toLowerCase()),
};
}),
layout: config.layout ?? "list",
dotSize: config.dotSize ?? "md",
};
case "countGrid":
return {
type: config.type,
items: config.items.map((item) => ({ ...item, value: query(json, item.jsonPath) })),
columns: config.columns ?? 2,
valueSize: config.valueSize ?? "md",
};
case "raw":
return { type: config.type, data: query(json, config.jsonPath), maxHeight: config.maxHeight ?? 300 };
case "actionButton":
return {
...config,
buttonColor: config.buttonColor ?? "blue",
confirmText: config.confirmText ?? "",
successMessage: config.successMessage ?? "",
};
case "customJsx":
return {
type: config.type,
template: config.template,
data: json,
jsxApiVersion: "jsxApiVersion" in config ? config.jsxApiVersion : 1,
requestCapabilities: Array.isArray(config.requests)
? config.requests.map(({ id, kind, method, minimumBoardPermission }) => ({
id,
kind,
method,
minimumBoardPermission,
}))
: [],
};
}
}
export const extractDisplayDataWithFallback = extractDisplayData;
export function extractActionButtonDisplay(config: DisplayConfig): ExtractedDisplayData {
if (config.type !== "actionButton") throw new Error("Display configuration is not an action button");
return extractDisplayData(null, "actionButton", config);
}
@@ -0,0 +1,258 @@
import { customJsxExamples } from "./examples";
import { customJsxRequestSchema, displayConfigSchema } from "./schema";
import type { DisplayConfig } from "./schema";
import type { CustomWidgetDisplayType } from "./schema";
function hydrateDisplayFormValues(displayType: string, config: Record<string, unknown>) {
const base = {
displayType,
jsonPath: "$",
label: "",
unit: "",
valueSize: "lg",
labelPosition: "below",
mappings: [{ label: "", jsonPath: "$", unit: "" }],
kvLayout: "list",
kvColumns: 2,
tablePath: "$",
columns: [{ header: "", jsonPath: "$" }],
striped: true,
compact: false,
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
statGridColumns: 2,
cardStyle: "filled",
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
showPercentage: true,
barSize: "md",
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
statusLayout: "list",
dotSize: "md",
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
countGridColumns: 2,
countValueSize: "md",
rawJsonPath: "$",
rawMaxHeight: 300,
buttonLabel: "Execute",
buttonColor: "blue",
confirmText: "",
successMessage: "",
template: CUSTOM_JSX_STARTER,
jsxApiVersion: "2",
networkScope: "public",
requestManifest: "[]",
};
const typeOverrides: Record<string, () => Record<string, unknown>> = {
singleValue: () => ({
jsonPath: (config.jsonPath as string) ?? "$",
label: (config.label as string) ?? "",
unit: (config.unit as string) ?? "",
valueSize: (config.valueSize as string) ?? "lg",
labelPosition: (config.labelPosition as string) ?? "below",
}),
keyValue: () => ({
mappings: (config.mappings as typeof base.mappings) ?? base.mappings,
kvLayout: (config.layout as string) ?? "list",
kvColumns: (config.columns as number) ?? 2,
}),
table: () => ({
tablePath: (config.tablePath as string) ?? "$",
columns: (config.columns as typeof base.columns) ?? base.columns,
striped: (config.striped as boolean) ?? true,
compact: (config.compact as boolean) ?? false,
}),
statGrid: () => ({
statGridItems: (config.items as typeof base.statGridItems) ?? base.statGridItems,
statGridColumns: (config.columns as number) ?? 2,
cardStyle: (config.cardStyle as string) ?? "filled",
}),
progressBars: () => ({
progressBars: (
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
[]
).map((bar) => ({ ...bar, maxPath: bar.maxPath ?? "", color: bar.color ?? "blue" })),
showPercentage: (config.showPercentage as boolean) ?? true,
barSize: (config.barSize as string) ?? "md",
}),
statusIndicator: () => ({
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
(item) => ({ ...item, goodValues: item.goodValues.join(", ") }),
),
statusLayout: (config.layout as string) ?? "list",
dotSize: (config.dotSize as string) ?? "md",
}),
countGrid: () => ({
countGridItems: (config.items as typeof base.countGridItems) ?? base.countGridItems,
countGridColumns: (config.columns as number) ?? 2,
countValueSize: (config.valueSize as string) ?? "md",
}),
raw: () => ({
rawJsonPath: (config.jsonPath as string) ?? "$",
rawMaxHeight: (config.maxHeight as number) ?? 300,
}),
actionButton: () => ({
buttonLabel: (config.buttonLabel as string) ?? "Execute",
buttonColor: (config.buttonColor as string) ?? "blue",
confirmText: (config.confirmText as string) ?? "",
successMessage: (config.successMessage as string) ?? "",
}),
customJsx: () => ({
template:
typeof config.template === "string" && config.template.trim().length > 0 ? config.template : CUSTOM_JSX_STARTER,
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
networkScope: (config.networkScope as string) ?? "public",
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
}),
};
return { ...base, ...typeOverrides[displayType]?.() };
}
const starterExample = customJsxExamples.find((example) => example.id === "simple-metric");
if (!starterExample) throw new Error("The simple-metric Custom JSX fixture is required");
export const CUSTOM_JSX_STARTER = starterExample.template;
export type CustomWidgetDisplayFormValues = ReturnType<typeof hydrateDisplayFormValues>;
const displayConfigBuilders: Record<string, (values: CustomWidgetDisplayFormValues) => Record<string, unknown>> = {
singleValue: (values) => ({
type: "singleValue",
jsonPath: values.jsonPath,
label: values.label,
unit: values.unit,
valueSize: values.valueSize,
labelPosition: values.labelPosition,
}),
keyValue: (values) => ({
type: "keyValue",
mappings: values.mappings,
layout: values.kvLayout,
columns: values.kvColumns,
}),
table: (values) => ({
type: "table",
tablePath: values.tablePath,
columns: values.columns,
striped: values.striped,
compact: values.compact,
}),
statGrid: (values) => ({
type: "statGrid",
items: values.statGridItems,
columns: values.statGridColumns,
cardStyle: values.cardStyle,
}),
progressBars: (values) => ({
type: "progressBars",
bars: values.progressBars.map((bar) => ({ ...bar, maxPath: bar.maxPath || undefined })),
showPercentage: values.showPercentage,
barSize: values.barSize,
}),
statusIndicator: (values) => ({
type: "statusIndicator",
items: values.statusItems.map((item) => ({
...item,
goodValues: item.goodValues
.split(",")
.map((value) => value.trim())
.filter(Boolean),
})),
layout: values.statusLayout,
dotSize: values.dotSize,
}),
countGrid: (values) => ({
type: "countGrid",
items: values.countGridItems,
columns: values.countGridColumns,
valueSize: values.countValueSize,
}),
raw: (values) => ({ type: "raw", jsonPath: values.rawJsonPath, maxHeight: values.rawMaxHeight }),
actionButton: (values) => ({
type: "actionButton",
buttonLabel: values.buttonLabel,
buttonColor: values.buttonColor,
confirmText: values.confirmText || undefined,
successMessage: values.successMessage || undefined,
}),
customJsx: (values) =>
values.jsxApiVersion === "2"
? {
type: "customJsx",
jsxApiVersion: 2,
template: values.template.trim() || CUSTOM_JSX_STARTER,
networkScope: values.networkScope,
requests: parseRequestManifest(values.requestManifest),
}
: { type: "customJsx", template: values.template.trim() || CUSTOM_JSX_STARTER },
};
export type CustomWidgetDisplayIcon =
| "action"
| "code"
| "count"
| "grid"
| "json"
| "keyValue"
| "number"
| "progress"
| "status"
| "table";
export interface CustomWidgetDisplayDescriptor {
type: CustomWidgetDisplayType;
icon: CustomWidgetDisplayIcon;
supportsActions: boolean;
supportsNamedRequests: boolean;
defaults(): CustomWidgetDisplayFormValues;
hydrate(config: Record<string, unknown>): CustomWidgetDisplayFormValues;
serialize(values: CustomWidgetDisplayFormValues): DisplayConfig;
validate(config: unknown): DisplayConfig;
}
const descriptorMetadata = [
["singleValue", "number", false, false],
["keyValue", "keyValue", false, false],
["table", "table", false, false],
["statGrid", "grid", false, false],
["progressBars", "progress", false, false],
["statusIndicator", "status", false, false],
["countGrid", "count", false, false],
["raw", "json", false, false],
["actionButton", "action", true, false],
["customJsx", "code", true, true],
] as const satisfies readonly [CustomWidgetDisplayType, CustomWidgetDisplayIcon, boolean, boolean][];
export const customWidgetDisplayDescriptors: readonly CustomWidgetDisplayDescriptor[] = descriptorMetadata.map(
([type, icon, supportsActions, supportsNamedRequests]) => ({
type,
icon,
supportsActions,
supportsNamedRequests,
defaults: () => hydrateDisplayFormValues(type, {}),
hydrate: (config) => hydrateDisplayFormValues(type, config),
serialize: (values) => {
const build = displayConfigBuilders[type];
if (!build) throw new Error(`Unsupported custom-widget display type: ${type}`);
return displayConfigSchema.parse(build(values));
},
validate: (config) => displayConfigSchema.parse(config),
}),
);
export function getCustomWidgetDisplayDescriptor(type: string): CustomWidgetDisplayDescriptor {
const descriptor = customWidgetDisplayDescriptors.find((candidate) => candidate.type === type);
if (!descriptor) throw new Error(`Unsupported custom-widget display type: ${type}`);
return descriptor;
}
export function buildDisplayFormValues(displayType: string, config: Record<string, unknown>) {
return getCustomWidgetDisplayDescriptor(displayType).hydrate(config);
}
export function buildDisplayConfigFromFormValues(values: CustomWidgetDisplayFormValues): DisplayConfig {
return getCustomWidgetDisplayDescriptor(values.displayType).serialize(values);
}
function parseRequestManifest(value: string) {
const parsed: unknown = JSON.parse(value);
return customJsxRequestSchema.array().parse(parsed);
}
@@ -0,0 +1,300 @@
export interface CustomJsxExample {
id: string;
title: string;
description: string;
template: string;
requests: readonly Record<string, unknown>[];
}
export const customJsxExamples: readonly CustomJsxExample[] = [
{
id: "simple-metric",
title: "Simple metric",
description: "A compact status and metric display with no nested requests.",
template: `<Stack gap="xs" p="sm">
<Group justify="space-between">
<Text fw={600}>{data.name}</Text>
<Badge color={data.status === "online" ? "green" : "red"}>{data.status}</Badge>
</Group>
<Title order={2}>{data.value}</Title>
</Stack>`,
requests: [],
},
{
id: "pokedex-detail",
title: "Pokédex detail list",
description: "Paginates the list and fetches details only for visible Pokémon.",
template: `<Stack gap="sm" p="xs">
<Title order={3}>Pokédex</Title>
<PaginatedList pageSize={5}>
{data.results.map((pokemon) =>
<Card key={pokemon.name} withBorder p="xs" mb="xs">
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
{(detail, meta) =>
<Stack gap={4}>
<Group gap="xs">
{detail.types.map((entry) => <Badge key={entry.type.name} color="red">{entry.type.name}</Badge>)}
</Group>
<Text size="xs">HP {detail.stats[0].base_stat} · HTTP {meta.status}</Text>
</Stack>
}
</SubFetch>
</Card>
)}
</PaginatedList>
</Stack>`,
requests: [
{
id: "pokemon-detail",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon/{name}",
parameters: { name: "string" },
auth: "none",
minimumBoardPermission: "view",
cacheTtlSeconds: 300,
},
],
},
{
id: "stocks-dashboard",
title: "Stocks dashboard",
description: "Responsive portfolio values and chart with parent refresh.",
template: `<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={3}>Portfolio</Title>
<RefreshButton label="Refresh" size="xs" />
</Group>
<SimpleGrid cols={3}>
{data.holdings.map((stock) =>
<Card key={stock.symbol} withBorder p="xs">
<Text fw={700}>{stock.symbol}</Text>
<NumberFormatter value={stock.price} prefix="$" decimalScale={2} />
<Sparkline data={stock.history} h={40} color={stock.change > 0 ? "green" : "red"} />
</Card>
)}
</SimpleGrid>
<LineChart h={200} data={data.timeline} dataKey="date" series={[{ name: "value", color: "blue" }]} />
</Stack>`,
requests: [],
},
{
id: "navidrome-player",
title: "Navidrome mini-player",
description: "Media controls backed by named actions with explicit permissions.",
template: `<Stack gap="sm" p="sm">
<Group wrap="nowrap">
<Image src={data.albumArt} alt={data.album} w={80} h={80} radius="sm" />
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} lineClamp={1}>{data.title}</Text>
<Text size="sm" c="dimmed" lineClamp={1}>{data.artist}</Text>
<Progress value={data.progress} size="xs" color="red" />
</Stack>
</Group>
<Group justify="center" gap="sm">
<ActionButton requestId="previous" label="Previous" size="xs" variant="subtle" />
<ActionButton requestId="toggle-playback" label={data.playing ? "Pause" : "Play"} icon="play" color="red" invalidate={["parent"]} />
<ActionButton requestId="next" label="Next" size="xs" variant="subtle" />
</Group>
<ToggleSwitch requestId="shuffle" onParams={{ enabled: true }} offParams={{ enabled: false }} initialValue={data.shuffle} label="Shuffle" size="xs" />
</Stack>`,
requests: [
{
id: "previous",
kind: "action",
method: "POST",
pathTemplate: "/api/player/previous",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "toggle-playback",
kind: "action",
method: "POST",
pathTemplate: "/api/player/toggle",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "next",
kind: "action",
method: "POST",
pathTemplate: "/api/player/next",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "shuffle",
kind: "action",
method: "PUT",
pathTemplate: "/api/player/shuffle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
auth: "inherit",
minimumBoardPermission: "modify",
},
],
},
{
id: "pokedex-interactive",
title: "Interactive Pokédex",
description:
"A full-featured Pokédex with paginated list, detail SubFetch, type badges, stats with progress bars, moves grid, and evolution chain. Exercises filter(Boolean), pop(), find, reduce, sort, replaceAll, padStart, arithmetic, and nested SubFetch.",
template: `<Stack gap="sm" p="sm" style={{ minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" radius="xl" variant="light" color="red">P</ThemeIcon>
<Box>
<Title order={3}>Pokédex</Title>
<Text size="xs" c="dimmed"><NumberFormatter value={data.count} thousandSeparator /> Pokémon discovered</Text>
</Box>
</Group>
<RefreshButton label="Refresh" size="xs" />
</Group>
<PaginatedList pageSize={5}>
{data.results.map((pokemon, index) =>
<Accordion key={pokemon.name} variant="separated" radius="md">
<Accordion.Item value={pokemon.name}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar size="sm" radius="md" src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + (index + 1) + ".png"} />
<Box style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed">#{String(index + 1).padStart(4, "0")}</Text>
<Text fw={700} tt="capitalize" lineClamp={1}>{pokemon.name.replaceAll("-", " ")}</Text>
</Box>
</Group>
<Badge size="xs" variant="outline">Details</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
{(detail, meta) =>
<Stack gap="sm">
<Grid gutter="sm">
<Grid.Col span={{ base: 12, sm: 5 }}>
<Center>
<Image src={detail.sprites.front_default} alt={detail.name} fit="contain" h={120} />
</Center>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 7 }}>
<Stack gap="xs">
<Group gap="xs">
{detail.types.map((entry) =>
<Badge key={entry.type.name} tt="capitalize">{entry.type.name}</Badge>
)}
</Group>
<SimpleGrid cols={3} spacing="xs">
<Box><Text size="xs" c="dimmed">Height</Text><Text fw={700}><NumberFormatter value={detail.height / 10} decimalScale={1} suffix=" m" /></Text></Box>
<Box><Text size="xs" c="dimmed">Weight</Text><Text fw={700}><NumberFormatter value={detail.weight / 10} decimalScale={1} suffix=" kg" /></Text></Box>
<Box><Text size="xs" c="dimmed">Base XP</Text><Text fw={700}>{detail.base_experience || "—"}</Text></Box>
</SimpleGrid>
<Group gap="xs">
{detail.abilities.map((entry) =>
<Badge key={entry.ability.name} variant="light" size="xs" tt="capitalize">
{entry.ability.name.replaceAll("-", " ")}{entry.is_hidden ? " ·H" : ""}
</Badge>
)}
</Group>
</Stack>
</Grid.Col>
</Grid>
<Tabs defaultValue="stats" variant="outline" radius="md">
<Tabs.List>
<Tabs.Tab value="stats">Stats</Tabs.Tab>
<Tabs.Tab value="moves">Moves</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="stats" pt="sm">
<Stack gap="xs">
{detail.stats.map((entry) =>
<Box key={entry.stat.name}>
<Group justify="space-between" mb={2}>
<Text size="xs" tt="capitalize">{entry.stat.name.replaceAll("-", " ")}</Text>
<Text size="xs" fw={700}>{entry.base_stat}</Text>
</Group>
<Progress value={Math.min(100, entry.base_stat / 2.55)} size="sm" radius="xl" />
</Box>
)}
<Group justify="space-between">
<Text size="sm" fw={700}>Total</Text>
<Badge>{detail.stats.reduce((sum, s) => sum + s.base_stat, 0)}</Badge>
</Group>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="moves" pt="sm">
<ScrollArea h={120} type="auto">
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="xs">
{detail.moves.slice(0, 30).map((entry) =>
<Badge key={entry.move.name} variant="light" size="xs" tt="capitalize" style={{ whiteSpace: "normal" }}>
{entry.move.name.replaceAll("-", " ")}
</Badge>
)}
</SimpleGrid>
</ScrollArea>
</Tabs.Panel>
</Tabs>
<Text size="xs" c="dimmed">Status: {meta.status}</Text>
</Stack>
}
</SubFetch>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</PaginatedList>
</Stack>`,
requests: [
{
id: "pokemon-detail",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon/{name}",
parameters: { name: "string" },
auth: "none",
minimumBoardPermission: "view",
cacheTtlSeconds: 3600,
},
],
},
{
id: "smart-home-control",
title: "Smart-home control",
description: "A stateful light control with an explicit destructive confirmation.",
template: `<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={4}>Living room</Title>
<Badge color={data.state === "on" ? "yellow" : "gray"}>{data.state}</Badge>
</Group>
<ToggleSwitch requestId="set-light" onParams={{ state: "on" }} offParams={{ state: "off" }} initialValue={data.state === "on"} label="Power" color="yellow" />
<ActionButton requestId="delete-scene" label="Delete scene" color="red" icon="trash" confirmMessage="Delete this scene?" />
</Stack>`,
requests: [
{
id: "set-light",
kind: "action",
method: "PUT",
pathTemplate: "/api/lights/living-room",
parameters: { state: "string" },
bodyTemplate: { state: { $param: "state" } },
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "delete-scene",
kind: "action",
method: "DELETE",
pathTemplate: "/api/scenes/living-room",
parameters: {},
auth: "inherit",
minimumBoardPermission: "full",
},
],
},
];
+108
View File
@@ -0,0 +1,108 @@
export interface ImportReview {
name: string;
origin: string;
authType: string;
networkScope: string;
methods: string[];
permissions: string[];
hasActions: boolean;
}
function extractFencedBlock(text: string, acceptedLanguages: readonly string[]): string | undefined {
let cursor = 0;
while (cursor < text.length) {
const fenceStart = text.indexOf("```", cursor);
if (fenceStart === -1) return undefined;
const headerStart = fenceStart + 3;
const headerEnd = text.indexOf("\n", headerStart);
if (headerEnd === -1) return undefined;
const contentStart = headerEnd + 1;
const fenceEnd = text.indexOf("```", contentStart);
if (fenceEnd === -1) return undefined;
const language = text.slice(headerStart, headerEnd).trim().toLowerCase();
if (acceptedLanguages.includes(language)) return text.slice(contentStart, fenceEnd);
cursor = fenceEnd + 3;
}
return undefined;
}
export function parseCustomWidgetClipboard(text: string): Record<string, unknown> | null {
try {
const jsonBlock = extractFencedBlock(text, ["", "json"]);
const jsxBlock = extractFencedBlock(text, ["jsx", "tsx"])?.trim();
const value: unknown = JSON.parse(jsonBlock ?? text);
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const widget = value as Record<string, unknown>;
if (
typeof widget.name !== "string" ||
typeof widget.url !== "string" ||
typeof widget.displayType !== "string" ||
!widget.displayConfig ||
typeof widget.displayConfig !== "object" ||
Array.isArray(widget.displayConfig)
) {
return null;
}
if (jsxBlock && widget.displayType === "customJsx") {
const displayConfig = widget.displayConfig as Record<string, unknown>;
if (displayConfig.template === "__HOMARR_TEMPLATE__" || !displayConfig.template) {
displayConfig.template = jsxBlock;
}
}
return widget;
} catch {
return null;
}
}
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
if (!value) return null;
const displayConfig = value.displayConfig as Record<string, unknown>;
const requests = Array.isArray(displayConfig.requests)
? displayConfig.requests.filter(
(request): request is Record<string, unknown> =>
request !== null && typeof request === "object" && !Array.isArray(request),
)
: [];
const baseMethod = typeof value.method === "string" ? value.method : "GET";
const methods = [
...new Set([
baseMethod,
...requests.map((request) => request.method).filter((method): method is string => typeof method === "string"),
]),
];
const permissions = [
...new Set(
requests
.map((request) => request.minimumBoardPermission)
.filter(
(permission): permission is string =>
permission === "view" || permission === "modify" || permission === "full",
),
),
];
if (permissions.length === 0) permissions.push(baseMethod === "GET" ? "view" : "modify");
let origin = value.url;
try {
origin = new URL(value.url as string).origin;
} catch {
// The server returns the localized validation error after confirmation.
}
return {
name: value.name as string,
origin: String(origin),
authType: typeof value.authType === "string" ? value.authType : "none",
networkScope: typeof displayConfig.networkScope === "string" ? displayConfig.networkScope : "legacy",
methods,
permissions,
hasActions: methods.some((method) => method !== "GET"),
};
}
@@ -0,0 +1,7 @@
export * from "./ai-prompt";
export * from "./component-registry";
export * from "./display-data";
export * from "./display-form";
export * from "./examples";
export * from "./import";
export * from "./schema";
@@ -0,0 +1,22 @@
export const customWidgetAuthTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
export type CustomWidgetAuthType = (typeof customWidgetAuthTypes)[number];
export const customWidgetDisplayTypes = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
] as const;
export type CustomWidgetDisplayType = (typeof customWidgetDisplayTypes)[number];
export const customWidgetMethods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
export type CustomWidgetMethod = (typeof customWidgetMethods)[number];
export const customWidgetSecretKinds = ["apiKey", "username", "password"] as const;
export type CustomWidgetSecretKind = (typeof customWidgetSecretKinds)[number];
@@ -0,0 +1,5 @@
export * from "./built-in-display-schemas";
export * from "./custom-jsx-schema";
export * from "./definition-schemas";
export * from "./display-config-schema";
export * from "./schema-types";
+306
View File
@@ -0,0 +1,306 @@
import { Parser } from "acorn";
import jsx from "acorn-jsx";
import { customJsxComponentByName, customJsxSupportedPropsByName } from "../core/component-registry";
import {
CUSTOM_JSX_BLOCKED_PROPERTIES,
CUSTOM_JSX_BLOCKED_PROPS,
CUSTOM_JSX_LIMITS,
normalizeCustomJsxProperty,
} from "./policy";
interface AstNode {
type: string;
start?: number;
loc?: { start?: { line?: number; column?: number } };
[key: string]: unknown;
}
export interface CustomJsxTemplateDiagnostic {
severity: "error" | "warning";
message: string;
index: number;
line: number;
column: number;
}
const JsxParser = Parser.extend(jsx());
const rootBindings = new Set([
"Array",
"Boolean",
"Date",
"Infinity",
"JSON",
"Math",
"NaN",
"Number",
"Object",
"String",
"data",
"decodeURIComponent",
"encodeURIComponent",
"isFinite",
"isNaN",
"parseFloat",
"parseInt",
"undefined",
]);
const nodeOf = (value: unknown): AstNode | null =>
value !== null && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"
? (value as AstNode)
: null;
const nodesOf = (value: unknown): AstNode[] =>
Array.isArray(value) ? value.map(nodeOf).filter((node): node is AstNode => node !== null) : [];
const staticPropertyName = (node: AstNode | null): string | undefined => {
if (!node) return undefined;
if (node.type === "Literal" && (typeof node.value === "string" || typeof node.value === "number")) {
return String(node.value);
}
if (node.type === "BinaryExpression" && node.operator === "+") {
const left = staticPropertyName(nodeOf(node.left));
const right = staticPropertyName(nodeOf(node.right));
return left === undefined || right === undefined ? undefined : left + right;
}
if (node.type === "TemplateLiteral" && Array.isArray(node.expressions) && node.expressions.length === 0) {
return (node.quasis as Array<{ value?: { cooked?: unknown } }> | undefined)
?.map((quasi) => String(quasi.value?.cooked ?? ""))
.join("");
}
return undefined;
};
export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDiagnostic[] {
const diagnostics: CustomJsxTemplateDiagnostic[] = [];
let operations = 0;
const add = (node: AstNode, message: string, severity: "error" | "warning" = "error") => {
const line = node.loc?.start?.line ?? 1;
diagnostics.push({
severity,
message,
index: Math.max(0, (node.start ?? 2) - 2),
line,
column: Math.max(1, (node.loc?.start?.column ?? 0) + 1 - (line === 1 ? 2 : 0)),
});
};
const checkBudget = (node: AstNode, depth: number) => {
operations += 1;
if (depth > CUSTOM_JSX_LIMITS.astDepth)
add(node, `Template exceeds the AST depth limit (${CUSTOM_JSX_LIMITS.astDepth})`);
if (operations === CUSTOM_JSX_LIMITS.operations + 1) {
add(node, `Template exceeds the operation limit (${CUSTOM_JSX_LIMITS.operations})`);
}
return depth <= CUSTOM_JSX_LIMITS.astDepth && operations <= CUSTOM_JSX_LIMITS.operations;
};
const tagName = (node: AstNode): string | null => {
if (node.type === "JSXIdentifier") return String(node.name ?? "");
if (node.type !== "JSXMemberExpression") return null;
const object = nodeOf(node.object);
const property = nodeOf(node.property);
const left = object ? tagName(object) : null;
const right = property ? tagName(property) : null;
return left && right ? `${left}.${right}` : null;
};
const visit = (node: AstNode, depth: number, bindings: ReadonlySet<string>): void => {
if (!checkBudget(node, depth)) return;
switch (node.type) {
case "Program":
nodesOf(node.body).forEach((child) => visit(child, depth + 1, bindings));
return;
case "ExpressionStatement": {
const expression = nodeOf(node.expression);
if (expression) visit(expression, depth + 1, bindings);
return;
}
case "JSXFragment":
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
return;
case "JSXElement": {
const opening = nodeOf(node.openingElement);
if (!opening) {
add(node, "Invalid JSX element");
return;
}
const nameNode = nodeOf(opening.name);
const name = nameNode ? tagName(nameNode) : null;
const descriptor = name ? customJsxComponentByName.get(name) : undefined;
if (!descriptor || descriptor.safety === "denied") {
add(opening, name ? `Component '${name}' is not available` : "Invalid JSX component name");
}
for (const attribute of nodesOf(opening.attributes)) {
if (attribute.type === "JSXSpreadAttribute") {
const argument = nodeOf(attribute.argument);
if (argument) visit(argument, depth + 1, bindings);
continue;
}
if (attribute.type !== "JSXAttribute") {
add(attribute, `Unsupported JSX attribute '${attribute.type}'`);
continue;
}
const attributeNameNode = nodeOf(attribute.name);
const attributeName = attributeNameNode?.type === "JSXIdentifier" ? String(attributeNameNode.name) : "";
const supportedProps = name ? customJsxSupportedPropsByName.get(name) : undefined;
if (
(/^on/i.test(attributeName) && !supportedProps?.has(attributeName)) ||
CUSTOM_JSX_BLOCKED_PROPS.has(attributeName)
) {
add(attribute, `Prop '${attributeName}' is not allowed`);
} else if (name && !supportedProps?.has(attributeName)) {
add(attribute, `Prop '${attributeName}' is not supported by ${name} and will be ignored`, "warning");
}
const value = nodeOf(attribute.value);
if (value?.type === "JSXExpressionContainer") {
const expression = nodeOf(value.expression);
if (expression && expression.type !== "JSXEmptyExpression") visit(expression, depth + 1, bindings);
}
}
nodesOf(node.children).forEach((child) => visit(child, depth + 1, bindings));
return;
}
case "JSXText":
case "JSXEmptyExpression":
case "TemplateElement":
return;
case "JSXExpressionContainer": {
const expression = nodeOf(node.expression);
if (expression && expression.type !== "JSXEmptyExpression") visit(expression, depth + 1, bindings);
return;
}
case "Literal":
if (node.regex !== undefined || typeof node.value === "bigint") {
add(node, "Regular expressions and bigint literals are not supported");
}
return;
case "Identifier": {
const name = String(node.name ?? "");
if (!bindings.has(name)) add(node, `Unknown binding '${name}'`);
return;
}
case "ArrayExpression":
nodesOf(node.elements).forEach((child) => {
const argument = child.type === "SpreadElement" ? nodeOf(child.argument) : child;
if (argument) visit(argument, depth + 1, bindings);
});
return;
case "ObjectExpression":
nodesOf(node.properties).forEach((property) => {
if (property.type === "SpreadElement") {
const argument = nodeOf(property.argument);
if (argument) visit(argument, depth + 1, bindings);
return;
}
if (property.type !== "Property" || property.kind !== "init" || property.method || property.shorthand) {
add(property, "Only explicit object properties are supported");
return;
}
const key = nodeOf(property.key);
if (property.computed && key) visit(key, depth + 1, bindings);
if (
!property.computed &&
key &&
CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(key.name ?? key.value))
) {
add(key, "Reflective object properties are not allowed");
}
const value = nodeOf(property.value);
if (value) visit(value, depth + 1, bindings);
});
return;
case "UnaryExpression":
if (!["!", "+", "-", "typeof"].includes(String(node.operator))) {
add(node, `Unary operator '${String(node.operator)}' is not supported`);
}
if (nodeOf(node.argument)) visit(node.argument as AstNode, depth + 1, bindings);
return;
case "BinaryExpression":
case "LogicalExpression": {
const left = nodeOf(node.left);
const right = nodeOf(node.right);
if (left) visit(left, depth + 1, bindings);
if (right) visit(right, depth + 1, bindings);
return;
}
case "ConditionalExpression": {
[node.test, node.consequent, node.alternate].map(nodeOf).forEach((child) => {
if (child) visit(child, depth + 1, bindings);
});
return;
}
case "MemberExpression": {
const object = nodeOf(node.object);
const property = nodeOf(node.property);
if (object) visit(object, depth + 1, bindings);
if (node.computed && property) visit(property, depth + 1, bindings);
const propertyName = node.computed
? staticPropertyName(property)
: String(property?.name ?? property?.value ?? "");
if (propertyName && CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(propertyName))) {
add(property ?? node, "Reflective property access is not allowed");
}
return;
}
case "CallExpression": {
const callee = nodeOf(node.callee);
if (callee) visit(callee, depth + 1, bindings);
nodesOf(node.arguments).forEach((argument) => {
if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported");
else visit(argument, depth + 1, bindings);
});
return;
}
case "ArrowFunctionExpression": {
if (node.async || node.generator || node.expression === false) {
add(node, "Only synchronous expression-bodied arrow callbacks are supported");
return;
}
const callbackBindings = new Set(bindings);
for (const parameter of nodesOf(node.params)) {
if (parameter.type !== "Identifier") add(parameter, "Callback parameters must be identifiers");
else callbackBindings.add(String(parameter.name));
}
const body = nodeOf(node.body);
if (body) visit(body, depth + 1, callbackBindings);
return;
}
case "TemplateLiteral":
nodesOf(node.expressions).forEach((expression) => visit(expression, depth + 1, bindings));
return;
case "ChainExpression": {
const expression = nodeOf(node.expression);
if (expression) visit(expression, depth + 1, bindings);
return;
}
default:
add(node, `Unsupported expression '${node.type}'`);
}
};
try {
const program = JsxParser.parse(`<>${template}</>`, {
ecmaVersion: "latest",
sourceType: "module",
locations: true,
}) as unknown as AstNode;
visit(program, 0, rootBindings);
} catch (error) {
const parseError = error as Error & { pos?: number; loc?: { line?: number; column?: number } };
diagnostics.push({
severity: "error",
message: parseError.message,
index: Math.max(0, (parseError.pos ?? 2) - 2),
line: parseError.loc?.line ?? 1,
column: Math.max(1, (parseError.loc?.column ?? 2) - 1),
});
}
return diagnostics;
}
@@ -0,0 +1,84 @@
import { createSafeCallable } from "./safe-bindings";
function sanitizeData(value: unknown): unknown {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(sanitizeData);
const safe: Record<string, unknown> = Object.create(null);
for (const [key, child] of Object.entries(value)) {
if (["constructor", "__proto__", "prototype"].includes(key)) continue;
safe[key] = sanitizeData(child);
}
return safe;
}
const safeMath = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
round: createSafeCallable((value: number) => Math.round(value)),
floor: createSafeCallable((value: number) => Math.floor(value)),
ceil: createSafeCallable((value: number) => Math.ceil(value)),
abs: createSafeCallable((value: number) => Math.abs(value)),
min: createSafeCallable((...values: number[]) => Math.min(...values)),
max: createSafeCallable((...values: number[]) => Math.max(...values)),
pow: createSafeCallable((base: number, exponent: number) => Math.pow(base, exponent)),
sqrt: createSafeCallable((value: number) => Math.sqrt(value)),
PI: Math.PI,
}),
);
const safeDate = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
now: createSafeCallable(() => Date.now()),
create: createSafeCallable((value?: string | number) =>
value === undefined ? Date.now() : new Date(value).getTime(),
),
toISOString: createSafeCallable((value: string | number) => new Date(value).toISOString()),
toLocaleDateString: createSafeCallable((value: string | number, locale?: string) =>
new Date(value).toLocaleDateString(locale ?? "en-US"),
),
toLocaleTimeString: createSafeCallable((value: string | number, locale?: string) =>
new Date(value).toLocaleTimeString(locale ?? "en-US"),
),
getTime: createSafeCallable((value: string | number) => new Date(value).getTime()),
getYear: createSafeCallable((value: string | number) => new Date(value).getFullYear()),
getMonth: createSafeCallable((value: string | number) => new Date(value).getMonth()),
getDay: createSafeCallable((value: string | number) => new Date(value).getUTCDay()),
}),
);
export function createCustomJsxBindings(apiData: unknown) {
const safeJson = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
stringify: createSafeCallable((value: unknown) => JSON.stringify(value)),
}),
);
const safeArray = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
isArray: createSafeCallable(Array.isArray),
from: createSafeCallable((value: ArrayLike<unknown> | Iterable<unknown>) => Array.from(value).slice(0, 2_000)),
}),
);
const safeObject = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
keys: createSafeCallable(Object.keys),
values: createSafeCallable(Object.values),
entries: createSafeCallable(Object.entries),
}),
);
return {
data: sanitizeData(apiData),
String: createSafeCallable((value: unknown) => String(value)),
Number: createSafeCallable((value: unknown) => Number(value)),
Boolean: createSafeCallable((value: unknown) => Boolean(value)),
Math: safeMath,
JSON: safeJson,
Array: safeArray,
Object: safeObject,
Date: safeDate,
parseInt: createSafeCallable((value: string, radix?: number) => parseInt(value, radix)),
parseFloat: createSafeCallable((value: string) => parseFloat(value)),
encodeURIComponent: createSafeCallable((value: string) => encodeURIComponent(value)),
decodeURIComponent: createSafeCallable((value: string) => decodeURIComponent(value)),
isNaN: createSafeCallable((value: unknown) => Number.isNaN(Number(value))),
isFinite: createSafeCallable((value: unknown) => Number.isFinite(Number(value))),
};
}
@@ -0,0 +1,271 @@
import { isSafeCallable } from "./safe-bindings";
import { isInterpreterCallback, normalizedSliceIndex, numericArgument, SafeJsxError } from "./interpreter-foundation";
import type { Budget, InterpreterCallback } from "./interpreter-foundation";
interface CollectionMethodContext {
budget: Budget;
runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown;
}
export function callCollectionMethod(
receiver: unknown,
method: string,
args: unknown[],
depth: number,
context: CollectionMethodContext,
): { 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) {
context.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) {
context.budget.collection();
const callbackResult = context.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") {
context.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) {
context.budget.collection();
result.push(receiver[index]);
}
return { handled: true, value: result };
}
if (method === "join") {
context.budget.collection(receiver.length);
const separator = String(args[0] ?? ",");
let result = "";
receiver.forEach((item, index) => {
result += `${index === 0 ? "" : separator}${String(item ?? "")}`;
context.budget.string(result);
});
return { handled: true, value: result };
}
if (method === "includes") {
context.budget.collection(receiver.length);
return { handled: true, value: receiver.some((item) => Object.is(item, args[0])) };
}
if (method === "indexOf") {
context.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) {
context.budget.collection();
const result = useSafe
? (callback as (...a: never[]) => unknown)(receiver[index] as never)
: context.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) => {
context.budget.collection();
const delta = Number(context.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) {
context.budget.collection();
accumulator = context.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;
context.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 flatDepth = numericArgument(args[0], 1);
if (flatDepth !== 1) throw new SafeJsxError("flat() only supports depth 1");
const result: unknown[] = [];
for (const item of receiver) {
context.budget.collection();
if (Array.isArray(item)) {
for (const nested of item) {
context.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) {
context.budget.collection();
result.push(copy[index]);
}
return { handled: true, value: result };
}
}
if (typeof receiver === "string") {
switch (method) {
case "toUpperCase":
return { handled: true, value: context.budget.string(receiver.toUpperCase()) };
case "toLowerCase":
return { handled: true, value: context.budget.string(receiver.toLowerCase()) };
case "trim":
return { handled: true, value: context.budget.string(receiver.trim()) };
case "trimStart":
return { handled: true, value: context.budget.string(receiver.trimStart()) };
case "trimEnd":
return { handled: true, value: context.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: context.budget.string(receiver) };
return {
handled: true,
value: context.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: context.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: context.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: context.budget.string(receiver.repeat(count).slice(0, 200_001)) };
}
case "slice":
return {
handled: true,
value: context.budget.string(receiver.slice(numericArgument(args[0], 0), args[1] as number | undefined)),
};
case "substring":
return {
handled: true,
value: context.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: context.budget.string(
receiver.padStart(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
),
};
case "padEnd":
return {
handled: true,
value: context.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);
context.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: context.budget.string(String(receiver)) };
}
return { handled: false };
}
+8
View File
@@ -0,0 +1,8 @@
export * from "./analyzer";
export * from "./interpreter";
export * from "./policy";
export * from "./safe-bindings";
export * from "./bindings";
export * from "./runtime-components";
export * from "./interactive-components";
export { sanitizeCustomJsxProps } from "./safe-properties";
@@ -0,0 +1,228 @@
"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>
);
}
@@ -0,0 +1,108 @@
import { CUSTOM_JSX_LIMITS } from "./policy";
export interface AstNode {
type: string;
start?: number;
end?: number;
[key: string]: unknown;
}
export interface EvaluationBudgets {
maxAstDepth: number;
maxOperations: number;
maxCollectionItems: number;
maxRenderedNodes: number;
maxStringLength: number;
}
export interface SafeJsxBudgets extends Partial<EvaluationBudgets> {}
export const INTERPRETER_CALLBACK = Symbol("custom-jsx-interpreter-callback");
export const DEFAULT_BUDGETS: EvaluationBudgets = {
maxAstDepth: CUSTOM_JSX_LIMITS.astDepth,
maxOperations: CUSTOM_JSX_LIMITS.operations,
maxCollectionItems: CUSTOM_JSX_LIMITS.collectionItems,
maxRenderedNodes: CUSTOM_JSX_LIMITS.renderedNodes,
maxStringLength: CUSTOM_JSX_LIMITS.stringLength,
};
export interface InterpreterCallback {
readonly kind: typeof INTERPRETER_CALLBACK;
readonly params: readonly string[];
readonly body: AstNode;
readonly environment: Environment;
}
export class SafeJsxError extends Error {
public constructor(message: string) {
super(message);
this.name = "SafeJsxError";
}
}
export 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}`);
}
}
export 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})`);
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 {
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;
}
}
export 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;
}
export function asNodeArray(value: unknown, context: string): AstNode[] {
if (!Array.isArray(value)) throw new SafeJsxError(`Invalid ${context}`);
return value.map((item) => asNode(item, context));
}
export const isInterpreterCallback = (value: unknown): value is InterpreterCallback =>
typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === INTERPRETER_CALLBACK;
export function numericArgument(value: unknown, fallback: number): number {
const number = value === undefined ? fallback : Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.trunc(number);
}
export 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);
}
@@ -0,0 +1,387 @@
import type { ComponentType, ReactNode } from "react";
import { Parser } from "acorn";
import jsx from "acorn-jsx";
import { callCollectionMethod } from "./collection-methods";
import { emitJsxElement, emitJsxFragment } from "./jsx-emitter";
import type { JsxEmitterContext } from "./jsx-emitter";
import { isSafeCallable } from "./safe-bindings";
import {
asNode,
asNodeArray,
Budget,
DEFAULT_BUDGETS,
Environment,
INTERPRETER_CALLBACK,
SafeJsxError,
} from "./interpreter-foundation";
import type { AstNode, EvaluationBudgets, InterpreterCallback, SafeJsxBudgets } from "./interpreter-foundation";
import { normalizedProperty, ownProperty } from "./safe-properties";
import { CUSTOM_JSX_LIMITS } from "./policy";
export { SafeJsxError } from "./interpreter-foundation";
export type { SafeJsxBudgets } from "./interpreter-foundation";
export { sanitizeCustomJsxProps } from "./safe-properties";
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());
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;
}
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 {
return emitJsxFragment(node, environment, depth, this.emitterContext());
}
private evaluateJsxElement(node: AstNode, environment: Environment, depth: number): ReactNode {
return emitJsxElement(node, environment, depth, this.emitterContext());
}
private emitterContext(): JsxEmitterContext {
return {
components: this.components,
budget: this.budget,
warnings: this.warnings,
evaluate: (node, environment, depth) => this.evaluate(node, environment, depth),
renderCallback: (callback, args) =>
new Interpreter(this.components, callback.environment, this.limits).runCallback(callback, args, 0) as ReactNode,
};
}
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 } {
return callCollectionMethod(receiver, method, args, depth, {
budget: this.budget,
runCallback: (callback, callbackArgs, callbackDepth) => this.runCallback(callback, callbackArgs, callbackDepth),
});
}
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 > CUSTOM_JSX_LIMITS.templateLength) {
throw new SafeJsxError(`Template exceeds the ${CUSTOM_JSX_LIMITS.templateLength} 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);
}
@@ -0,0 +1,93 @@
import type { ComponentType, ReactNode } from "react";
import { createElement, Fragment } from "react";
import { asNode, asNodeArray, isInterpreterCallback, SafeJsxError } from "./interpreter-foundation";
import type { AstNode, Budget, Environment, InterpreterCallback } from "./interpreter-foundation";
import { normalizedProperty, sanitizeCustomJsxProps } from "./safe-properties";
import { CUSTOM_JSX_BLOCKED_TAGS } from "./policy";
export interface JsxEmitterContext {
components: Readonly<Record<string, ComponentType<never>>>;
budget: Budget;
warnings: Set<string>;
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
renderCallback(callback: InterpreterCallback, args: unknown[]): ReactNode;
}
export function emitJsxFragment(
node: AstNode,
environment: Environment,
depth: number,
context: JsxEmitterContext,
): ReactNode {
const children = asNodeArray(node.children, "fragment children").map((child) =>
context.evaluate(child, environment, depth + 1),
);
context.budget.rendered();
return createElement(Fragment, null, ...(children as ReactNode[]));
}
export function emitJsxElement(
node: AstNode,
environment: Environment,
depth: number,
context: JsxEmitterContext,
): ReactNode {
const opening = asNode(node.openingElement, "JSX opening element");
const tag = jsxTagName(asNode(opening.name, "JSX tag"));
if (CUSTOM_JSX_BLOCKED_TAGS.has(tag.toLowerCase())) {
context.warnings.add(`Blocked element: ${tag}`);
return null;
}
const component = context.components[tag];
if (!component) {
context.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 = context.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 value = asNode(attribute.value, "attribute value");
rawProps[name] = value.type === "Literal" ? value.value : context.evaluate(value, environment, depth + 1);
}
}
let children = asNodeArray(node.children, "JSX children").map((child) =>
context.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 callback = isInterpreterCallback(callbackProp) ? callbackProp : callbackChildren[0];
if (callback) {
props.render = (value: unknown, metadata: unknown) => context.renderCallback(callback, [value, metadata]);
children = children.filter((child) => child !== callback);
}
}
context.budget.rendered();
return createElement(component, props as never, ...(children as ReactNode[]));
}
function jsxTagName(node: AstNode): string {
if (node.type === "JSXIdentifier") return String(node.name);
if (node.type === "JSXMemberExpression") {
return `${jsxTagName(asNode(node.object, "JSX member object"))}.${jsxTagName(asNode(node.property, "JSX member property"))}`;
}
throw new SafeJsxError(`Unsupported JSX tag: ${node.type}`);
}
+73
View File
@@ -0,0 +1,73 @@
export const CUSTOM_JSX_LIMITS = Object.freeze({
templateLength: 50_000,
astDepth: 64,
operations: 25_000,
collectionItems: 2_000,
renderedNodes: 10_000,
stringLength: 200_000,
});
export const CUSTOM_JSX_BLOCKED_PROPERTIES: ReadonlySet<string> = new Set([
"__proto__",
"apply",
"arguments",
"bind",
"call",
"callee",
"caller",
"constructor",
"prototype",
]);
export const CUSTOM_JSX_BLOCKED_PROPS: ReadonlySet<string> = new Set([
"children",
"className",
"classNames",
"classes",
"component",
"dangerouslySetInnerHTML",
"innerRef",
"portalProps",
"ref",
"renderRoot",
"styles",
"unstyled",
"withinPortal",
]);
export const CUSTOM_JSX_BLOCKED_TAGS: ReadonlySet<string> = new Set([
"base",
"embed",
"form",
"iframe",
"link",
"meta",
"object",
"script",
"style",
]);
export const CUSTOM_JSX_URL_PROPS: ReadonlySet<string> = new Set(["backgroundImage", "href", "src"]);
export const CUSTOM_JSX_BLOCKED_STYLE_KEYS: ReadonlySet<string> = new Set([
"backdropFilter",
"behavior",
"bottom",
"clipPath",
"content",
"filter",
"inset",
"left",
"mask",
"pointerEvents",
"position",
"right",
"top",
"zIndex",
]);
export function normalizeCustomJsxProperty(value: unknown): string {
return String(value ?? "")
.normalize("NFKC")
.toLowerCase();
}
@@ -0,0 +1,149 @@
import type { ComponentType, ReactNode } from "react";
import { createElement, useEffect, useRef, useState } from "react";
import * as Core from "@mantine/core";
import * as Charts from "@mantine/charts";
import * as Dates from "@mantine/dates";
import { enabledCustomJsxComponents } from "../core/component-registry";
import { ActionButton, ToggleSwitch } from "../runtime/actions";
import { SubData } from "../runtime/data";
import { RefreshButton } from "../runtime/refresh-button";
import { SubFetch } from "../runtime/sub-fetch";
import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge } from "./interactive-components";
import { sanitizeCustomJsxProps } from "./safe-properties";
type Namespace = Readonly<Record<string, unknown>>;
function resolveExport(namespace: Namespace, name: string): ComponentType<never> | undefined {
let value: unknown = namespace;
for (const segment of name.split(".")) {
if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) {
return undefined;
}
value = (value as Record<string, unknown>)[segment];
}
return typeof value === "function" || (typeof value === "object" && value !== null)
? (value as ComponentType<never>)
: undefined;
}
function safeUrl(value: unknown) {
return typeof value === "string" && (/^https?:\/\//iu.test(value) || value.startsWith("/") || value.startsWith("#"));
}
function wrap(component: ComponentType<never>, additions: Record<string, unknown> = {}) {
return function SafeComponent(props: Record<string, unknown>) {
return createElement(component, { ...sanitizeCustomJsxProps(props), ...additions } as never);
};
}
function SafeLink({ component, props }: { component: ComponentType<never>; props: Record<string, unknown> }) {
const sanitized = sanitizeCustomJsxProps(props);
return createElement(component, {
...sanitized,
href: safeUrl(props.href) ? props.href : undefined,
rel: sanitized.target === "_blank" ? "noopener noreferrer" : sanitized.rel,
} as never);
}
function createCopyButton(labels: { copy: string; copied: string }) {
return function SafeCopyButton({ value }: { value?: string; children?: ReactNode }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const copy = async () => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 2_000);
} catch {
setCopied(false);
}
};
return (
<Core.Button
size="xs"
variant={copied ? "filled" : "light"}
color={copied ? "teal" : "blue"}
onClick={() => void copy()}
>
{copied ? labels.copied : labels.copy}
</Core.Button>
);
};
}
export interface CustomJsxComponentAdapters {
TablerIcon: ComponentType<never>;
copyLabels: { copy: string; copied: string };
}
export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record<string, ComponentType<never>> {
const namespaces: Record<string, Namespace> = {
"@mantine/core": Core as unknown as Namespace,
"@mantine/charts": Charts as unknown as Namespace,
"@mantine/dates": Dates as unknown as Namespace,
};
const components: Record<string, ComponentType<never>> = {};
for (const descriptor of enabledCustomJsxComponents) {
const namespace = namespaces[descriptor.package];
const component = namespace ? resolveExport(namespace, descriptor.name) : undefined;
if (component) components[descriptor.name] = component;
}
const core = Core as unknown as Namespace;
for (const name of [
"Notification",
"LoadingOverlay",
"Overlay",
"Breadcrumbs",
"Stepper",
"Tree",
"Button",
"ActionIcon",
"Burger",
"CloseButton",
"Chip",
] as const) {
const component = resolveExport(core, name);
if (component) components[name] = wrap(component);
}
const anchor = resolveExport(core, "Anchor");
const navLink = resolveExport(core, "NavLink");
if (anchor) components.Anchor = (props) => <SafeLink component={anchor} props={props} />;
if (navLink) components.NavLink = (props) => <SafeLink component={navLink} props={props} />;
for (const [name, additions] of Object.entries({
Rating: { readOnly: true },
Slider: { readOnly: true },
Switch: { readOnly: true },
SegmentedControl: { readOnly: true },
Pagination: { disabled: true },
})) {
const component = resolveExport(core, name);
if (component) components[name] = wrap(component, additions);
}
const dates = Dates as unknown as Namespace;
for (const descriptor of enabledCustomJsxComponents.filter(
(item) => item.package === "@mantine/dates" && item.safety === "wrapped",
)) {
const component = resolveExport(dates, descriptor.name);
if (component) components[descriptor.name] = wrap(component, { static: true });
}
Object.assign(components, {
CopyButton: createCopyButton(adapters.copyLabels),
PaginatedList,
TabsContainer,
TabPanel,
Collapsible,
StatBar,
TypeBadge,
TablerIcon: adapters.TablerIcon,
SubFetch,
SubData,
ActionButton,
ToggleSwitch,
RefreshButton,
});
return components;
}
@@ -0,0 +1,16 @@
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);
}
@@ -0,0 +1,96 @@
import { customJsxSupportedPropsByName } from "../core";
import { isInterpreterCallback, SafeJsxError } from "./interpreter-foundation";
import {
CUSTOM_JSX_BLOCKED_PROPERTIES,
CUSTOM_JSX_BLOCKED_PROPS,
CUSTOM_JSX_BLOCKED_STYLE_KEYS,
CUSTOM_JSX_URL_PROPS,
normalizeCustomJsxProperty,
} from "./policy";
export 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);
if (CUSTOM_JSX_BLOCKED_PROPERTIES.has(normalizeCustomJsxProperty(property))) {
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;
}
export 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 (CUSTOM_JSX_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;
}
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)) || CUSTOM_JSX_BLOCKED_PROPS.has(key)) continue;
if (componentName && !supportedProps?.has(key)) continue;
if (typeof value === "function" || isInterpreterCallback(value)) continue;
if (CUSTOM_JSX_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;
}
@@ -0,0 +1,214 @@
"use client";
import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react";
import { Button, Switch } from "@mantine/core";
import { IconCheck, IconPlayerPlay, IconPower, IconRefresh, IconTrash } from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { useCustomWidgetRuntime } from "./context";
import { normalizeParams } from "./data";
import { MigrationRequiredAlert } from "./status";
import type { CustomJsxRuntimeParams, CustomWidgetRequestResult } from "./types";
const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
play: IconPlayerPlay,
check: IconCheck,
refresh: IconRefresh,
power: IconPower,
trash: IconTrash,
};
function parseBool(value: unknown, fallback = false) {
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return fallback;
}
function useActionExecutor() {
const runtime = useCustomWidgetRuntime();
const mutation = useMutation({
mutationFn: ({
requestId,
params,
confirmed,
}: {
requestId: string;
params: CustomJsxRuntimeParams;
confirmed: boolean;
}): Promise<CustomWidgetRequestResult> => {
if (!runtime.itemId && !runtime.previewSessionId) {
return Promise.resolve({ ok: false, status: 0, data: null, error: runtime.messages.widgetItemUnavailable });
}
if (runtime.isEditMode) {
return Promise.resolve({ ok: false, status: 0, data: null, error: runtime.messages.actionsDisabledEditMode });
}
return runtime.port.executeAction({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
requestId,
params,
confirmed,
});
},
});
return { runtime, mutation };
}
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;
url?: string;
method?: string;
body?: string;
headers?: string;
fullWidth?: boolean | string;
}
export function ActionButton(props: ActionButtonProps) {
const { runtime, mutation } = useActionExecutor();
const params = normalizeParams(props.params);
const Icon = props.icon ? ICON_MAP[props.icon.toLowerCase()] : undefined;
const mustConfirm = runtime.requestCapabilities.some(
(capability) => capability.id === props.requestId && capability.kind === "action" && capability.method === "DELETE",
);
const run = async (confirmed: boolean) => {
if (!props.requestId || !params) return;
try {
const result = await mutation.mutateAsync({ requestId: props.requestId, params, confirmed });
const success = result.ok;
runtime.port.notify({
kind: success ? "success" : "error",
title: props.label,
message: success
? (props.successMessage ??
(result.simulated ? runtime.messages.actionSimulated : runtime.messages.actionCompleted))
: (props.errorMessage ?? result.error ?? `${runtime.messages.requestFailed} (${result.status})`),
});
if (success)
await runtime.port.invalidate({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
targets: props.invalidate ?? [],
});
} catch {
runtime.port.notify({
kind: "error",
title: props.label,
message: props.errorMessage ?? runtime.messages.requestFailed,
});
}
};
const click = async () => {
const confirmMessage = props.confirmMessage ?? (mustConfirm ? runtime.messages.confirmDelete : undefined);
if (confirmMessage && !(await runtime.port.confirm({ title: props.label, message: confirmMessage }))) return;
await run(Boolean(confirmMessage));
};
if (!props.requestId) return <MigrationRequiredAlert />;
return (
<Button
color={props.color ?? "blue"}
variant={(props.variant ?? "filled") as never}
size={(props.size ?? "sm") as never}
onClick={() => void click()}
loading={mutation.isPending}
leftSection={Icon ? <Icon size={16} /> : undefined}
disabled={
parseBool(props.disabled) || runtime.isEditMode || (!runtime.itemId && !runtime.previewSessionId) || !params
}
>
{props.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;
url?: string;
method?: string;
onBody?: string;
offBody?: string;
}
export function ToggleSwitch(props: ToggleSwitchProps) {
const { runtime, mutation } = useActionExecutor();
const initial = parseBool(props.initialValue);
const [checked, setChecked] = useState(initial);
const locked = useRef(false);
const onParams = normalizeParams(props.onParams);
const offParams = normalizeParams(props.offParams);
useEffect(() => {
if (!locked.current) setChecked(initial);
}, [initial]);
const change = async (next: boolean) => {
const params = next ? onParams : offParams;
if (!props.requestId || !params || locked.current) return;
locked.current = true;
const previous = checked;
setChecked(next);
try {
const result = await mutation.mutateAsync({ requestId: props.requestId, params, confirmed: false });
if (!result.ok) {
setChecked(previous);
runtime.port.notify({
kind: "error",
title: props.label ?? runtime.messages.toggle,
message: props.errorMessage ?? result.error ?? runtime.messages.requestFailed,
});
} else {
await runtime.port.invalidate({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
targets: props.invalidate ?? [],
});
}
} catch {
setChecked(previous);
runtime.port.notify({
kind: "error",
title: props.label ?? runtime.messages.toggle,
message: props.errorMessage ?? runtime.messages.requestFailed,
});
} finally {
locked.current = false;
}
};
if (!props.requestId) return <MigrationRequiredAlert />;
return (
<Switch
label={props.label}
color={props.color ?? "blue"}
size={(props.size ?? "sm") as never}
checked={checked}
onChange={(event) => void change(event.currentTarget.checked)}
disabled={
parseBool(props.disabled) ||
mutation.isPending ||
runtime.isEditMode ||
(!runtime.itemId && !runtime.previewSessionId) ||
!onParams ||
!offParams
}
/>
);
}
@@ -0,0 +1,125 @@
import { ActionIcon, Code, Group, ScrollArea, SimpleGrid, Stack, Table, Text, Tooltip } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import type { CustomWidgetDisplayData } from "./display-types";
export function KeyValueDisplay({ data }: { data: CustomWidgetDisplayData }) {
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }> | undefined) ?? [];
const content = entries.map((entry, index) => (
<Group key={index} 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>
));
return data.layout === "grid" ? (
<SimpleGrid cols={Number(data.columns ?? 2)} spacing="xs" p="sm" h="100%">
{content}
</SimpleGrid>
) : (
<Stack h="100%" justify="center" gap="xs" p="sm">
{content}
</Stack>
);
}
export function TableDisplay({ data }: { data: CustomWidgetDisplayData }) {
const columns = (data.columns as string[] | undefined) ?? [];
const rows = (data.rows as unknown[][] | undefined) ?? [];
return (
<ScrollArea>
<Table striped={data.striped !== false} highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{columns.map((column, index) => (
<Table.Th key={index} py={data.compact ? 4 : undefined}>
{column}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row, rowIndex) => (
<Table.Tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<Table.Td key={cellIndex} py={data.compact ? 2 : undefined}>
{String(cell ?? "—")}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
);
}
export function StatusIndicatorDisplay({ data }: { data: CustomWidgetDisplayData }) {
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }> | undefined) ?? [];
const size = ({ sm: 8, md: 10, lg: 14 } as Record<string, number>)[String(data.dotSize ?? "md")] ?? 10;
const content = items.map((item, index) => (
<Group key={index} gap="xs" wrap="nowrap">
<div
aria-hidden
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>
));
return data.layout === "grid" ? (
<SimpleGrid cols={2} spacing="xs" p="sm" h="100%">
{content}
</SimpleGrid>
) : (
<Stack h="100%" justify="center" gap="xs" p="sm">
{content}
</Stack>
);
}
function openJsonInBrowser(value: unknown) {
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }));
window.open(url);
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function createRawDisplay(openJsonLabel: string) {
return function RawDisplay({ data }: { data: CustomWidgetDisplayData }) {
return (
<Stack gap={4} p="xs">
<Group justify="flex-end">
<Tooltip label={openJsonLabel}>
<ActionIcon
aria-label={openJsonLabel}
variant="subtle"
size="sm"
onClick={() => openJsonInBrowser(data.data)}
>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea mah={Number(data.maxHeight ?? 300)}>
<Code block style={{ fontSize: 12 }}>
{JSON.stringify(data.data, null, 2)}
</Code>
</ScrollArea>
</Stack>
);
};
}
@@ -0,0 +1,25 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import type { CustomWidgetRuntimeValue } from "./types";
const RuntimeContext = createContext<CustomWidgetRuntimeValue | null>(null);
export function CustomWidgetRuntimeProvider({
children,
...value
}: CustomWidgetRuntimeValue & { children: ReactNode }) {
return <RuntimeContext.Provider value={value}>{children}</RuntimeContext.Provider>;
}
export function useCustomWidgetRuntime(): CustomWidgetRuntimeValue {
const value = useContext(RuntimeContext);
if (!value) throw new Error("Custom Widget runtime components require CustomWidgetRuntimeProvider");
return value;
}
export function useWidgetDefinitionId(): string {
return useCustomWidgetRuntime().definitionId ?? "";
}
@@ -0,0 +1,194 @@
import type { ComponentType, 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 { renderSafeJsx } from "../jsx/interpreter";
import type { CustomJsxRequestCapability } from "./types";
const methodColors: Readonly<Record<string, string>> = {
GET: "blue",
POST: "orange",
PUT: "yellow",
DELETE: "red",
PATCH: "grape",
};
const methods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
const kinds = new Set(["query", "action"]);
const permissions = new Set(["view", "modify", "full"]);
export interface CustomJsxRendererMessages {
noTemplate: string;
interactive: string;
networkCapabilities: string;
templateWarnings(count: number): string;
}
export interface CustomJsxRendererProps {
template: string;
data: unknown;
requestCapabilities: unknown;
components: Readonly<Record<string, ComponentType<never>>>;
createBindings(data: unknown): Readonly<Record<string, unknown>>;
messages: CustomJsxRendererMessages;
}
export const CUSTOM_JSX_METHOD_COLORS = methodColors;
export 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" ||
!kinds.has(record.kind) ||
typeof record.method !== "string" ||
!methods.has(record.method) ||
typeof record.minimumBoardPermission !== "string" ||
!permissions.has(record.minimumBoardPermission)
)
return [];
return [
{
id: record.id,
kind: record.kind,
method: record.method,
minimumBoardPermission: record.minimumBoardPermission,
} as CustomJsxRequestCapability,
];
});
}
class RendererErrorBoundary extends Component<
{ children: ReactNode; onError(error: Error): void },
{ error: Error | null }
> {
public state = { error: null } as { error: Error | null };
public static getDerivedStateFromError(error: Error) {
return { error };
}
public componentDidCatch(error: Error, _info: ErrorInfo) {
this.props.onError(error);
}
public render() {
return this.state.error ? <ErrorAlert error={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>
);
}
export function CustomJsxRenderer({
template,
data,
requestCapabilities: rawCapabilities,
components,
createBindings,
messages,
}: CustomJsxRendererProps) {
const [parseErrors, setParseErrors] = useState<string[]>([]);
const bindings = useMemo(() => createBindings(data), [createBindings, data]);
const capabilities = useMemo(() => parseRequestCapabilities(rawCapabilities), [rawCapabilities]);
const rendered = useMemo(() => {
try {
return { ...renderSafeJsx({ template, components, bindings }), error: null };
} catch (error) {
return { node: null, warnings: [], error: error instanceof Error ? error : new Error(String(error)) };
}
}, [bindings, components, template]);
useEffect(() => setParseErrors([]), [bindings, template]);
const handleError = useCallback(
(error: Error) =>
setParseErrors((current) => {
if (current.length >= 5 || current.includes(error.message)) return current;
return [...current, 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">
{messages.noTemplate}
</Text>
</Alert>
);
return (
<Stack gap={0} h="100%">
{capabilities.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" }}
>
{messages.interactive}
</Badge>
</Popover.Target>
<Popover.Dropdown>
<Stack gap={6}>
<Text size="sm" fw={600}>
{messages.networkCapabilities}
</Text>
{capabilities.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={methodColors[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}>
{rendered.node}
</RendererErrorBoundary>
)}
</Box>
{parseErrors.length > 0 && (
<Alert color="yellow" variant="light" p="xs" mt="xs">
<Text size="xs" c="dimmed">
{messages.templateWarnings(parseErrors.length)}
</Text>
{parseErrors.map((message) => (
<Text key={message} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{message}
</Text>
))}
</Alert>
)}
</Stack>
);
}
@@ -0,0 +1,90 @@
"use client";
import { createContext, useContext } from "react";
import { Badge, Code, Image, Text, Title } from "@mantine/core";
export const SubFetchDataContext = createContext<unknown>(undefined);
const BLOCKED_KEYS = new Set(["constructor", "prototype", "__proto__"]);
export function normalizeParams(value: unknown) {
if (value === undefined) return {};
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const result: Record<string, string | number | boolean> = {};
for (const [key, param] of Object.entries(value)) {
if (BLOCKED_KEYS.has(key)) return null;
if (typeof param !== "string" && typeof param !== "number" && typeof param !== "boolean") return null;
result[key] = param;
}
return result;
}
export function getByPath(value: unknown, path?: string): unknown {
if (!path?.trim()) return value;
return path.split(".").reduce<unknown>((current, key) => {
if (current == null || typeof current !== "object" || BLOCKED_KEYS.has(key)) return undefined;
return Object.hasOwn(current, key) ? (current as Record<string, unknown>)[key] : undefined;
}, value);
}
export function formatDisplayValue(value: unknown): string {
if (value == null) return "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
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 value = getByPath(useContext(SubFetchDataContext), path);
if (value === undefined && path) return <Text 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}>{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;
}
}
@@ -0,0 +1,20 @@
import type { CustomWidgetDisplayAdapters, CustomWidgetDisplayComponent } from "./display-types";
import { createRawDisplay, KeyValueDisplay, StatusIndicatorDisplay, TableDisplay } from "./collection-displays";
import { CountGridDisplay, ProgressBarsDisplay, SingleValueDisplay, StatGridDisplay } from "./metric-displays";
export function createCustomWidgetDisplayComponents(
adapters: CustomWidgetDisplayAdapters,
): Record<string, CustomWidgetDisplayComponent> {
return {
singleValue: SingleValueDisplay,
keyValue: KeyValueDisplay,
table: TableDisplay,
statGrid: StatGridDisplay,
progressBars: ProgressBarsDisplay,
statusIndicator: StatusIndicatorDisplay,
countGrid: CountGridDisplay,
raw: createRawDisplay(adapters.openJsonLabel),
actionButton: adapters.actionButton,
customJsx: adapters.customJsx,
} satisfies Record<string, CustomWidgetDisplayComponent>;
}
@@ -0,0 +1,10 @@
import type { ComponentType } from "react";
export type CustomWidgetDisplayData = Readonly<Record<string, unknown>>;
export type CustomWidgetDisplayComponent = ComponentType<{ data: CustomWidgetDisplayData }>;
export interface CustomWidgetDisplayAdapters {
actionButton: CustomWidgetDisplayComponent;
customJsx: CustomWidgetDisplayComponent;
openJsonLabel: string;
}
@@ -0,0 +1,14 @@
export { ActionButton, ToggleSwitch } from "./actions";
export { CustomWidgetRuntimeProvider, useCustomWidgetRuntime, useWidgetDefinitionId } from "./context";
export { SubData } from "./data";
export { RefreshButton } from "./refresh-button";
export { SubFetch } from "./sub-fetch";
export { createCustomWidgetDisplayComponents } from "./display-registry";
export { CustomJsxRenderer, CUSTOM_JSX_METHOD_COLORS, parseRequestCapabilities } from "./custom-jsx-renderer";
export type { CustomJsxRendererMessages, CustomJsxRendererProps } from "./custom-jsx-renderer";
export type { ActionButtonProps, ToggleSwitchProps } from "./actions";
export type { SubDataProps } from "./data";
export type { RefreshButtonProps } from "./refresh-button";
export type { SubFetchMetadata, SubFetchProps } from "./sub-fetch";
export type * from "./display-types";
export type * from "./types";
@@ -0,0 +1,124 @@
import { Card, Flex, Group, Progress, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import type { CustomWidgetDisplayData } from "./display-types";
export function SingleValueDisplay({ data }: { data: CustomWidgetDisplayData }) {
const size = String(data.valueSize ?? "lg");
const labelAbove = data.labelPosition === "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 ? ` ${String(data.unit)}` : ""}
</Title>
{!labelAbove && label}
</Stack>
);
}
interface MetricItem {
label: string;
unit: string;
color: string;
value: unknown;
}
function StatGridCard({ item, cardStyle }: { item: MetricItem; cardStyle: string }) {
const { ref, height, width } = useElementSize();
const isWide = width > height + 20;
const background = cardStyle === "outline" ? "transparent" : `var(--mantine-color-${item.color}-light)`;
return (
<Card ref={ref} p="sm" radius="md" bg={background} withBorder={cardStyle === "outline"} h="100%">
<Flex h="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>
{height > 38 && (
<Text size="xs" c="dimmed" ta="center" tt="uppercase" lh={1.3}>
{item.label}
</Text>
)}
</Flex>
</Flex>
</Card>
);
}
export function StatGridDisplay({ data }: { data: CustomWidgetDisplayData }) {
const items = (data.items as MetricItem[] | undefined) ?? [];
const columns = Number(data.columns ?? 2);
return (
<SimpleGrid
cols={columns}
spacing="xs"
p="xs"
h="100%"
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
>
{items.map((item, index) => (
<StatGridCard key={index} item={item} cardStyle={String(data.cardStyle ?? "filled")} />
))}
</SimpleGrid>
);
}
export function ProgressBarsDisplay({ data }: { data: CustomWidgetDisplayData }) {
const bars = (data.bars as Array<MetricItem & { value: number; max?: number }> | undefined) ?? [];
const sizes: Record<string, number> = { sm: 8, md: 14, lg: 22 };
return (
<Stack h="100%" justify="center" gap="sm" p="sm">
{bars.map((bar, index) => {
const max = bar.max ?? 100;
const percentage = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
const value =
data.showPercentage !== false ? `${percentage.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`;
return (
<Stack key={index} gap={4}>
<Group justify="space-between" wrap="nowrap">
<Text size="xs" fw={500}>
{bar.label}
</Text>
<Text size="xs" c="dimmed">
{value}
</Text>
</Group>
<Progress
value={percentage}
size={sizes[String(data.barSize ?? "md")] ?? 14}
color={bar.color}
radius="sm"
/>
</Stack>
);
})}
</Stack>
);
}
export function CountGridDisplay({ data }: { data: CustomWidgetDisplayData }) {
const items = (data.items as MetricItem[] | undefined) ?? [];
return (
<SimpleGrid cols={Number(data.columns ?? 2)} spacing="xs" p="sm" h="100%">
{items.map((item, index) => (
<Stack key={index} align="center" justify="center" gap={0}>
<Text size={String(data.valueSize ?? "md")} fw={700}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" ta="center">
{item.label}
</Text>
</Stack>
))}
</SimpleGrid>
);
}
@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import { Button } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
import { useCustomWidgetRuntime } from "./context";
export interface RefreshButtonProps {
label?: string;
color?: string;
variant?: string;
size?: string;
}
export function RefreshButton({ label, color = "blue", variant = "light", size = "sm" }: RefreshButtonProps) {
const runtime = useCustomWidgetRuntime();
const [loading, setLoading] = useState(false);
const refresh = async () => {
if (!runtime.itemId && !runtime.previewSessionId) return;
setLoading(true);
try {
await runtime.port.invalidate({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
targets: ["parent", "*"],
});
} finally {
setLoading(false);
}
};
return (
<Button
color={color}
variant={variant as never}
size={size as never}
leftSection={<IconRefresh size={16} />}
onClick={() => void refresh()}
loading={loading}
disabled={(!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode}
>
{label ?? runtime.messages.refresh}
</Button>
);
}
@@ -0,0 +1,15 @@
"use client";
import { Alert, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useCustomWidgetRuntime } from "./context";
export function MigrationRequiredAlert() {
const { messages } = useCustomWidgetRuntime();
return (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{messages.migrationRequired}</Text>
</Alert>
);
}
@@ -0,0 +1,133 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, Code, Skeleton, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useCustomWidgetRuntime } from "./context";
import { formatDisplayValue, getByPath, normalizeParams, SubFetchDataContext } from "./data";
import { MigrationRequiredAlert } from "./status";
import type { CustomJsxRuntimeParams } from "./types";
export interface SubFetchMetadata {
ok: boolean;
status: number;
statusText?: string;
}
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";
trigger?: "auto" | "manual";
/** @deprecated Inline targets are inert in JSX API v2. */
url?: string;
/** @deprecated Request manifests own the method, body, and headers. */
method?: string;
body?: string;
headers?: string;
}
export function SubFetch(props: SubFetchProps) {
const { itemId, previewSessionId, port, messages } = useCustomWidgetRuntime();
const [manualRun, setManualRun] = useState(false);
const params = useMemo(() => normalizeParams(props.params), [props.params]);
const paramsKey = useMemo(() => JSON.stringify(params), [params]);
useEffect(() => setManualRun(false), [paramsKey, props.requestId, props.trigger]);
const enabled = Boolean(
(itemId || previewSessionId) && props.requestId && params && (props.trigger !== "manual" || manualRun),
);
const refreshMs = normalizeRefreshInterval(props.refreshInterval);
const query = useQuery({
queryKey: ["custom-widget", itemId ?? previewSessionId, props.requestId, paramsKey],
queryFn: ({ signal }) =>
port.query({ itemId, previewSessionId, requestId: props.requestId ?? "", params: params ?? {} }, signal),
enabled,
retry: 2,
refetchInterval: refreshMs,
});
if (!props.requestId) return <MigrationRequiredAlert />;
if (!itemId && !previewSessionId) return props.fallback ?? <Text c="dimmed">{messages.unsavedPreview}</Text>;
if (!params) return <RequestAlert message={messages.invalidParams} />;
if (props.trigger === "manual" && !manualRun) {
return (
<Button size="compact-sm" variant="light" onClick={() => setManualRun(true)}>
{messages.loadRequest}
</Button>
);
}
const result = query.data;
const requestError =
(query.error && !result ? messages.requestFailed : null) ??
(!result?.ok ? (result?.error ?? `${messages.requestFailed} (${result?.status ?? 0})`) : null);
if (!result && query.isPending) return props.fallback ?? <Loading label={props.loadingLabel ?? messages.loading} />;
if (requestError) {
return (
<RequestAlert message={props.errorMessage ?? requestError}>
<Button
size="compact-xs"
variant="subtle"
mt={4}
onClick={() => void query.refetch()}
loading={query.isFetching}
>
{messages.retry}
</Button>
</RequestAlert>
);
}
const content = renderContent(props, result?.data, result);
return <SubFetchDataContext.Provider value={result?.data}>{content}</SubFetchDataContext.Provider>;
}
function normalizeRefreshInterval(value: number | undefined): number | false {
return value && Number.isFinite(value) && value > 0 ? Math.max(1_000, value * 1_000) : false;
}
function renderContent(props: SubFetchProps, data: unknown, result: SubFetchMetadata | undefined) {
if (props.render) {
return props.render(data, {
ok: result?.ok ?? false,
status: result?.status ?? 0,
statusText: result?.statusText,
});
}
if (props.children) return props.children;
const value = getByPath(data, props.path);
if (props.as === "text") return <Text size="sm">{formatDisplayValue(value)}</Text>;
return (
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(value, null, 2)}
</Code>
);
}
function Loading({ label }: { label: string }) {
return (
<div>
<Skeleton height={8} radius="xl" mb="xs" />
<Text c="dimmed">{label}</Text>
</div>
);
}
function RequestAlert({ message, children }: { message: string; children?: ReactNode }) {
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{message}</Text>
{children}
</Alert>
);
}
@@ -0,0 +1,79 @@
import type { ReactNode } from "react";
export type RuntimeParam = string | number | boolean;
export type CustomJsxRuntimeParams = Record<string, RuntimeParam>;
export interface CustomWidgetRequestResult {
ok: boolean;
status: number;
statusText?: string;
data: unknown;
error?: string;
simulated?: boolean;
}
export interface CustomJsxRequestCapability {
id: string;
kind: "query" | "action";
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
minimumBoardPermission: "view" | "modify" | "full";
}
export interface RuntimeRequestInput {
itemId?: string;
previewSessionId?: string;
requestId: string;
params: CustomJsxRuntimeParams;
}
export interface RuntimeActionInput extends RuntimeRequestInput {
confirmed: boolean;
}
export interface RuntimeInvalidationInput {
itemId?: string;
previewSessionId?: string;
targets: readonly string[];
}
export interface RuntimeNotification {
kind: "success" | "error";
title: string;
message: string;
}
export interface CustomWidgetRuntimePort {
query(input: RuntimeRequestInput, signal?: AbortSignal): Promise<CustomWidgetRequestResult>;
executeAction(input: RuntimeActionInput): Promise<CustomWidgetRequestResult>;
invalidate(input: RuntimeInvalidationInput): Promise<void>;
confirm(input: { title: string; message: ReactNode }): Promise<boolean>;
notify(notification: RuntimeNotification): void;
}
export interface CustomWidgetRuntimeMessages {
migrationRequired: string;
unsavedPreview: string;
invalidParams: string;
loadRequest: string;
requestFailed: string;
loading: string;
retry: string;
widgetItemUnavailable: string;
actionsDisabledEditMode: string;
actionSimulated: string;
actionCompleted: string;
confirmDelete: string;
toggle: string;
refresh: string;
}
export interface CustomWidgetRuntimeValue {
itemId?: string;
definitionId?: string;
previewSessionId?: string;
previewLiveActions?: boolean;
isEditMode: boolean;
requestCapabilities: readonly CustomJsxRequestCapability[];
port: CustomWidgetRuntimePort;
messages: CustomWidgetRuntimeMessages;
}
@@ -0,0 +1,45 @@
type HeaderSetter = Pick<Headers, "set">;
export const AUTH_HANDLERS: Record<
string,
(headers: HeaderSetter, url: URL, apiKey: string, headerName?: string) => void
> = {
bearer: (headers, _url, apiKey) => {
headers.set("Authorization", `Bearer ${apiKey}`);
},
apiKeyHeader: (headers, _url, apiKey, headerName) => {
headers.set(headerName ?? "X-API-Key", apiKey);
},
apiKeyQuery: (_headers, url, apiKey, headerName) => {
url.searchParams.set(headerName ?? "api_key", apiKey);
},
};
export function buildSecretMap(secrets: Array<{ kind: string; value: string }>): Record<string, string> {
const map: Record<string, string> = {};
for (const secret of secrets) {
map[secret.kind] = secret.value;
}
return map;
}
export function applyAuth(
headers: HeaderSetter,
url: URL,
authType: string,
secrets: Array<{ kind: string; value: string }>,
headerName?: string | null,
): void {
const secretMap = buildSecretMap(secrets);
if (authType === "basic" && secretMap.username && secretMap.password) {
const credentials = Buffer.from(`${secretMap.username}:${secretMap.password}`).toString("base64");
headers.set("Authorization", `Basic ${credentials}`);
return;
}
const handler = AUTH_HANDLERS[authType];
if (handler && secretMap.apiKey) {
handler(headers, url, secretMap.apiKey, headerName ?? undefined);
}
}
@@ -0,0 +1,25 @@
export type CustomWidgetDomainErrorCode =
| "BAD_GATEWAY"
| "BAD_REQUEST"
| "FORBIDDEN"
| "INTERNAL_SERVER_ERROR"
| "NOT_FOUND"
| "PAYLOAD_TOO_LARGE"
| "TOO_MANY_REQUESTS";
export class CustomWidgetDomainError extends Error {
public readonly code: CustomWidgetDomainErrorCode;
public readonly retryAfterMs?: number;
public constructor(input: {
code: CustomWidgetDomainErrorCode;
message: string;
cause?: unknown;
retryAfterMs?: number;
}) {
super(input.message, { cause: input.cause });
this.name = "CustomWidgetDomainError";
this.code = input.code;
this.retryAfterMs = input.retryAfterMs;
}
}
@@ -0,0 +1,28 @@
export { CustomWidgetDomainError } from "./errors";
export type { CustomWidgetDomainErrorCode } from "./errors";
export {
assertJsonBudget,
assertSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest,
MAX_REQUEST_BODY_BYTES,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
MAX_RESPONSE_JSON_NODES,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "./request-executor";
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-executor";
export { CustomWidgetRequestLimiter } from "./request-limits";
export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits";
export { CustomWidgetPreviewSessionService } from "./preview-sessions";
export type {
CreatePreviewSessionInput,
CustomWidgetPreviewJournalEntry,
CustomWidgetPreviewSession,
PreviewSessionServiceOptions,
PreviewSessionStore,
} from "./preview-sessions";
export { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRuntimeParams } from "./request-manifest";
export type { CustomJsxRuntimeParams } from "./request-manifest";
@@ -0,0 +1,168 @@
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
import type { LookupFunction } from "node:net";
import { Agent } from "undici";
import type { CustomJsxNetworkScope } from "../core";
import { CustomWidgetDomainError } from "./errors";
import { MAX_RESPONSE_BODY_BYTES } from "./response";
const RESERVED_HEADERS = new Set([
"authorization",
"connection",
"content-length",
"cookie",
"expect",
"forwarded",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"via",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
]);
const blocked = new BlockList();
for (const [address, prefix] of [
["0.0.0.0", 8],
["100.64.0.0", 10],
["169.254.0.0", 16],
["192.0.0.0", 24],
["192.0.2.0", 24],
["198.18.0.0", 15],
["198.51.100.0", 24],
["203.0.113.0", 24],
["224.0.0.0", 4],
["240.0.0.0", 4],
] as const)
blocked.addSubnet(address, prefix, "ipv4");
blocked.addAddress("::", "ipv6");
for (const [address, prefix] of [
["64:ff9b::", 96],
["100::", 64],
["2001:db8::", 32],
["fe80::", 10],
["ff00::", 8],
] as const) {
blocked.addSubnet(address, prefix, "ipv6");
}
blocked.addAddress("fd00:ec2::254", "ipv6");
const privateAddresses = new BlockList();
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
privateAddresses.addSubnet("fc00::", 7, "ipv6");
const loopbackAddresses = new BlockList();
loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
loopbackAddresses.addAddress("::1", "ipv6");
type AddressFamily = 4 | 6;
export type ResolvedAddress = { address: string; family: AddressFamily };
type AddressClass = "public" | "private" | "loopback" | "blocked";
const normalizeHostname = (value: string) =>
value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
const familyName = (family: AddressFamily) => (family === 4 ? "ipv4" : "ipv6");
export function classifyAddress(address: string): AddressClass {
const normalized = normalizeHostname(address).toLowerCase();
const family = isIP(normalized) as AddressFamily | 0;
if (!family || normalized.includes("::ffff:") || blocked.check(normalized, familyName(family))) return "blocked";
if (loopbackAddresses.check(normalized, familyName(family))) return "loopback";
if (privateAddresses.check(normalized, familyName(family))) return "private";
return "public";
}
export async function resolveAndValidateHost(
hostname: string,
scope: CustomJsxNetworkScope,
): Promise<ResolvedAddress[]> {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized) as AddressFamily | 0;
const addresses = family
? [{ address: normalized, family }]
: ((await lookup(normalized, { all: true, verbatim: true })) as ResolvedAddress[]);
if (!addresses.length)
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
for (const address of addresses) {
const classification = classifyAddress(address.address);
const allowed =
classification === "public" ||
(classification === "private" && scope !== "public") ||
(classification === "loopback" && scope === "loopback");
if (!allowed)
throw new CustomWidgetDomainError({
code: "FORBIDDEN",
message: `Target address is not allowed by the ${scope} network scope`,
});
}
return addresses;
}
export function validateCustomWidgetUrl(value: string | URL): URL {
let url: URL;
try {
url = new URL(value);
} catch {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Invalid custom widget URL" });
}
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Only HTTP and HTTPS URLs are allowed" });
if (url.username || url.password)
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "URL credentials are not allowed" });
if (url.hash) throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "URL fragments are not allowed" });
return url;
}
export function resolveSameOriginTarget(baseValue: string, targetValue?: string | URL): URL {
const base = validateCustomWidgetUrl(baseValue);
const target = validateCustomWidgetUrl(targetValue ?? base);
if (target.origin !== base.origin)
throw new CustomWidgetDomainError({
code: "FORBIDDEN",
message: "Named requests must stay on the widget's origin",
});
return target;
}
export function assertSafeStaticHeaders(headers: Record<string, string> | undefined): void {
for (const name of Object.keys(headers ?? {})) {
const value = name.trim().toLowerCase();
if (
RESERVED_HEADERS.has(value) ||
value.startsWith("proxy-") ||
value.startsWith("sec-") ||
value.startsWith("x-forwarded-")
) {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Header '${name}' is reserved` });
}
}
}
export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) {
const customLookup: LookupFunction = (_hostname, options, callback) => {
const family = options.family === 4 || options.family === 6 ? options.family : undefined;
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses;
const selected = candidates[0];
if (!selected) {
const error = new Error("No validated address for the requested family") as NodeJS.ErrnoException;
error.code = "ENOTFOUND";
callback(error, "", 0);
} else if (options.all) callback(null, candidates);
else callback(null, selected.address, selected.family);
};
return new Agent({
connect: { lookup: customLookup },
connectTimeout: timeoutMs,
headersTimeout: timeoutMs,
bodyTimeout: timeoutMs,
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
});
}
@@ -0,0 +1,154 @@
import { z } from "zod/v4";
import {
customJsxNetworkScopes,
customJsxRequestSchema,
customWidgetAuthTypes,
customWidgetSecretKinds,
} from "../core";
import type { CustomJsxNetworkScope, CustomJsxRequest, CustomWidgetAuthType } from "../core";
import { CustomWidgetDomainError } from "./errors";
const SESSION_TTL_MS = 5 * 60_000;
const MAX_JOURNAL_ENTRIES = 50;
const sessionSchema = z.object({
id: z.string(),
userId: z.string(),
expiresAt: z.number(),
baseUrl: z.string(),
authType: z.enum(customWidgetAuthTypes),
headerName: z.string().nullable(),
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })),
networkScope: z.enum(customJsxNetworkScopes),
requests: z.array(customJsxRequestSchema),
definitionId: z.string().optional(),
liveActions: z.boolean(),
});
export type CustomWidgetPreviewSession = z.infer<typeof sessionSchema>;
const journalEntrySchema = z.object({
id: z.string(),
requestId: z.string(),
kind: z.enum(["query", "action"]),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
pathTemplate: z.string(),
status: z.number().nullable(),
durationMs: z.number().nonnegative(),
simulated: z.boolean(),
timestamp: z.number(),
});
export type CustomWidgetPreviewJournalEntry = z.infer<typeof journalEntrySchema>;
export interface CreatePreviewSessionInput {
userId: string;
baseUrl: string;
authType: CustomWidgetAuthType;
headerName?: string;
secrets: Array<{ kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
networkScope: CustomJsxNetworkScope;
requests: CustomJsxRequest[];
definitionId?: string;
}
export interface PreviewSessionStore {
saveSession(id: string, value: unknown, ttlMs: number): Promise<void>;
getSession(id: string): Promise<unknown>;
deleteSession(id: string): Promise<void>;
appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number): Promise<void>;
getJournal(id: string, maxEntries: number): Promise<unknown[]>;
}
export interface PreviewSessionServiceOptions {
createId(): string;
encrypt(value: string): string;
decrypt(value: string): string;
now?: () => number;
store?: PreviewSessionStore;
}
export class CustomWidgetPreviewSessionService {
private readonly sessions = new Map<string, CustomWidgetPreviewSession>();
private readonly journals = new Map<string, CustomWidgetPreviewJournalEntry[]>();
private readonly now: () => number;
public constructor(private readonly options: PreviewSessionServiceOptions) {
this.now = options.now ?? Date.now;
}
public async create(input: CreatePreviewSessionInput) {
const session: CustomWidgetPreviewSession = {
id: this.options.createId(),
userId: input.userId,
expiresAt: this.now() + SESSION_TTL_MS,
baseUrl: input.baseUrl,
authType: input.authType,
headerName: input.headerName ?? null,
secrets: input.secrets.map((secret) => ({ kind: secret.kind, value: this.options.encrypt(secret.value) })),
networkScope: input.networkScope,
requests: input.requests,
definitionId: input.definitionId,
liveActions: false,
};
await this.save(session);
return { id: session.id, expiresAt: session.expiresAt, liveActions: false as const };
}
public async get(id: string, userId: string): Promise<CustomWidgetPreviewSession> {
const candidate = this.options.store ? await this.options.store.getSession(id) : this.sessions.get(id);
const parsed = sessionSchema.safeParse(parseStoredValue(candidate));
if (!parsed.success || parsed.data.userId !== userId || parsed.data.expiresAt <= this.now()) {
if (this.options.store) await this.options.store.deleteSession(id);
else this.sessions.delete(id);
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
}
return parsed.data;
}
public async setLiveActions(id: string, userId: string, enabled: boolean) {
const session = { ...(await this.get(id, userId)), liveActions: enabled };
await this.save(session);
return { id, expiresAt: session.expiresAt, liveActions: enabled };
}
public async appendJournal(
session: CustomWidgetPreviewSession,
input: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
): Promise<void> {
const value = journalEntrySchema.parse({ ...input, id: this.options.createId(), timestamp: this.now() });
const ttlMs = Math.max(1, session.expiresAt - this.now());
if (this.options.store) await this.options.store.appendJournal(session.id, value, MAX_JOURNAL_ENTRIES, ttlMs);
else this.journals.set(session.id, [value, ...(this.journals.get(session.id) ?? [])].slice(0, MAX_JOURNAL_ENTRIES));
}
public async getJournal(id: string, userId: string): Promise<CustomWidgetPreviewJournalEntry[]> {
await this.get(id, userId);
const entries = this.options.store
? await this.options.store.getJournal(id, MAX_JOURNAL_ENTRIES)
: (this.journals.get(id) ?? []);
return entries.flatMap((entry) => {
const parsed = journalEntrySchema.safeParse(parseStoredValue(entry));
return parsed.success ? [parsed.data] : [];
});
}
public getSecrets(session: CustomWidgetPreviewSession) {
return session.secrets.map((secret) => ({ kind: secret.kind, value: this.options.decrypt(secret.value) }));
}
private async save(session: CustomWidgetPreviewSession): Promise<void> {
const ttlMs = Math.max(0, session.expiresAt - this.now());
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
if (this.options.store) await this.options.store.saveSession(session.id, session, ttlMs);
else this.sessions.set(session.id, session);
}
}
function parseStoredValue(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value) as unknown;
} catch {
return null;
}
}
@@ -0,0 +1,179 @@
import { Buffer } from "node:buffer";
import { fetch, Headers } from "undici";
import type { Response } from "undici";
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
import { applyAuth } from "./auth";
import { CustomWidgetDomainError } from "./errors";
import {
assertSafeStaticHeaders,
createPinnedAgent,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "./network-policy";
import { parseResponseBody } from "./response";
export {
assertSafeStaticHeaders,
classifyAddress,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "./network-policy";
export {
assertJsonBudget,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
MAX_RESPONSE_JSON_NODES,
} from "./response";
export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_QUERY_REDIRECTS = 3;
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
export interface CustomWidgetAuthConfig {
type: string;
secrets: Array<{ kind: string; value: string }>;
headerName?: string | null;
}
export interface CustomWidgetHttpRequest {
baseUrl: string;
targetUrl?: string | URL;
method: CustomWidgetMethod;
body?: string;
staticHeaders?: Record<string, string>;
auth?: CustomWidgetAuthConfig;
networkScope: CustomJsxNetworkScope;
kind: "query" | "action";
cacheKey?: string;
cacheTtlSeconds?: number;
logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string }) => void;
}
export interface CustomWidgetHttpResponse {
ok: boolean;
status: number;
statusText: string;
data: unknown;
}
const cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
assertRequest(input);
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
for (let redirects = 0; ; redirects += 1) {
const dispatcher = createPinnedAgent(
await resolveAndValidateHost(currentUrl.hostname, input.networkScope),
REQUEST_TIMEOUT_MS,
);
const headers = buildHeaders(input, currentUrl);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(currentUrl, {
method: input.method,
headers,
body: input.method === "GET" ? undefined : input.body,
redirect: "manual",
signal: controller.signal,
dispatcher,
});
} catch (error) {
await dispatcher.close();
if (error instanceof CustomWidgetDomainError) throw error;
input.logError?.({
origin: currentUrl.origin,
method: input.method,
errorName: error instanceof Error ? error.name : "UnknownError",
});
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: error instanceof Error ? error.message : "External request failed",
cause: error,
});
} finally {
clearTimeout(timeout);
}
if (![301, 302, 303, 307, 308].includes(response.status)) {
try {
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: await parseResponseBody(response),
};
} finally {
await dispatcher.close();
}
}
await response.body?.cancel();
await dispatcher.close();
if (redirects >= maxRedirects)
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
const location = response.headers.get("location");
if (!location)
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect is missing a location" });
const redirected = validateCustomWidgetUrl(new URL(location, currentUrl));
if (redirected.origin !== baseUrl.origin)
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
currentUrl = redirected;
}
}
function assertRequest(input: CustomWidgetHttpRequest): void {
if (input.kind === "query" && input.method !== "GET")
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Queries must use GET" });
if (input.kind === "action" && input.method === "GET")
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Actions cannot use GET" });
if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES)
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" });
assertSafeStaticHeaders(input.staticHeaders);
}
function buildHeaders(input: CustomWidgetHttpRequest, url: URL): Headers {
const headers = new Headers({ Accept: "application/json" });
for (const [name, value] of Object.entries(input.staticHeaders ?? {})) headers.set(name, value);
if (input.body !== undefined) headers.set("Content-Type", "application/json");
if (input.auth) {
if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
applyAuth(headers, url, input.auth.type, input.auth.secrets, input.auth.headerName);
}
return headers;
}
export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
const key = input.method === "GET" ? input.cacheKey : undefined;
if (!key) return performRequest(input);
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.response;
if (cached) cache.delete(key);
const pending = inFlight.get(key);
if (pending) return pending;
const request = performRequest(input)
.then((response) => {
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0) {
pruneCache();
cache.set(key, { expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000, response });
}
return response;
})
.finally(() => inFlight.delete(key));
inFlight.set(key, request);
return request;
}
function pruneCache(): void {
for (const [key, entry] of cache) if (entry.expiresAt <= Date.now()) cache.delete(key);
while (cache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
const key = cache.keys().next().value as string | undefined;
if (!key) return;
cache.delete(key);
}
}
@@ -0,0 +1,113 @@
import { createHash } from "node:crypto";
import { CustomWidgetDomainError } from "./errors";
const WINDOW_MS = 60_000;
const CONCURRENCY_TTL_MS = 30_000;
const CATEGORY_LIMITS = { query: 60, action: 10, delete: 3 } as const;
type RequestCategory = keyof typeof CATEGORY_LIMITS;
export interface RequestLimitInput {
category: RequestCategory;
userId?: string;
itemId: string;
definitionId: string;
}
export interface RequestLimitStore {
incrementRate(key: string, windowMs: number): Promise<{ count: number; retryAfterMs: number }>;
acquireConcurrency(key: string, limit: number, ttlMs: number): Promise<boolean>;
releaseConcurrency(key: string): Promise<void>;
}
export interface RequestLimiterOptions {
store?: RequestLimitStore;
onStoreError?: (error: unknown) => void;
}
export class CustomWidgetRequestLimiter {
private readonly rateBuckets = new Map<string, number[]>();
private readonly concurrency = new Map<string, number>();
public constructor(private readonly options: RequestLimiterOptions = {}) {}
public async acquire(input: RequestLimitInput): Promise<() => Promise<void>> {
const identity = input.userId ?? "anonymous";
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
const userKey = `custom-widget:concurrency:user-item:${hashKey(identity, input.itemId)}`;
const definitionKey = `custom-widget:concurrency:definition:${hashKey(input.definitionId)}`;
if (!this.options.store) return this.acquireLocal(rateKey, userKey, definitionKey, input.category);
try {
return await this.acquireStored(rateKey, userKey, definitionKey, input.category, this.options.store);
} catch (error) {
if (error instanceof CustomWidgetDomainError) throw error;
this.options.onStoreError?.(error);
throw new CustomWidgetDomainError({
code: "INTERNAL_SERVER_ERROR",
message: "Request limiter is unavailable",
cause: error,
});
}
}
private acquireLocal(rateKey: string, userKey: string, definitionKey: string, category: RequestCategory) {
const now = Date.now();
const recent = (this.rateBuckets.get(rateKey) ?? []).filter((timestamp) => now - timestamp < WINDOW_MS);
if (recent.length >= CATEGORY_LIMITS[category]) throwLimit(WINDOW_MS - (now - (recent[0] ?? now)));
recent.push(now);
this.rateBuckets.set(rateKey, recent);
this.acquireLocalConcurrency(userKey, 4);
try {
this.acquireLocalConcurrency(definitionKey, 8);
} catch (error) {
this.releaseLocalConcurrency(userKey);
throw error;
}
return async () => {
this.releaseLocalConcurrency(userKey);
this.releaseLocalConcurrency(definitionKey);
};
}
private async acquireStored(
rateKey: string,
userKey: string,
definitionKey: string,
category: RequestCategory,
store: RequestLimitStore,
) {
const rate = await store.incrementRate(rateKey, WINDOW_MS);
if (rate.count > CATEGORY_LIMITS[category]) throwLimit(rate.retryAfterMs);
if (!(await store.acquireConcurrency(userKey, 4, CONCURRENCY_TTL_MS))) throwLimit(1_000);
try {
if (!(await store.acquireConcurrency(definitionKey, 8, CONCURRENCY_TTL_MS))) throwLimit(1_000);
} catch (error) {
await store.releaseConcurrency(userKey);
throw error;
}
return async () => {
await Promise.all([store.releaseConcurrency(userKey), store.releaseConcurrency(definitionKey)]);
};
}
private acquireLocalConcurrency(key: string, limit: number): void {
const count = this.concurrency.get(key) ?? 0;
if (count >= limit) throwLimit(1_000);
this.concurrency.set(key, count + 1);
}
private releaseLocalConcurrency(key: string): void {
const count = this.concurrency.get(key) ?? 0;
if (count <= 1) this.concurrency.delete(key);
else this.concurrency.set(key, count - 1);
}
}
const hashKey = (...parts: string[]) => createHash("sha256").update(parts.join("\0")).digest("hex");
const throwLimit = (retryAfterMs: number): never => {
throw new CustomWidgetDomainError({
code: "TOO_MANY_REQUESTS",
message: "Custom widget request limit exceeded",
retryAfterMs: Math.max(1, retryAfterMs),
});
};
@@ -0,0 +1,79 @@
import { createHash } from "node:crypto";
import { CustomWidgetDomainError } from "./errors";
import type { CustomJsxRequest } from "../core";
import { resolveSameOriginTarget } from "./request-executor";
export type CustomJsxRuntimeParams = Record<string, string | number | boolean>;
export const validateRuntimeParams = (request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
const declaredNames = Object.keys(request.parameters).toSorted();
const suppliedNames = Object.keys(params).toSorted();
if (
declaredNames.length !== suppliedNames.length ||
declaredNames.some((name, index) => name !== suppliedNames[index])
) {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Request parameters do not match the manifest" });
}
for (const [name, expectedType] of Object.entries(request.parameters)) {
if (typeof params[name] !== expectedType) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: `Request parameter '${name}' must be ${expectedType}`,
});
}
}
};
export const renderRequestBody = (
template: CustomJsxRequest["bodyTemplate"],
params: CustomJsxRuntimeParams,
): string | undefined => {
if (template === undefined) return undefined;
const substitute = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(substitute);
if (value !== null && typeof value === "object") {
const entries = Object.entries(value);
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
const parameter = params[entries[0][1]];
if (parameter === undefined) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: `Unknown body parameter '${entries[0][1]}'`,
});
}
return parameter;
}
return Object.fromEntries(entries.map(([key, child]) => [key, substitute(child)]));
}
return value;
};
return JSON.stringify(substitute(template));
};
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
validateRuntimeParams(request, params);
const path = request.pathTemplate.replaceAll(/\{([a-z][a-z0-9_-]*)\}/g, (_placeholder, name: string) => {
const value = params[name];
if (value === undefined) {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Unknown path parameter '${name}'` });
}
return encodeURIComponent(String(value));
});
if (/[{}]/.test(path)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Path template contains an invalid placeholder",
});
}
return resolveSameOriginTarget(baseUrl, new URL(path, baseUrl));
};
export const hashRuntimeParams = (params: CustomJsxRuntimeParams) => {
const stableParams = Object.fromEntries(
Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)),
);
return createHash("sha256").update(JSON.stringify(stableParams)).digest("hex");
};
@@ -0,0 +1,65 @@
import type { Response } from "undici";
import { CustomWidgetDomainError } from "./errors";
export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
export const MAX_RESPONSE_JSON_DEPTH = 32;
export const MAX_RESPONSE_JSON_NODES = 50_000;
export function assertJsonBudget(value: unknown): void {
let nodes = 0;
const visit = (current: unknown, depth: number): void => {
if (++nodes > MAX_RESPONSE_JSON_NODES)
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too large" });
if (depth > MAX_RESPONSE_JSON_DEPTH)
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too deeply nested" });
if (Array.isArray(current)) current.forEach((entry) => visit(entry, depth + 1));
else if (current !== null && typeof current === "object")
Object.values(current).forEach((entry) => visit(entry, depth + 1));
};
visit(value, 0);
}
async function readLimitedBody(response: Response): Promise<string> {
const length = Number(response.headers.get("content-length"));
if (Number.isFinite(length) && length > MAX_RESPONSE_BODY_BYTES) throw tooLarge();
if (!response.body) return "";
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
size += chunk.value.byteLength;
if (size > MAX_RESPONSE_BODY_BYTES) {
await reader.cancel();
throw tooLarge();
}
chunks.push(chunk.value);
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
export async function parseResponseBody(response: Response): Promise<unknown> {
const text = await readLimitedBody(response);
if (!text) return null;
try {
const json = JSON.parse(text) as unknown;
assertJsonBudget(json);
return json;
} catch (error) {
if (error instanceof CustomWidgetDomainError) throw error;
if (response.headers.get("content-type")?.toLowerCase().includes("json")) {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
}
return text;
}
}
const tooLarge = () =>
new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { buildCustomWidgetAiPrompt } from "../core/ai-prompt";
describe("buildCustomWidgetAiPrompt", () => {
test("describes the complete v2 named-request and render-child workflow", () => {
const prompt = buildCustomWidgetAiPrompt({ type: "object" }, '{"serviceId":"alpha"}');
expect(prompt).toContain('"$schema": "homarr-custom-widget-v3"');
expect(prompt).toContain("jsxApiVersion: 2");
expect(prompt).toContain("SubFetch can reference query requests only");
expect(prompt).toContain("Prefer the SubFetch function-as-children API");
expect(prompt).toContain('<SubFetch requestId="service-detail"');
expect(prompt).toContain("ActionButton and ToggleSwitch can reference action requests only");
expect(prompt).toContain("## Generated component reference");
expect(prompt).toContain("## Tested examples");
expect(prompt).toContain('{"serviceId":"alpha"}');
});
test("keeps an existing multiline template readable and outside JSON escaping", () => {
const template = '<Stack gap="sm">\n <Text>{data.name}</Text>\n</Stack>';
const prompt = buildCustomWidgetAiPrompt({ type: "object" }, null, {
name: "Existing",
displayConfig: { type: "customJsx", jsxApiVersion: 2, template, requests: [] },
});
expect(prompt).toContain('"template": "__HOMARR_TEMPLATE__"');
expect(prompt).toContain(`\`\`\`jsx\n${template}\n\`\`\``);
expect(prompt).not.toContain(template.replaceAll("\n", "\\n"));
expect(prompt).toContain("PASTE_API_RESPONSE_HERE");
});
});
@@ -0,0 +1,229 @@
import { describe, expect, test } from "vitest";
import { customJsxComponentByName, customJsxComponentRegistry } from "../core/component-registry";
const mantineCore941ComponentExports = [
"RemoveScroll",
"Collapse",
"ScrollArea",
"UnstyledButton",
"VisuallyHidden",
"Paper",
"Popover",
"ActionIcon",
"CloseButton",
"Group",
"Loader",
"Overlay",
"ModalBase",
"Input",
"InputBase",
"Flex",
"FloatingIndicator",
"Accordion",
"Affix",
"Alert",
"Anchor",
"AngleSlider",
"AppShell",
"AspectRatio",
"Autocomplete",
"Avatar",
"BackgroundImage",
"Badge",
"Blockquote",
"Breadcrumbs",
"Burger",
"Button",
"Card",
"Center",
"Checkbox",
"Chip",
"Code",
"ColorPicker",
"ColorInput",
"ColorSwatch",
"Combobox",
"ComboboxPopover",
"Container",
"CopyButton",
"DataList",
"Dialog",
"Divider",
"Drawer",
"EmptyState",
"Fieldset",
"FileButton",
"FileInput",
"FloatingWindow",
"FocusTrap",
"Grid",
"Highlight",
"HoverCard",
"Image",
"Indicator",
"JsonInput",
"Kbd",
"List",
"LoadingOverlay",
"Mark",
"Marquee",
"MaskInput",
"Menu",
"Menubar",
"Modal",
"MultiSelect",
"NativeSelect",
"NavLink",
"Notification",
"NumberFormatter",
"NumberInput",
"OverflowList",
"Pagination",
"PasswordInput",
"Pill",
"PillsInput",
"PinInput",
"Portal",
"Progress",
"Radio",
"Rating",
"RollingNumber",
"RingProgress",
"Scroller",
"SegmentedControl",
"Select",
"SemiCircleProgress",
"SimpleGrid",
"Skeleton",
"Slider",
"Space",
"Splitter",
"Spoiler",
"Stack",
"Stepper",
"Switch",
"Table",
"TableOfContents",
"Tabs",
"TagsInput",
"Text",
"Textarea",
"TextInput",
"ThemeIcon",
"Timeline",
"Title",
"Tooltip",
"Transition",
"Tree",
"TreeSelect",
"Typography",
] as const;
const mantineCharts941ComponentExports = [
"ChartTooltip",
"ChartLegend",
"AreaChart",
"BarChart",
"LineChart",
"Sparkline",
"DonutChart",
"PieChart",
"RadarChart",
"ScatterChart",
"BubbleChart",
"CompositeChart",
"RadialBarChart",
"FunnelChart",
"Heatmap",
"BarsList",
"Treemap",
"SankeyChart",
] as const;
const mantineDates941ComponentExports = [
"DatesProvider",
"HiddenDatesInput",
"TimeInput",
"TimePicker",
"TimeValue",
"Day",
"WeekdaysRow",
"Month",
"PickerControl",
"YearsList",
"MonthsList",
"CalendarHeader",
"DecadeLevel",
"YearLevel",
"MonthLevel",
"LevelsGroup",
"DecadeLevelGroup",
"YearLevelGroup",
"MonthLevelGroup",
"PickerInputBase",
"Calendar",
"YearPicker",
"MonthPicker",
"DatePicker",
"DateInput",
"DateTimePicker",
"InlineDateTimePicker",
"YearPickerInput",
"MonthPickerInput",
"DatePickerInput",
"TimeGrid",
"MiniCalendar",
] as const;
describe("customJsxComponentRegistry", () => {
test("classifies every component name exactly once", () => {
const names = customJsxComponentRegistry.map(({ name }) => name);
expect(new Set(names).size).toBe(names.length);
});
test.each([
["ChartTooltip", "allowed"],
["ChartLegend", "allowed"],
["VisuallyHidden", "allowed"],
["SubFetch", "wrapped"],
["Portal", "denied"],
["TextInput", "denied"],
] as const)("classifies %s as %s", (name, safety) => {
expect(customJsxComponentByName.get(name)?.safety).toBe(safety);
});
test("documents why every denied component is unavailable", () => {
for (const component of customJsxComponentRegistry) {
if (component.safety === "denied") {
expect(component.reason, component.name).toBeTruthy();
}
}
});
test("publishes complete authoring metadata for every component", () => {
for (const component of customJsxComponentRegistry) {
expect(component.documentationUrl, component.name).toMatch(/^https:\/\//);
expect(component.supportedProps, component.name).toBeInstanceOf(Array);
expect(component.subcomponents, component.name).toBeInstanceOf(Array);
expect(component.accessibilityRequirements, component.name).toBeInstanceOf(Array);
}
});
test.each([
["Tabs.List", "grow"],
["ScrollArea", "offsetScrollbars"],
["SubData", "fit"],
["SubData", "alt"],
] as const)("supports the %s.%s authoring prop", (component, prop) => {
expect(customJsxComponentByName.get(component)?.supportedProps).toContain(prop);
});
test.each([
...mantineCore941ComponentExports,
...mantineCharts941ComponentExports,
...mantineDates941ComponentExports,
])("classifies the Mantine 9.4.1 export %s", (name) => {
expect(customJsxComponentByName.has(name), `${name} is not classified`).toBe(true);
});
});
@@ -0,0 +1,135 @@
import { describe, expect, test } from "vitest";
import { extractDisplayData } from "../core/display-data";
import type { DisplayConfig } from "../core/schema";
const response = {
value: 42,
status: "online",
total: 100,
rows: [
{ name: "Alpha", count: 2 },
{ name: "Beta", count: 3 },
],
};
const extract = (config: DisplayConfig) => extractDisplayData(response, config.type, config);
describe("display data extraction", () => {
test("extracts a single value", () => {
expect(extract({ type: "singleValue", jsonPath: "$.value", label: "Value", unit: "%" })).toMatchObject({
type: "singleValue",
value: 42,
valueSize: "lg",
labelPosition: "below",
});
});
test("extracts key-value mappings", () => {
expect(
extract({
type: "keyValue",
mappings: [
{ label: "Value", jsonPath: "$.value", unit: "%" },
{ label: "Status", jsonPath: "$.status", unit: "" },
],
}),
).toMatchObject({ entries: [{ value: 42 }, { value: "online" }], layout: "list", columns: 2 });
});
test("extracts table rows", () => {
expect(
extract({
type: "table",
tablePath: "$.rows",
columns: [
{ header: "Name", jsonPath: "$.name" },
{ header: "Count", jsonPath: "$.count" },
],
}),
).toMatchObject({
columns: ["Name", "Count"],
rows: [
["Alpha", 2],
["Beta", 3],
],
});
});
test("extracts stat cards", () => {
expect(
extract({ type: "statGrid", items: [{ label: "Value", jsonPath: "$.value", unit: "", color: "red" }] }),
).toMatchObject({ items: [{ value: 42, color: "red" }], columns: 2, cardStyle: "filled" });
});
test("extracts progress values and maxima", () => {
expect(
extract({
type: "progressBars",
bars: [{ label: "Usage", valuePath: "$.value", maxPath: "$.total", unit: "%" }],
}),
).toMatchObject({ bars: [{ value: 42, max: 100, color: "blue" }], showPercentage: true, barSize: "md" });
});
test("classifies status values", () => {
expect(
extract({
type: "statusIndicator",
items: [{ label: "Service", jsonPath: "$.status", goodValues: ["ONLINE"] }],
}),
).toMatchObject({ items: [{ value: "online", isGood: true }], layout: "list", dotSize: "md" });
});
test("extracts count grids", () => {
expect(extract({ type: "countGrid", items: [{ label: "Count", jsonPath: "$.value", unit: "" }] })).toMatchObject({
items: [{ value: 42 }],
columns: 2,
valueSize: "md",
});
});
test("extracts raw paths", () => {
expect(extract({ type: "raw", jsonPath: "$.rows", maxHeight: 200 })).toEqual({
type: "raw",
data: response.rows,
maxHeight: 200,
});
});
test("normalizes action button defaults", () => {
expect(extract({ type: "actionButton", buttonLabel: "Run" })).toEqual({
type: "actionButton",
buttonLabel: "Run",
buttonColor: "blue",
confirmText: "",
successMessage: "",
});
});
test("passes Custom JSX data and capabilities through", () => {
expect(
extract({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text>{data.value}</Text>",
networkScope: "public",
requests: [
{
id: "detail",
kind: "query",
method: "GET",
pathTemplate: "/detail",
parameters: {},
auth: "none",
minimumBoardPermission: "view",
},
],
}),
).toMatchObject({
type: "customJsx",
data: response,
jsxApiVersion: 2,
requestCapabilities: [{ id: "detail", kind: "query", method: "GET", minimumBoardPermission: "view" }],
});
});
});
@@ -0,0 +1,65 @@
import { describe, expect, test } from "vitest";
import { buildDisplayConfigFromFormValues, buildDisplayFormValues, CUSTOM_JSX_STARTER } from "../core/display-form";
import { customWidgetDisplayTypes, displayConfigSchema } from "../core/schema";
const requiredFormValues = (displayType: string) => {
const values = buildDisplayFormValues(displayType, {});
values.label = "Value";
values.mappings = [{ label: "CPU", jsonPath: "$.cpu", unit: "%" }];
values.columns = [{ header: "Name", jsonPath: "$.name" }];
values.statGridItems = [{ label: "CPU", jsonPath: "$.cpu", unit: "%", color: "blue" }];
values.progressBars = [{ label: "Disk", valuePath: "$.used", maxPath: "$.total", unit: "GB", color: "blue" }];
values.statusItems = [{ label: "Service", jsonPath: "$.status", goodValues: "online, true" }];
values.countGridItems = [{ label: "Users", jsonPath: "$.users", unit: "" }];
values.buttonLabel = "Execute";
values.template = CUSTOM_JSX_STARTER;
return values;
};
describe("custom-widget display form contracts", () => {
test.each(customWidgetDisplayTypes)("round-trips the %s display through one canonical transform", (displayType) => {
const initialForm = requiredFormValues(displayType);
const config = buildDisplayConfigFromFormValues(initialForm);
const parsed = displayConfigSchema.parse(config);
const hydratedForm = buildDisplayFormValues(displayType, parsed);
const rebuilt = buildDisplayConfigFromFormValues(hydratedForm);
expect(rebuilt).toEqual(parsed);
});
test("preserves display-only v1 Custom JSX without adding network capabilities", () => {
const values = requiredFormValues("customJsx");
values.jsxApiVersion = "1";
values.requestManifest = JSON.stringify([
{ id: "unsafe", kind: "action", method: "POST", pathTemplate: "/action" },
]);
expect(buildDisplayConfigFromFormValues(values)).toEqual({
type: "customJsx",
template: CUSTOM_JSX_STARTER,
});
});
test("validates the v2 request manifest while serializing form state", () => {
const values = requiredFormValues("customJsx");
values.jsxApiVersion = "2";
values.requestManifest = JSON.stringify([
{
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status/{id}",
parameters: { id: "string" },
auth: "none",
minimumBoardPermission: "view",
},
]);
expect(buildDisplayConfigFromFormValues(values)).toMatchObject({
type: "customJsx",
jsxApiVersion: 2,
requests: [{ id: "status", kind: "query", method: "GET" }],
});
});
});
@@ -0,0 +1,23 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
import { customJsxComponentRegistry } from "../core";
describe("generated Custom JSX documentation", () => {
test("matches the canonical component registry", async () => {
const path = resolve(import.meta.dirname, "../../../../apps/docs/src/generated/custom-jsx-components.json");
const generated = JSON.parse(await readFile(path, "utf8")) as unknown;
const expected = customJsxComponentRegistry.map(
({ name, package: packageName, category, safety, reason, documentationUrl }) => ({
name,
package: packageName,
category,
safety,
reason,
documentationUrl,
}),
);
expect(generated).toEqual(expected);
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { parseCustomWidgetClipboard } from "../core/import";
import { customWidgetImportSchema } from "../core/schema";
const widget = {
$schema: "homarr-custom-widget-v3",
name: "Example",
url: "https://example.com/api",
authType: "none",
method: "GET",
displayType: "customJsx",
displayConfig: {
type: "customJsx",
jsxApiVersion: 2,
networkScope: "public",
requests: [],
template: "__HOMARR_TEMPLATE__",
},
};
describe("parseCustomWidgetClipboard", () => {
it("keeps supporting raw JSON imports", () => {
const result = parseCustomWidgetClipboard(JSON.stringify({ ...widget, displayType: "raw" }));
expect(result?.name).toBe("Example");
});
it("keeps accepting the v2 import envelope", () => {
expect(
customWidgetImportSchema.parse({
...widget,
$schema: "homarr-custom-widget-v2",
displayType: "raw",
displayConfig: { type: "raw", jsonPath: "$", maxHeight: 300 },
}).$schema,
).toBe("homarr-custom-widget-v2");
});
it("combines a JSON metadata block with a readable JSX block", () => {
const result = parseCustomWidgetClipboard(`Here is the widget:
\`\`\`json
${JSON.stringify(widget, null, 2)}
\`\`\`
\`\`\`jsx
<Stack gap="sm">
<Text>{data.name}</Text>
</Stack>
\`\`\``);
expect((result?.displayConfig as Record<string, unknown> | undefined)?.template).toBe(
'<Stack gap="sm">\n <Text>{data.name}</Text>\n</Stack>',
);
});
it("accepts case-insensitive fenced languages and CRLF line endings", () => {
const result = parseCustomWidgetClipboard(
`\`\`\`JSON\r\n${JSON.stringify(widget)}\r\n\`\`\`\r\n\`\`\`TSX\r\n<Text>{data.name}</Text>\r\n\`\`\``,
);
expect((result?.displayConfig as Record<string, unknown> | undefined)?.template).toBe("<Text>{data.name}</Text>");
});
it("rejects an unterminated fence with a large whitespace payload", () => {
expect(parseCustomWidgetClipboard(`\`\`\`json\n${" ".repeat(250_000)}`)).toBeNull();
});
});
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { renderSafeJsx, sanitizeCustomJsxProps } from "../jsx/interpreter";
import { CUSTOM_JSX_BLOCKED_PROPERTIES, CUSTOM_JSX_BLOCKED_PROPS, CUSTOM_JSX_LIMITS } from "../jsx/policy";
const components = { Text: () => null };
const bindings = { data: Object.create(null) as Record<string, unknown> };
describe("shared Custom JSX policy", () => {
test.each([...CUSTOM_JSX_BLOCKED_PROPERTIES])(
"blocks reflective property %s in diagnostics and runtime",
(property) => {
const template = `<Text>{data[${JSON.stringify(property)}]}</Text>`;
expect(validateCustomJsxTemplate(template).some(({ severity }) => severity === "error")).toBe(true);
expect(() => renderSafeJsx({ template, components, bindings })).toThrow(/reflective property/u);
},
);
test.each([...CUSTOM_JSX_BLOCKED_PROPS])("removes blocked prop %s at the renderer boundary", (property) => {
const safe = sanitizeCustomJsxProps({ [property]: "unsafe", title: "safe" }, "Text");
expect(safe).not.toHaveProperty(property);
expect(safe).toHaveProperty("title", "safe");
});
test("uses the same depth and operation budgets for analysis and runtime", () => {
const template = `<Text>{${"!".repeat(CUSTOM_JSX_LIMITS.astDepth + 1)}data}</Text>`;
expect(validateCustomJsxTemplate(template).some(({ message }) => message.includes("depth limit"))).toBe(true);
});
});
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { CustomWidgetPreviewSessionService } from "../server";
import type { CreatePreviewSessionInput } from "../server";
function createHarness() {
let now = 1_000;
let id = 0;
const service = new CustomWidgetPreviewSessionService({
createId: () => `id-${++id}`,
encrypt: (value) => `encrypted:${value}`,
decrypt: (value) => value.replace("encrypted:", ""),
now: () => now,
});
return {
service,
advance: (milliseconds: number) => {
now += milliseconds;
},
};
}
const input: CreatePreviewSessionInput = {
userId: "user-1",
baseUrl: "https://example.com/api",
authType: "bearer",
secrets: [{ kind: "apiKey", value: "secret" }],
networkScope: "public",
requests: [],
};
describe("preview session service", () => {
it("stores secrets encrypted and returns them only through the decrypting boundary", async () => {
const { service } = createHarness();
const created = await service.create(input);
const session = await service.get(created.id, input.userId);
expect(session.secrets[0]?.value).toBe("encrypted:secret");
expect(service.getSecrets(session)).toEqual([{ kind: "apiKey", value: "secret" }]);
});
it("isolates sessions by user and expires them after five minutes", async () => {
const { service, advance } = createHarness();
const created = await service.create(input);
await expect(service.get(created.id, "other-user")).rejects.toMatchObject({ code: "NOT_FOUND" });
const second = await service.create(input);
advance(5 * 60_000 + 1);
await expect(service.get(second.id, input.userId)).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("updates live-action approval and caps the newest-first journal", async () => {
const { service } = createHarness();
const created = await service.create(input);
const approved = await service.setLiveActions(created.id, input.userId, true);
expect(approved.liveActions).toBe(true);
const session = await service.get(created.id, input.userId);
for (let index = 0; index < 52; index += 1) {
await service.appendJournal(session, {
requestId: `request-${index}`,
kind: "query",
method: "GET",
pathTemplate: "/data",
status: 200,
durationMs: 1,
simulated: false,
});
}
const journal = await service.getJournal(created.id, input.userId);
expect(journal).toHaveLength(50);
expect(journal[0]?.requestId).toBe("request-51");
expect(journal.at(-1)?.requestId).toBe("request-2");
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { CustomWidgetRequestLimiter } from "../server";
import type { RequestLimitStore } from "../server";
const input = { category: "action", userId: "user", itemId: "item", definitionId: "definition" } as const;
describe("custom widget request limits", () => {
it("enforces local per-user/item concurrency and releases capacity", async () => {
const limiter = new CustomWidgetRequestLimiter();
const releases = await Promise.all(Array.from({ length: 4 }, () => limiter.acquire(input)));
await expect(limiter.acquire(input)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS", retryAfterMs: 1_000 });
await releases[0]?.();
await expect(limiter.acquire(input)).resolves.toBeTypeOf("function");
await Promise.all(releases.slice(1).map((release) => release()));
});
it("enforces category limits and exposes retry metadata", async () => {
const store: RequestLimitStore = {
incrementRate: vi.fn(async () => ({ count: 11, retryAfterMs: 321 })),
acquireConcurrency: vi.fn(async () => true),
releaseConcurrency: vi.fn(async () => undefined),
};
await expect(new CustomWidgetRequestLimiter({ store }).acquire(input)).rejects.toMatchObject({
code: "TOO_MANY_REQUESTS",
retryAfterMs: 321,
});
expect(store.acquireConcurrency).not.toHaveBeenCalled();
});
it("releases user capacity when definition capacity cannot be acquired", async () => {
const store: RequestLimitStore = {
incrementRate: vi.fn(async () => ({ count: 1, retryAfterMs: 60_000 })),
acquireConcurrency: vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false),
releaseConcurrency: vi.fn(async () => undefined),
};
await expect(new CustomWidgetRequestLimiter({ store }).acquire(input)).rejects.toMatchObject({
code: "TOO_MANY_REQUESTS",
});
expect(store.releaseConcurrency).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { CustomJsxRequest } from "../core";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRuntimeParams } from "../server";
const request: CustomJsxRequest = {
id: "device",
kind: "action",
method: "PATCH",
pathTemplate: "/devices/{device}/state",
parameters: { device: "string", enabled: "boolean", level: "number" },
bodyTemplate: { enabled: { $param: "enabled" }, nested: { level: { $param: "level" } } },
auth: "inherit",
minimumBoardPermission: "modify",
};
describe("named request manifest rendering", () => {
it("validates exact parameter names and declared primitive types", () => {
expect(() => validateRuntimeParams(request, { device: "lamp", enabled: true, level: 5 })).not.toThrow();
expect(() => validateRuntimeParams(request, { device: "lamp", enabled: true })).toThrow("match the manifest");
expect(() => validateRuntimeParams(request, { device: "lamp", enabled: true, level: "5" })).toThrow(
"must be number",
);
});
it("encodes path placeholders and preserves JSON parameter types", () => {
const params = { device: "living room/1", enabled: true, level: 5 };
expect(renderRequestTarget("https://example.com/api", request, params).pathname).toBe(
"/devices/living%20room%2F1/state",
);
expect(JSON.parse(renderRequestBody(request.bodyTemplate, params) ?? "null")).toEqual({
enabled: true,
nested: { level: 5 },
});
});
it("rejects unresolved or malformed placeholders", () => {
expect(() =>
renderRequestTarget(
"https://example.com",
{ ...request, pathTemplate: "/{missing}" },
{ device: "lamp", enabled: true, level: 5 },
),
).toThrow("Unknown path parameter");
expect(() =>
renderRequestTarget(
"https://example.com",
{ ...request, pathTemplate: "/{INVALID}" },
{ device: "lamp", enabled: true, level: 5 },
),
).toThrow("invalid placeholder");
});
it("hashes parameters independently of object insertion order", () => {
expect(hashRuntimeParams({ a: 1, b: "two" })).toBe(hashRuntimeParams({ b: "two", a: 1 }));
});
});
@@ -0,0 +1,157 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionButton, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import type {
CustomJsxRequestCapability,
CustomWidgetRequestResult,
CustomWidgetRuntimeMessages,
CustomWidgetRuntimePort,
} from "../runtime";
const messages: CustomWidgetRuntimeMessages = {
migrationRequired: "Migration required",
unsavedPreview: "Unsaved",
invalidParams: "Invalid params",
loadRequest: "Load",
requestFailed: "Failed",
loading: "Loading",
retry: "Retry",
widgetItemUnavailable: "Unavailable",
actionsDisabledEditMode: "Disabled",
actionSimulated: "Simulated",
actionCompleted: "Completed",
confirmDelete: "Confirm delete",
toggle: "Toggle",
refresh: "Refresh",
};
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
vi.restoreAllMocks();
});
function createPort(overrides: Partial<CustomWidgetRuntimePort> = {}): CustomWidgetRuntimePort {
return {
query: vi.fn(async () => ({ ok: true, status: 200, data: { name: "Bulbasaur" } })),
executeAction: vi.fn(async () => ({ ok: true, status: 200, data: null })),
invalidate: vi.fn(async () => undefined),
confirm: vi.fn(async () => true),
notify: vi.fn(),
...overrides,
};
}
async function render(
node: ReactNode,
port: CustomWidgetRuntimePort,
capabilities: readonly CustomJsxRequestCapability[] = [],
) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<MantineProvider>
<QueryClientProvider client={queryClient}>
<CustomWidgetRuntimeProvider
itemId="item-1"
isEditMode={false}
requestCapabilities={capabilities}
port={port}
messages={messages}
>
{node}
</CustomWidgetRuntimeProvider>
</QueryClientProvider>
</MantineProvider>,
);
});
}
async function settle() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe("Custom Widget runtime ports", () => {
it("runs an automatic query once and exposes data to a render child", async () => {
const port = createPort();
await render(
<SubFetch requestId="details" render={(data) => <span>{(data as { name: string }).name}</span>} />,
port,
);
await settle();
expect(port.query).toHaveBeenCalledTimes(1);
expect(host.textContent).toContain("Bulbasaur");
});
it("passes cancellation to the port when the component unmounts", async () => {
let signal: AbortSignal | undefined;
const port = createPort({
query: vi.fn((_input, nextSignal) => {
signal = nextSignal;
return new Promise<CustomWidgetRequestResult>(() => undefined);
}),
});
await render(<SubFetch requestId="slow" />, port);
await act(() => root.unmount());
expect(signal?.aborted).toBe(true);
root = createRoot(host);
});
it("confirms DELETE actions, reports success, and invalidates declared targets", async () => {
const port = createPort();
await render(<ActionButton requestId="remove" label="Remove" invalidate={["parent"]} />, port, [
{ id: "remove", kind: "action", method: "DELETE", minimumBoardPermission: "full" },
]);
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
expect(port.confirm).toHaveBeenCalledOnce();
expect(port.executeAction).toHaveBeenCalledWith(expect.objectContaining({ confirmed: true }));
expect(port.notify).toHaveBeenCalledWith(expect.objectContaining({ kind: "success" }));
expect(port.invalidate).toHaveBeenCalledWith(expect.objectContaining({ targets: ["parent"] }));
});
it("rolls a toggle back when its action fails", async () => {
const port = createPort({
executeAction: vi.fn(async () => ({ ok: false, status: 500, data: null, error: "Nope" })),
});
await render(<ToggleSwitch requestId="toggle" onParams={{ enabled: true }} offParams={{ enabled: false }} />, port);
const input = host.querySelector("input") as HTMLInputElement;
await act(async () => {
input.click();
});
await settle();
expect(input.checked).toBe(false);
expect(port.notify).toHaveBeenCalledWith(expect.objectContaining({ kind: "error", message: "Nope" }));
expect(port.invalidate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,167 @@
import { describe, expect, test } from "vitest";
import { customJsxExamples } from "../core/examples";
import { customJsxDisplayConfigV2Schema, customJsxRequestSchema, displayConfigSchema } from "../core/schema";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
describe("custom JSX v2 validation", () => {
test.each(customJsxExamples)("validates the $id shared example", (example) => {
expect(() =>
customJsxDisplayConfigV2Schema.parse({
type: "customJsx",
jsxApiVersion: 2,
template: example.template,
networkScope: "public",
requests: [...example.requests],
}),
).not.toThrow();
});
test("retains display-only v1 compatibility", () => {
expect(displayConfigSchema.parse({ type: "customJsx", template: "<Text>{data.value}</Text>" })).toEqual({
type: "customJsx",
template: "<Text>{data.value}</Text>",
});
});
test("accepts benign text containing interpreter-reserved words", () => {
expect(() =>
customJsxDisplayConfigV2Schema.parse({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text>constructor fetch prototype</Text>",
networkScope: "public",
requests: [],
}),
).not.toThrow();
});
test("accepts supported Tabs, ScrollArea, and SubData image props without warnings", () => {
const diagnostics = validateCustomJsxTemplate(
'<Tabs.List grow><ScrollArea offsetScrollbars><SubData as="Image" fit="contain" alt="Artwork" /></ScrollArea></Tabs.List>',
);
expect(diagnostics).toEqual([]);
});
test("rejects malformed JSX and direct global fetch while allowing declarative onParams", () => {
const config = {
type: "customJsx" as const,
jsxApiVersion: 2 as const,
networkScope: "public" as const,
requests: [],
};
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "<Text>" }).success).toBe(false);
expect(
customJsxDisplayConfigV2Schema.safeParse({ ...config, template: '<Text>{fetch("/private")}</Text>' }).success,
).toBe(false);
expect(
customJsxDisplayConfigV2Schema.safeParse({
...config,
template: '<ToggleSwitch requestId="toggle" onParams={{ enabled: true }} offParams={{ enabled: false }} />',
requests: [
{
id: "toggle",
kind: "action",
method: "POST",
pathTemplate: "/toggle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
auth: "none",
minimumBoardPermission: "modify",
},
],
}).success,
).toBe(true);
});
test("rejects reserved manifest headers and mismatched request component kinds", () => {
expect(
customJsxRequestSchema.safeParse({
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
staticHeaders: { Authorization: "secret" },
auth: "none",
minimumBoardPermission: "view",
}).success,
).toBe(false);
expect(
customJsxDisplayConfigV2Schema.safeParse({
type: "customJsx",
jsxApiVersion: 2,
networkScope: "public",
template: '<ActionButton requestId="status" label="Run" />',
requests: [
{
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
auth: "none",
minimumBoardPermission: "view",
},
],
}).success,
).toBe(false);
});
test("accepts 50,000 characters and rejects 50,001", () => {
const config = {
type: "customJsx" as const,
jsxApiVersion: 2 as const,
networkScope: "public" as const,
requests: [],
};
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "x".repeat(50_000) }).success).toBe(true);
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "x".repeat(50_001) }).success).toBe(false);
});
test("rejects duplicate request IDs", () => {
const request = {
id: "status",
kind: "query" as const,
method: "GET" as const,
pathTemplate: "/status",
parameters: {},
auth: "none" as const,
minimumBoardPermission: "view" as const,
};
const result = customJsxDisplayConfigV2Schema.safeParse({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text />",
networkScope: "public",
requests: [request, request],
});
expect(result.success).toBe(false);
});
test("rejects mutation-shaped queries and weak DELETE permissions", () => {
expect(
customJsxRequestSchema.safeParse({
id: "mutating-query",
kind: "query",
method: "POST",
pathTemplate: "/status",
parameters: {},
auth: "none",
minimumBoardPermission: "view",
}).success,
).toBe(false);
expect(
customJsxRequestSchema.safeParse({
id: "delete",
kind: "action",
method: "DELETE",
pathTemplate: "/resource",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
}).success,
).toBe(false);
});
});
@@ -0,0 +1,86 @@
import { createServer } from "node:http";
import { describe, expect, test } from "vitest";
import {
assertJsonBudget,
assertSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "../server";
describe("custom widget network policy", () => {
test.each([
["8.8.8.8", "public"],
["10.0.0.1", "private"],
["127.0.0.1", "loopback"],
["169.254.169.254", "blocked"],
["224.0.0.1", "blocked"],
["::ffff:127.0.0.1", "blocked"],
["fe80::1", "blocked"],
] as const)("classifies %s as %s", (address, expected) => expect(classifyAddress(address)).toBe(expected));
test("enforces configured address scopes", async () => {
await expect(resolveAndValidateHost("10.0.0.1", "public")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(resolveAndValidateHost("10.0.0.1", "private")).resolves.toHaveLength(1);
await expect(resolveAndValidateHost("127.0.0.1", "private")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(resolveAndValidateHost("127.0.0.1", "loopback")).resolves.toHaveLength(1);
await expect(resolveAndValidateHost("169.254.169.254", "loopback")).rejects.toMatchObject({ code: "FORBIDDEN" });
});
test("executes a DNS-pinned request within the approved scope", async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end('{"status":"ok"}');
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
try {
await expect(
executeCustomWidgetRequest({
baseUrl: `http://127.0.0.1:${address.port}`,
method: "GET",
networkScope: "loopback",
kind: "query",
}),
).resolves.toMatchObject({ ok: true, status: 200, data: { status: "ok" } });
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("allows only credential-free HTTP(S) URLs without fragments", () => {
expect(validateCustomWidgetUrl("https://example.com/api").href).toBe("https://example.com/api");
expect(() => validateCustomWidgetUrl("file:///etc/passwd")).toThrow("HTTP and HTTPS");
expect(() => validateCustomWidgetUrl("https://user:password@example.com/api")).toThrow("credentials");
expect(() => validateCustomWidgetUrl("https://example.com/api#secret")).toThrow("fragments");
});
test("rejects cross-origin targets and reserved headers", () => {
expect(resolveSameOriginTarget("https://example.com/base", "https://example.com/status").pathname).toBe("/status");
expect(() => resolveSameOriginTarget("https://example.com", "https://attacker.example")).toThrow("origin");
for (const header of [
"Authorization",
"Cookie",
"Host",
"Proxy-Authorization",
"Sec-Fetch-Site",
"X-Forwarded-For",
]) {
expect(() => assertSafeStaticHeaders({ [header]: "value" })).toThrow("reserved");
}
});
test("rejects response JSON beyond the depth budget", () => {
let nested: Record<string, unknown> = {};
const root = nested;
for (let index = 0; index < 40; index += 1) {
nested.child = {};
nested = nested.child as Record<string, unknown>;
}
expect(() => assertJsonBudget(root)).toThrow("deeply nested");
});
});
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PreviewHeader, PreviewResponsePanel, ResponseTree } from "../workbench";
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
async function render(node: ReactNode) {
await act(async () => root.render(<MantineProvider>{node}</MantineProvider>));
}
describe("Custom Widget workbench UI", () => {
test("renders response data through the Mantine Tree with accessible path actions", async () => {
await render(
<ResponseTree
value={{ service: { name: "Homarr", online: true } }}
labels={{ copyPath: "Copy path", pathCopied: "Path copied", insertPath: "Insert path" }}
onInsertDataPath={vi.fn()}
/>,
);
expect(host.querySelector('[role="tree"]')).not.toBeNull();
expect(host.textContent).toContain("service");
expect(host.querySelector('button[aria-label="Copy path"]')).not.toBeNull();
expect(host.querySelector('button[aria-label="Insert path"]')).not.toBeNull();
});
test("keeps mutation preview execution disabled and labels every capability control", async () => {
await render(
<PreviewHeader
method="POST"
url="https://example.com"
methods={["GET", "POST"]}
hasNamedActions
hasPreviewSession={false}
liveActions={false}
isUpdatingLiveActions={false}
isTesting={false}
isSampleStale={false}
onTest={vi.fn()}
methodColor={() => "blue"}
messages={{
title: "Preview",
interactive: "Interactive",
capabilitiesTitle: "Capabilities",
capabilitiesDescription: "Declared operations",
liveActions: "Live actions",
liveActionsDescription: "Enabled",
runTestFirst: "Run test first",
simulated: "Simulated",
test: "Test",
mutationDisabled: "Mutations are disabled",
staleTitle: "Stale",
staleDescription: "Run again",
}}
/>,
);
const testButton = [...host.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Test");
expect(testButton?.disabled).toBe(true);
expect(host.textContent).toContain("Mutations are disabled");
expect(host.textContent).toContain("Interactive");
});
test("shows an empty response state without exposing a raw JSON toggle", async () => {
await render(
<PreviewResponsePanel
value={null}
messages={{
empty: "No response",
sampleHint: "Add sample data",
editSample: "Edit sample",
addSample: "Add sample",
copied: "Copied",
copy: "Copy",
sampleLabel: "Sample",
sampleDescription: "JSON",
invalidSample: "Invalid JSON",
cancelSample: "Cancel",
applySample: "Apply",
copyPath: "Copy path",
pathCopied: "Path copied",
insertPath: "Insert path",
openRaw: "Open raw response",
}}
/>,
);
expect(host.textContent).toContain("No response");
expect(host.textContent).not.toContain("View raw JSON");
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
appendDataPath,
createResponseTreeNode,
CUSTOM_JSX_TEMPLATE_LIMIT,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
customWidgetFormSchema,
} from "../workbench";
describe("Custom Widget workbench contracts", () => {
it("always provides a valid starter template", () => {
expect(DEFAULT_CUSTOM_WIDGET_FORM_VALUES.template.trim()).not.toBe("");
expect(
customWidgetFormSchema.safeParse({
...DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
name: "Example",
url: "https://example.com/api",
}).success,
).toBe(true);
});
it("accepts 50,000 template characters and rejects 50,001", () => {
expect(analyzeJsxTemplate("x".repeat(CUSTOM_JSX_TEMPLATE_LIMIT))).not.toEqual(
expect.arrayContaining([expect.objectContaining({ code: "templateTooLong" })]),
);
expect(analyzeJsxTemplate("x".repeat(CUSTOM_JSX_TEMPLATE_LIMIT + 1))).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "templateTooLong", severity: "error" })]),
);
});
it("validates full request manifests rather than accepting any JSON array", () => {
expect(analyzeRequestManifest("[]")).toEqual([]);
expect(analyzeRequestManifest('[{"id":"broken"}]')).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "invalidRequestManifest", severity: "error" })]),
);
});
it("validates named request references and rejects inline network capabilities", () => {
expect(analyzeJsxTemplate('<SubFetch requestId="known" />', { apiVersion: 2, requestIds: ["known"] })).toEqual([]);
expect(
analyzeJsxTemplate('<SubFetch requestId="missing" url="/api" />', { apiVersion: 2, requestIds: ["known"] }),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "legacyNetworkProps" }),
expect.objectContaining({ code: "unknownRequest", value: "missing" }),
]),
);
});
it("uses the AST security policy for computed reflective access while accepting benign fetch text", () => {
expect(analyzeJsxTemplate("<Text>{data['con' + 'structor']}</Text>")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "ast", severity: "error" })]),
);
expect(analyzeJsxTemplate("<Text>fetch the latest status</Text>")).toEqual([]);
});
it("builds safe response-tree paths for identifiers, arrays, and arbitrary object keys", () => {
expect(appendDataPath("data", "items", false)).toBe("data.items");
expect(appendDataPath("data.items", "0", true)).toBe("data.items[0]");
expect(appendDataPath("data", "first-name", false)).toBe('data["first-name"]');
const tree = createResponseTreeNode({ items: [{ name: "one" }] }, "data", "data");
expect(tree.children?.[0]?.children?.[0]?.children?.[0]?.value).toBe("data.items[0].name");
});
});
@@ -0,0 +1,97 @@
import { customJsxRequestSchema } from "../core";
import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx";
export type EditorDiagnosticCode =
| "ast"
| "dynamicRequestId"
| "invalidRequestManifest"
| "legacyNetworkProps"
| "missingRequestId"
| "templateEmpty"
| "templateTooLong"
| "unknownRequest";
export interface EditorDiagnostic {
code: EditorDiagnosticCode;
severity: "error" | "warning";
line?: number;
column?: number;
index?: number;
value?: string;
}
export interface AnalyzeJsxTemplateOptions {
apiVersion?: 1 | 2;
requestIds?: string[];
}
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
const lineAt = (source: string, index: number) => source.slice(0, index).split("\n").length;
export const CUSTOM_JSX_TEMPLATE_LIMIT = CUSTOM_JSX_LIMITS.templateLength;
export function analyzeJsxTemplate(
template: string,
{ apiVersion = 1, requestIds = [] }: AnalyzeJsxTemplateOptions = {},
): EditorDiagnostic[] {
const diagnostics: EditorDiagnostic[] = [];
if (!template.trim()) diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
if (template.length > CUSTOM_JSX_TEMPLATE_LIMIT)
diagnostics.push({ code: "templateTooLong", severity: "error", line: 1, column: 1, index: 0 });
diagnostics.push(
...validateCustomJsxTemplate(template).map((entry) => ({
code: "ast" as const,
severity: entry.severity,
line: entry.line,
column: entry.column,
index: entry.index,
value: entry.message,
})),
);
if (apiVersion !== 2) return diagnostics;
const knownIds = new Set(requestIds);
for (const match of template.matchAll(networkComponentPattern)) {
const props = match[2] ?? "";
const line = lineAt(template, match.index);
if (/\b(?:url|method|headers|body|onBody|offBody)\s*=/iu.test(props)) {
diagnostics.push({ code: "legacyNetworkProps", severity: "error", line, index: match.index });
}
const staticRequest = props.match(/\brequestId\s*=\s*["']([^"']+)["']/iu);
if (staticRequest?.[1] && !knownIds.has(staticRequest[1])) {
diagnostics.push({
code: "unknownRequest",
severity: "error",
line,
index: match.index,
value: staticRequest[1],
});
} else if (!staticRequest && /\brequestId\s*=/iu.test(props)) {
diagnostics.push({ code: "dynamicRequestId", severity: "error", line, index: match.index });
} else if (!staticRequest) {
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
}
}
return diagnostics;
}
export function analyzeRequestManifest(value: string): EditorDiagnostic[] {
try {
const result = customJsxRequestSchema.array().safeParse(JSON.parse(value) as unknown);
if (result.success) return [];
return result.error.issues.map((issue) => ({
code: "invalidRequestManifest",
severity: "error",
line: 1,
value: `${issue.path.map(String).join(".") || "requests"}: ${issue.message}`,
}));
} catch {
return [{ code: "invalidRequestManifest", severity: "error", line: 1 }];
}
}
export function parseRequestManifest(value: string): unknown[] {
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
@@ -0,0 +1,68 @@
import { useMemo, useState } from "react";
import { Badge, Button, Group, Popover, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
import { IconComponents, IconSearch } from "@tabler/icons-react";
import { enabledCustomJsxComponents } from "../core/component-registry";
export interface ComponentReferenceMessages {
action: string;
search: string;
empty: string;
count(count: number): string;
}
export function ComponentReference({ messages }: { messages: ComponentReferenceMessages }) {
const [query, setQuery] = useState("");
const components = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase();
return enabledCustomJsxComponents.filter(
(component) =>
normalized.length === 0 ||
component.name.toLocaleLowerCase().includes(normalized) ||
component.category.toLocaleLowerCase().includes(normalized),
);
}, [query]);
return (
<Popover width={320} position="bottom-end" withinPortal shadow="md">
<Popover.Target>
<Button type="button" size="compact-xs" variant="subtle" leftSection={<IconComponents size={14} />}>
{messages.action}
</Button>
</Popover.Target>
<Popover.Dropdown p="xs">
<Stack gap="xs">
<TextInput
size="xs"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder={messages.search}
aria-label={messages.search}
leftSection={<IconSearch size={14} />}
/>
<ScrollArea.Autosize mah={300} type="auto">
<Stack gap={4}>
{components.map((component) => (
<Group key={component.name} justify="space-between" wrap="nowrap" py={3} px={4}>
<Text size="xs" ff="monospace" fw={600}>
{component.name}
</Text>
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
{component.category}
</Badge>
</Group>
))}
{components.length === 0 && (
<Text size="xs" c="dimmed" ta="center" py="md">
{messages.empty}
</Text>
)}
</Stack>
</ScrollArea.Autosize>
<Text size="xs" c="dimmed">
{messages.count(enabledCustomJsxComponents.length)}
</Text>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,144 @@
import { z } from "zod/v4";
import { buildDisplayFormValues } from "../core";
const requiredScalarFields: Record<string, string[]> = {
singleValue: ["jsonPath"],
table: ["tablePath"],
raw: ["rawJsonPath"],
actionButton: ["buttonLabel"],
customJsx: ["template"],
};
const requiredCollectionFields: Record<string, { collection: string; fields: string[] }> = {
keyValue: { collection: "mappings", fields: ["label", "jsonPath"] },
table: { collection: "columns", fields: ["header", "jsonPath"] },
statGrid: { collection: "statGridItems", fields: ["label", "jsonPath"] },
progressBars: { collection: "progressBars", fields: ["label", "valuePath"] },
statusIndicator: { collection: "statusItems", fields: ["label", "jsonPath", "goodValues"] },
countGrid: { collection: "countGridItems", fields: ["label", "jsonPath"] },
};
const requiredString = z.string();
export const customWidgetFormSchema = z
.object({
name: z.string().trim().min(1).max(128),
description: requiredString,
iconUrl: requiredString,
url: z.string().trim().min(1),
authType: requiredString,
headerName: requiredString,
method: requiredString,
requestBody: requiredString,
displayType: requiredString,
jsonPath: requiredString,
label: requiredString,
unit: requiredString,
valueSize: requiredString,
labelPosition: requiredString,
mappings: z.array(z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString })),
kvLayout: requiredString,
kvColumns: z.number(),
tablePath: requiredString,
columns: z.array(z.object({ header: requiredString, jsonPath: requiredString })),
striped: z.boolean(),
compact: z.boolean(),
statGridItems: z.array(
z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString, color: requiredString }),
),
statGridColumns: z.number(),
cardStyle: requiredString,
progressBars: z.array(
z.object({
label: requiredString,
valuePath: requiredString,
maxPath: requiredString,
unit: requiredString,
color: requiredString,
}),
),
showPercentage: z.boolean(),
barSize: requiredString,
statusItems: z.array(z.object({ label: requiredString, jsonPath: requiredString, goodValues: requiredString })),
statusLayout: requiredString,
dotSize: requiredString,
countGridItems: z.array(z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString })),
countGridColumns: z.number(),
countValueSize: requiredString,
rawJsonPath: requiredString,
rawMaxHeight: z.number(),
buttonLabel: requiredString,
buttonColor: requiredString,
confirmText: requiredString,
successMessage: requiredString,
template: requiredString,
jsxApiVersion: requiredString,
networkScope: requiredString,
requestManifest: requiredString,
secrets: z.array(z.object({ kind: requiredString, value: requiredString, hasValue: z.boolean().optional() })),
})
.superRefine((data, ctx) => {
for (const field of requiredScalarFields[data.displayType] ?? []) {
if (!data[field as keyof typeof data]) addRequiredIssue(ctx, [field], data[field as keyof typeof data]);
}
const collection = requiredCollectionFields[data.displayType];
if (collection) {
const entries = data[collection.collection as keyof typeof data];
if (Array.isArray(entries))
entries.forEach((entry, index) => {
for (const field of collection.fields) {
const value = (entry as Record<string, unknown>)[field];
if (!value) addRequiredIssue(ctx, [collection.collection, index, field], value);
}
});
}
if (data.displayType === "customJsx" && data.jsxApiVersion === "2" && !isJsonArray(data.requestManifest)) {
ctx.addIssue({
code: "custom",
message: "invalidRequestManifest",
input: data.requestManifest,
path: ["requestManifest"],
});
}
});
export type CustomWidgetFormValues = z.infer<typeof customWidgetFormSchema>;
export const CUSTOM_WIDGET_AUTH_SECRET_FIELDS: Record<
string,
Array<{ kind: string; labelKey: string; isPassword: boolean }>
> = {
bearer: [{ kind: "apiKey", labelKey: "apiKey", isPassword: true }],
basic: [
{ kind: "username", labelKey: "username", isPassword: false },
{ kind: "password", labelKey: "password", isPassword: true },
],
apiKeyHeader: [{ kind: "apiKey", labelKey: "apiKey", isPassword: true }],
apiKeyQuery: [{ kind: "apiKey", labelKey: "apiKey", isPassword: true }],
};
export const CUSTOM_WIDGET_AUTH_USES_HEADER_NAME: Record<string, boolean> = { apiKeyHeader: true, apiKeyQuery: true };
export const DEFAULT_CUSTOM_WIDGET_FORM_VALUES: CustomWidgetFormValues = {
name: "",
description: "",
iconUrl: "",
url: "",
authType: "none",
headerName: "",
method: "GET",
requestBody: "",
...buildDisplayFormValues("singleValue", {}),
secrets: [],
};
function addRequiredIssue(ctx: z.core.$RefinementCtx, path: PropertyKey[], input: unknown): void {
ctx.addIssue({ code: "too_small", minimum: 1, origin: "string", inclusive: true, input, path });
}
function isJsonArray(value: string): boolean {
try {
return Array.isArray(JSON.parse(value));
} catch {
return false;
}
}
@@ -0,0 +1,104 @@
import { Alert, Badge, Button, Code, Group, Modal, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { ImportReview } from "../core/import";
export interface ImportReviewMessages {
title: string;
description: string;
name: string;
origin: string;
authentication: string;
networkScope: string;
methods: string;
permissions: string;
actionWarningTitle: string;
actionWarningDescription: string;
cancel: string;
confirm: string;
permission(permission: string): string;
}
export interface ImportReviewDialogProps {
opened: boolean;
review: ImportReview | null;
pending: boolean;
messages: ImportReviewMessages;
onClose(): void;
onConfirm(): void;
}
function ImportFact({ label, value }: { label: string; value: string }) {
return (
<Paper withBorder p="xs">
<Text size="xs" c="dimmed">
{label}
</Text>
<Code>{value || "—"}</Code>
</Paper>
);
}
export function ImportReviewDialog({ opened, review, pending, messages, onClose, onConfirm }: ImportReviewDialogProps) {
return (
<Modal opened={opened} onClose={onClose} title={messages.title} centered size="lg">
{review && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{messages.description}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<ImportFact label={messages.name} value={review.name} />
<ImportFact label={messages.origin} value={review.origin} />
<ImportFact label={messages.authentication} value={review.authType} />
<ImportFact label={messages.networkScope} value={review.networkScope} />
</SimpleGrid>
<div>
<Text size="sm" fw={600} mb={6}>
{messages.methods}
</Text>
<Group gap={6}>
{review.methods.map((method) => (
<Badge
key={method}
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
variant="light"
>
{method}
</Badge>
))}
</Group>
</div>
<div>
<Text size="sm" fw={600} mb={6}>
{messages.permissions}
</Text>
<Group gap={6}>
{review.permissions.map((permission) => (
<Badge key={permission} color="gray" variant="light">
{messages.permission(permission)}
</Badge>
))}
</Group>
</div>
{review.hasActions && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
<Text size="sm" fw={600}>
{messages.actionWarningTitle}
</Text>
<Text size="sm">{messages.actionWarningDescription}</Text>
</Alert>
)}
<Group justify="flex-end">
<Button type="button" variant="default" onClick={onClose} disabled={pending}>
{messages.cancel}
</Button>
<Button type="button" onClick={onConfirm} loading={pending}>
{messages.confirm}
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
@@ -0,0 +1,18 @@
export * from "./analyzer";
export * from "./response-tree";
export * from "./import-review";
export * from "./ports";
export * from "./component-reference";
export * from "./preview-error-boundary";
export * from "./preview-response-panel";
export * from "./preview-request-panel";
export * from "./preview-diagnostics-panel";
export * from "./preview-header";
export * from "./preview-utils";
export {
CUSTOM_WIDGET_AUTH_SECRET_FIELDS,
CUSTOM_WIDGET_AUTH_USES_HEADER_NAME,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
customWidgetFormSchema,
} from "./form-schema";
export type { CustomWidgetFormValues } from "./form-schema";
@@ -0,0 +1,32 @@
import type { CustomWidgetFormValues } from "./form-schema";
export interface WorkbenchPreviewResult {
success: boolean;
data?: unknown;
rawResponse?: string | null;
error?: string;
sessionId?: string;
}
export interface WorkbenchJournalEntry {
requestId: string;
method: string;
status: number | null;
durationMs: number;
simulated: boolean;
}
export interface CustomWidgetWorkbenchPort {
preview(values: CustomWidgetFormValues, signal?: AbortSignal): Promise<WorkbenchPreviewResult>;
queryPreview(
input: { sessionId: string; requestId: string; params: Record<string, string | number | boolean> },
signal?: AbortSignal,
): Promise<unknown>;
simulateAction(input: {
sessionId: string;
requestId: string;
params: Record<string, string | number | boolean>;
}): Promise<unknown>;
setLiveActions(input: { sessionId: string; enabled: boolean }): Promise<void>;
readJournal(sessionId: string, signal?: AbortSignal): Promise<readonly WorkbenchJournalEntry[]>;
}
@@ -0,0 +1,73 @@
import { Alert, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconCheck } from "@tabler/icons-react";
import type { EditorDiagnostic } from "./analyzer";
interface PreviewDiagnosticsMessages {
templateSize: string;
characters: (count: number) => string;
namedRequests: string;
methods: string;
networkScope: string;
ready: string;
diagnostic: (diagnostic: EditorDiagnostic) => string;
}
interface PreviewDiagnosticsPanelProps {
templateLength: number;
namedRequestCount: number;
methods: string;
networkScope: string;
diagnostics: EditorDiagnostic[];
messages: PreviewDiagnosticsMessages;
}
export function PreviewDiagnosticsPanel({
templateLength,
namedRequestCount,
methods,
networkScope,
diagnostics,
messages,
}: PreviewDiagnosticsPanelProps) {
return (
<Stack gap="xs">
<SimpleGrid cols={2} spacing="xs">
<DiagnosticFact label={messages.templateSize} value={messages.characters(templateLength)} />
<DiagnosticFact label={messages.namedRequests} value={String(namedRequestCount)} />
<DiagnosticFact label={messages.methods} value={methods} />
<DiagnosticFact label={messages.networkScope} value={networkScope} />
</SimpleGrid>
{diagnostics.length === 0 ? (
<Alert color="green" variant="light" p="xs" icon={<IconCheck size={15} />}>
<Text size="xs">{messages.ready}</Text>
</Alert>
) : (
diagnostics.map((diagnostic, index) => (
<Alert
key={`${diagnostic.code}-${index}`}
color={diagnostic.severity === "error" ? "red" : "yellow"}
variant="light"
p="xs"
icon={<IconAlertTriangle size={15} />}
>
<Text size="xs">{messages.diagnostic(diagnostic)}</Text>
</Alert>
))
)}
</Stack>
);
}
function DiagnosticFact({ label, value }: { label: string; value: string }) {
return (
<Paper withBorder p="xs">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={600} lineClamp={2}>
{value || "—"}
</Text>
</Paper>
);
}
@@ -0,0 +1,42 @@
import type { ErrorInfo, ReactNode } from "react";
import { Component } from "react";
import { Button, Card, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
export interface PreviewErrorBoundaryProps {
children: ReactNode;
title: string;
description: string;
retryLabel: string;
}
export class PreviewErrorBoundary extends Component<PreviewErrorBoundaryProps, { hasError: boolean }> {
public state = { hasError: false };
public static getDerivedStateFromError() {
return { hasError: true };
}
public componentDidCatch(_error: Error, _info: ErrorInfo) {
// Imported templates and response data are intentionally not logged.
}
public render() {
if (!this.state.hasError) return this.props.children;
return (
<Card p="xl" role="alert">
<Stack align="center" gap="sm" py="xl">
<IconAlertTriangle size={28} color="var(--mantine-color-red-6)" />
<div>
<Text fw={600} ta="center">
{this.props.title}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={480}>
{this.props.description}
</Text>
</div>
<Button type="button" variant="light" onClick={() => this.setState({ hasError: false })}>
{this.props.retryLabel}
</Button>
</Stack>
</Card>
);
}
}
@@ -0,0 +1,154 @@
import { Alert, Badge, Button, Group, Loader, Popover, Stack, Switch, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconInfoCircle, IconNetwork, IconPlayerPlay } from "@tabler/icons-react";
interface PreviewHeaderMessages {
title: string;
interactive: string;
capabilitiesTitle: string;
capabilitiesDescription: string;
liveActions: string;
liveActionsDescription: string;
runTestFirst: string;
simulated: string;
test: string;
mutationDisabled: string;
staleTitle: string;
staleDescription: string;
}
interface PreviewHeaderProps {
method: string;
url: string;
methods: string[];
hasNamedActions: boolean;
hasPreviewSession: boolean;
liveActions: boolean;
isUpdatingLiveActions: boolean;
isTesting: boolean;
isSampleStale: boolean;
testError?: string | null;
responseError?: string;
responseStatus?: { status: number; statusText: string } | null;
onTest: () => void;
onSetLiveActions?: (enabled: boolean) => void;
methodColor: (method: string) => string;
messages: PreviewHeaderMessages;
}
export function PreviewHeader({
method,
url,
methods,
hasNamedActions,
hasPreviewSession,
liveActions,
isUpdatingLiveActions,
isTesting,
isSampleStale,
testError,
responseError,
responseStatus,
onTest,
onSetLiveActions,
methodColor,
messages,
}: PreviewHeaderProps) {
return (
<>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Title order={5}>{messages.title}</Title>
{methods.length > 0 && (
<Popover width={300} position="bottom" withinPortal shadow="md">
<Popover.Target>
<Button
type="button"
size="compact-xs"
variant="subtle"
color="gray"
leftSection={<IconNetwork size={14} />}
>
{messages.interactive}
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="sm" fw={600}>
{messages.capabilitiesTitle}
</Text>
<Text size="xs" c="dimmed">
{messages.capabilitiesDescription}
</Text>
<Group gap={4}>
{methods.map((requestMethod) => (
<Badge key={requestMethod} size="xs" color={methodColor(requestMethod)} variant="light">
{requestMethod}
</Badge>
))}
</Group>
{hasNamedActions && (
<Stack gap={4} mt="xs">
<Switch
label={messages.liveActions}
description={hasPreviewSession ? messages.liveActionsDescription : messages.runTestFirst}
checked={liveActions}
disabled={!hasPreviewSession || !onSetLiveActions || isUpdatingLiveActions}
onChange={(event) => onSetLiveActions?.(event.currentTarget.checked)}
/>
{!liveActions && (
<Badge size="xs" color="yellow" variant="light" style={{ alignSelf: "flex-start" }}>
{messages.simulated}
</Badge>
)}
</Stack>
)}
</Stack>
</Popover.Dropdown>
</Popover>
)}
</Group>
<Button
type="button"
size="xs"
variant="light"
leftSection={isTesting ? <Loader size={14} /> : <IconPlayerPlay size={14} />}
onClick={onTest}
loading={isTesting}
disabled={method !== "GET" || !url}
>
{messages.test}
</Button>
</Group>
{method !== "GET" && (
<Alert color="yellow" variant="light" p="xs" icon={<IconInfoCircle size={15} />}>
<Text size="xs">{messages.mutationDisabled}</Text>
</Alert>
)}
{isSampleStale && (
<Alert color="yellow" variant="light" p="xs" icon={<IconAlertTriangle size={15} />}>
<Text size="xs" fw={600}>
{messages.staleTitle}
</Text>
<Text size="xs">{messages.staleDescription}</Text>
</Alert>
)}
{testError && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{testError}</Text>
</Alert>
)}
{responseError && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs" fw={500}>
{responseError}
</Text>
{responseStatus && (
<Badge size="xs" color="red" variant="light" mt={4}>
{responseStatus.status} {responseStatus.statusText}
</Badge>
)}
</Alert>
)}
</>
);
}
@@ -0,0 +1,155 @@
import { Alert, Badge, Group, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconClipboard } from "@tabler/icons-react";
export interface PreviewNamedRequest {
id: string;
kind: "query" | "action";
method: string;
pathTemplate: string;
minimumBoardPermission?: string;
}
export interface PreviewJournalEntry {
id: string;
requestId: string;
method: string;
pathTemplate: string;
simulated: boolean;
status: number | null;
durationMs: number;
}
interface PreviewRequestPanelMessages {
method: string;
authentication: string;
endpoint: string;
status: string;
notRun: string;
named: string;
journal: string;
journalEmpty: string;
simulated: string;
redacted: string;
permission: (permission: string) => string;
duration: (duration: number) => string;
}
interface PreviewRequestPanelProps {
method: string;
authentication: string;
endpoint: string;
status?: number;
namedRequests: PreviewNamedRequest[];
journal?: PreviewJournalEntry[];
methodColor: (method: string) => string;
messages: PreviewRequestPanelMessages;
}
const journalColor = ({ simulated, status }: PreviewJournalEntry): string => {
if (simulated) return "yellow";
if (status !== null && status >= 200 && status < 400) return "green";
return "red";
};
export function PreviewRequestPanel({
method,
authentication,
endpoint,
status,
namedRequests,
journal,
methodColor,
messages,
}: PreviewRequestPanelProps) {
return (
<Stack gap="sm">
<SimpleGrid cols={2} spacing="xs">
<RequestFact label={messages.method} value={method} />
<RequestFact label={messages.authentication} value={authentication} />
<RequestFact label={messages.endpoint} value={endpoint} />
<RequestFact label={messages.status} value={status === undefined ? messages.notRun : String(status)} />
</SimpleGrid>
{namedRequests.length > 0 && (
<Stack gap={6}>
<Text size="xs" fw={600}>
{messages.named}
</Text>
{namedRequests.map((request) => (
<Paper key={request.id} withBorder p="xs">
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="xs" fw={600} ff="monospace">
{request.id}
</Text>
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1}>
{request.pathTemplate}
</Text>
</div>
<Group gap={4} wrap="nowrap">
<Badge size="xs" variant="light" color={methodColor(request.method)}>
{request.method}
</Badge>
<Badge size="xs" variant="light" color="gray">
{messages.permission(request.minimumBoardPermission ?? "view")}
</Badge>
</Group>
</Group>
</Paper>
))}
</Stack>
)}
{journal && journal.length > 0 ? (
<Stack gap={6}>
<Text size="xs" fw={600}>
{messages.journal}
</Text>
{journal.map((entry) => (
<Paper key={entry.id} withBorder p="xs">
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text size="xs" fw={600} ff="monospace">
{entry.requestId}
</Text>
<Text size="xs" c="dimmed" ff="monospace" truncate>
{entry.pathTemplate}
</Text>
</div>
<Group gap={4} wrap="nowrap">
<Badge size="xs" color={methodColor(entry.method)} variant="light">
{entry.method}
</Badge>
<Badge size="xs" color={journalColor(entry)}>
{entry.simulated ? messages.simulated : (entry.status ?? "—")}
</Badge>
<Text size="xs" c="dimmed" w={56} ta="right">
{messages.duration(entry.durationMs)}
</Text>
</Group>
</Group>
</Paper>
))}
</Stack>
) : (
<Text size="xs" c="dimmed">
{messages.journalEmpty}
</Text>
)}
<Alert color="blue" variant="light" p="xs" icon={<IconClipboard size={15} />}>
<Text size="xs">{messages.redacted}</Text>
</Alert>
</Stack>
);
}
function RequestFact({ label, value }: { label: string; value: string }) {
return (
<Paper withBorder p="xs">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={600} lineClamp={2}>
{value || "—"}
</Text>
</Paper>
);
}
@@ -0,0 +1,149 @@
import { useMemo, useState } from "react";
import { Button, Collapse, CopyButton, Group, Paper, ScrollArea, Stack, Text, Textarea } from "@mantine/core";
import { IconCheck, IconCopy, IconExternalLink } from "@tabler/icons-react";
import { ResponseTree } from "./response-tree";
export interface PreviewResponseMessages {
empty: string;
sampleHint: string;
editSample: string;
addSample: string;
copied: string;
copy: string;
sampleLabel: string;
sampleDescription: string;
invalidSample: string;
cancelSample: string;
applySample: string;
copyPath: string;
pathCopied: string;
insertPath: string;
openRaw: string;
}
export interface PreviewResponsePanelProps {
value: unknown;
rawResponse?: string | null;
onInsertDataPath?(path: string): void;
onSampleDataChange?(value: unknown): void;
messages: PreviewResponseMessages;
}
function openRawResponse(rawResponse: string) {
const url = URL.createObjectURL(new Blob([rawResponse], { type: "application/json" }));
window.open(url);
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function PreviewResponsePanel({
value,
rawResponse,
onInsertDataPath,
onSampleDataChange,
messages,
}: PreviewResponsePanelProps) {
const responseJson = value == null ? "" : JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(responseJson);
const [editing, setEditing] = useState(false);
const error = useMemo(() => {
if (!draft.trim()) return null;
try {
JSON.parse(draft);
return null;
} catch {
return messages.invalidSample;
}
}, [draft, messages.invalidSample]);
const edit = () => {
setDraft(responseJson || JSON.stringify({ name: "Sample service", status: "online", value: 42 }, null, 2));
setEditing(true);
};
const apply = () => {
if (!draft.trim() || error) return;
onSampleDataChange?.(JSON.parse(draft) as unknown);
setEditing(false);
};
return (
<Stack gap="xs">
<Group justify="space-between" wrap="wrap">
<Text size="xs" c="dimmed">
{responseJson ? messages.sampleHint : messages.empty}
</Text>
<Group gap={4}>
{onSampleDataChange && (
<Button type="button" size="compact-xs" variant="light" onClick={edit}>
{responseJson ? messages.editSample : messages.addSample}
</Button>
)}
{responseJson && (
<CopyButton value={responseJson}>
{({ copied, copy }) => (
<Button
type="button"
size="compact-xs"
variant="subtle"
leftSection={copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
onClick={copy}
>
{copied ? messages.copied : messages.copy}
</Button>
)}
</CopyButton>
)}
</Group>
</Group>
<Collapse expanded={editing}>
<Paper p="sm" bg="var(--mantine-color-default-hover)">
<Stack gap="sm">
<Textarea
label={messages.sampleLabel}
description={messages.sampleDescription}
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
error={error}
autosize
minRows={8}
maxRows={18}
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
/>
<Group justify="flex-end">
<Button type="button" variant="default" size="xs" onClick={() => setEditing(false)}>
{messages.cancelSample}
</Button>
<Button type="button" size="xs" onClick={apply} disabled={!draft.trim() || Boolean(error)}>
{messages.applySample}
</Button>
</Group>
</Stack>
</Paper>
</Collapse>
{responseJson ? (
<>
<ScrollArea h={360} type="auto">
<ResponseTree
value={value}
onInsertDataPath={onInsertDataPath}
labels={{ copyPath: messages.copyPath, pathCopied: messages.pathCopied, insertPath: messages.insertPath }}
/>
</ScrollArea>
{rawResponse && (
<Button
type="button"
size="xs"
variant="subtle"
leftSection={<IconExternalLink size={14} />}
onClick={() => openRawResponse(rawResponse)}
>
{messages.openRaw}
</Button>
)}
</>
) : (
<Text size="xs" c="dimmed" ta="center" py="xl">
{messages.empty}
</Text>
)}
</Stack>
);
}
@@ -0,0 +1,37 @@
import type { PreviewNamedRequest } from "./preview-request-panel";
export const redactPreviewUrl = (value: string): string => {
try {
const url = new URL(value);
for (const key of url.searchParams.keys()) url.searchParams.set(key, "redacted");
return url.toString();
} catch {
return value;
}
};
export const getPreviewNamedRequests = (displayConfig: Record<string, unknown>): PreviewNamedRequest[] => {
if (!Array.isArray(displayConfig.requests)) return [];
return displayConfig.requests.filter(
(request): request is PreviewNamedRequest =>
request !== null &&
typeof request === "object" &&
"id" in request &&
typeof request.id === "string" &&
"kind" in request &&
(request.kind === "query" || request.kind === "action") &&
"method" in request &&
typeof request.method === "string" &&
"pathTemplate" in request &&
typeof request.pathTemplate === "string",
);
};
export const getPreviewLegacyMethods = (template: string): string[] => {
const methods = new Set<string>();
for (const match of template.matchAll(/<(?:SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu)) {
const method = match[1]?.match(/\bmethod\s*=\s*["'](GET|POST|PUT|PATCH|DELETE)["']/iu)?.[1];
methods.add(method?.toUpperCase() ?? (match[0].startsWith("<SubFetch") ? "GET" : "POST"));
}
return [...methods];
};
@@ -0,0 +1,123 @@
"use client";
import { useMemo } from "react";
import { ActionIcon, Code, CopyButton, Group, Text, Tree, getTreeExpandedState, useTree } from "@mantine/core";
import type { RenderTreeNodePayload, TreeNodeData } from "@mantine/core";
import { IconCheck, IconChevronDown, IconChevronRight, IconCopy, IconPlus } from "@tabler/icons-react";
export interface ResponseTreeLabels {
copyPath: string;
pathCopied: string;
insertPath: string;
}
export interface ResponseTreeProps {
value: unknown;
labels: ResponseTreeLabels;
onInsertDataPath?: (path: string) => void;
}
interface ResponseTreeData extends TreeNodeData {
path: string;
displayValue: string;
}
export function ResponseTree({ value, labels, onInsertDataPath }: ResponseTreeProps) {
const data = useMemo(() => [createResponseTreeNode(value, "data", "data")], [value]);
const tree = useTree({ initialExpandedState: getTreeExpandedState(data, ["data"]) });
return (
<Tree
data={data}
tree={tree}
levelOffset={14}
expandOnClick
selectOnClick={false}
renderNode={(payload) => <ResponseTreeNode {...payload} labels={labels} onInsertDataPath={onInsertDataPath} />}
/>
);
}
function ResponseTreeNode({
node,
expanded,
hasChildren,
elementProps,
labels,
onInsertDataPath,
}: RenderTreeNodePayload & { labels: ResponseTreeLabels; onInsertDataPath?: (path: string) => void }) {
const responseNode = node as ResponseTreeData;
return (
<Group {...elementProps} gap={4} wrap="nowrap" mih={28} className="response-tree-row">
<div style={{ width: 16, flexShrink: 0 }}>
{hasChildren && (expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />)}
</div>
<Code style={{ flex: "0 1 auto", overflow: "hidden", textOverflow: "ellipsis", fontSize: 11 }}>
{responseNode.label}
</Code>
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
{responseNode.displayValue}
</Text>
<Group gap={2} wrap="nowrap" className="response-tree-actions">
<CopyButton value={responseNode.path}>
{({ copied, copy }) => (
<ActionIcon
type="button"
variant="subtle"
color={copied ? "green" : "gray"}
size={24}
aria-label={copied ? labels.pathCopied : labels.copyPath}
onClick={(event) => {
event.stopPropagation();
copy();
}}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
)}
</CopyButton>
{onInsertDataPath && (
<ActionIcon
type="button"
variant="subtle"
size={24}
aria-label={labels.insertPath}
onClick={(event) => {
event.stopPropagation();
onInsertDataPath(responseNode.path);
}}
>
<IconPlus size={14} />
</ActionIcon>
)}
</Group>
</Group>
);
}
export function createResponseTreeNode(value: unknown, path: string, label: string): ResponseTreeData {
const collection = value !== null && typeof value === "object";
const entries = collection ? Object.entries(value as Record<string, unknown>) : [];
return {
value: path,
label,
path,
displayValue: collection
? Array.isArray(value)
? `[${entries.length}]`
: `{${entries.length}}`
: formatValue(value),
children: entries.map(([key, child]) =>
createResponseTreeNode(child, appendDataPath(path, key, Array.isArray(value)), key),
),
};
}
export function appendDataPath(parent: string, key: string, isArray: boolean): string {
if (isArray) return `${parent}[${key}]`;
return /^[A-Za-z_$][\w$]*$/u.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
}
function formatValue(value: unknown): string {
if (typeof value === "string") return JSON.stringify(value);
if (value === undefined) return "undefined";
return String(value);
}