feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304)
This commit is contained in:
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user