refactor(custom-widgets): extract domain package

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

No files matched your search

+1 -2
View File
@@ -14,7 +14,6 @@
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts",
"./custom-widget/display-data": "./src/router/custom-widget/display-data.ts",
"./mcp": "./src/mcp.ts"
},
"scripts": {
@@ -28,6 +27,7 @@
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/custom-widgets": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -49,7 +49,6 @@
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"jsonpath-plus": "catalog:",
"lodash.clonedeep": "catalog:",
"next": "catalog:",
"react": "catalog:",
@@ -1,221 +1,40 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { stringify as stringifySuperJson } from "superjson";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { boards, customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { eq, or } from "@homarr/db";
import { encryptSecret } from "@homarr/common/server";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { createLogger } from "@homarr/core/infrastructure/logs";
import {
customWidgetAuthTypes,
customWidgetCreateSchema,
customWidgetDisplayTypes,
customWidgetImportSchema,
customWidgetMethods,
customWidgetSecretKinds,
customWidgetUpdateSchema,
customJsxDisplayConfigV2Schema,
displayConfigSchema,
} from "@homarr/validation/custom-widget";
} from "@homarr/custom-widgets/core";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "./display-data";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
import { managementQueryProcedures } from "./management-queries";
import { metadataProcedures } from "./metadata-procedures";
import { previewActionProcedures } from "./preview-action-procedures";
import { previewBaseProcedures } from "./preview-base-procedures";
import { previewQueryProcedures } from "./preview-query-procedures";
import { parseDisplayConfig } from "./parse-display-config";
import { executeCustomWidgetRequest } from "./request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import {
appendPreviewJournal,
createPreviewSession,
getPreviewJournal,
getPreviewSession,
getPreviewSessionSecrets,
setPreviewSessionLiveActions,
} from "./preview-sessions";
import { transferProcedures } from "./transfer-procedures";
import { templateProcedures } from "./template-procedures";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const logger = createLogger({ module: "custom-widget" });
const recordPreviewJournal = async (...args: Parameters<typeof appendPreviewJournal>) => {
try {
await appendPreviewJournal(...args);
} catch (error) {
logger.warn("Failed to append custom widget preview journal", {
sessionId: args[0].id,
errorName: error instanceof Error ? error.name : "UnknownError",
});
}
};
const previewRuntimeParamsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
const previewSessionRequestSchema = z.object({
sessionId: z.string().min(1),
requestId: z.string().min(1).max(64),
params: previewRuntimeParamsSchema.default({}),
});
const updateFieldSerializers: Record<string, (value: unknown) => unknown> = {
displayConfig: (value) => stringifySuperJson(value),
};
let importJsonSchemaCache: Record<string, unknown> | null = null;
function getImportJsonSchema() {
if (!importJsonSchemaCache) {
importJsonSchemaCache = {
...z.toJSONSchema(customWidgetImportSchema),
title: "Homarr Custom Widget",
description:
"Schema for importing/exporting custom widget definitions in Homarr. " +
"All jsonPath fields use JSONPath syntax (e.g. $.data.count, $.items[0].name). " +
"The displayConfig must match the chosen displayType. " +
"Secrets (API keys, passwords) are not included in exports and must be configured separately after import.",
};
}
return importJsonSchemaCache;
}
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
const validateUpdatedTemplate = (displayConfig: Record<string, unknown>, template: string) => {
displayConfig.template = template;
const result = displayConfigSchema.safeParse(displayConfig);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: result.error.issues
.map((issue) => `${issue.path.map(String).join(".") || "template"}: ${issue.message}`)
.join("; "),
});
}
return result.data;
};
export const customWidgetRouter = createTRPCRouter({
schema: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get the JSON Schema for Homarr custom-widget imports. Use this before generating a widget and before calling validate, create, or update.",
},
})
.query(() => getImportJsonSchema()),
...metadataProcedures,
validate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Validate a complete custom-widget JSON draft without saving or making network requests. REQUIRED: widget (the homarr-custom-widget-v3 object). Returns structured issue paths and messages so an agent can correct the draft and validate again.",
},
})
.input(z.object({ widget: z.unknown() }))
.query(({ input }) => {
const result = customWidgetImportSchema.safeParse(input.widget);
if (!result.success) {
return {
valid: false as const,
issues: result.error.issues.map((issue) => ({
path: issue.path.map(String).join("."),
code: issue.code,
message: issue.message,
})),
};
}
return {
valid: true as const,
issues: [],
widget: result.data,
};
}),
all: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"List all custom-widget definitions. Admin only. Use the returned ID to inspect or update a widget.",
},
})
.query(async ({ ctx }) => {
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
orderBy: (table, { asc }) => asc(table.name),
});
return definitions.map((def) => ({
id: def.id,
name: def.name,
description: def.description,
iconUrl: def.iconUrl,
url: def.url,
method: def.method,
displayType: def.displayType,
authType: def.authType,
enabled: def.enabled,
}));
}),
byId: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Get one complete custom-widget definition for iterative editing. REQUIRED: id. Stored secret values are never returned; only their presence is reported.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
with: { secrets: true },
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
const displayConfig = parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget",
);
return {
...definition,
enabled: definition.enabled,
displayConfig,
secrets: definition.secrets.map((s) => ({
kind: s.kind,
hasValue: true,
updatedAt: s.updatedAt,
})),
};
}),
available: protectedProcedure
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
return ctx.db.query.customWidgetDefinitions.findMany({
where: input.currentId
? or(eq(customWidgetDefinitions.enabled, true), eq(customWidgetDefinitions.id, input.currentId))
: eq(customWidgetDefinitions.enabled, true),
orderBy: (table, { asc }) => asc(table.name),
columns: {
id: true,
name: true,
description: true,
iconUrl: true,
displayType: true,
},
});
}),
...managementQueryProcedures,
create: adminProcedure
.meta({
@@ -357,582 +176,13 @@ export const customWidgetRouter = createTRPCRouter({
logger.info("Deleted custom widget definition", { id: input.id });
}),
readTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Read the JSX template of a custom widget definition as plain text. Returns the template string separately from the full widget config, making it easier to inspect and edit.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
...templateProcedures,
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
...transferProcedures,
const config = parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget readTemplate",
);
if (config.type !== "customJsx") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Widget is not using customJsx display type",
});
}
const template = (config.template as string | undefined) ?? "";
return {
id: definition.id,
name: definition.name,
template,
templateLines: template.split("\n"),
revision: getTemplateRevision(template),
};
}),
writeTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.",
},
})
.input(
z
.object({
id: z.string(),
template: z.string().optional(),
templateLines: z.array(z.string()).optional(),
})
.refine((data) => data.template !== undefined || data.templateLines !== undefined, {
message: "Provide either template or templateLines",
})
.refine((data) => !(data.template !== undefined && data.templateLines !== undefined), {
message: "Provide template or templateLines, not both",
}),
)
.mutation(async ({ ctx, input }) => {
const resolvedTemplate =
input.templateLines !== undefined ? input.templateLines.join("\n") : (input.template ?? "");
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
const displayConfig = parseDisplayConfig(
existing.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget writeTemplate",
);
if (displayConfig.type !== "customJsx") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Widget is not using customJsx display type",
});
}
if (resolvedTemplate.trim().length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
}
if (resolvedTemplate.length > 50_000) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template exceeds the 50,000 character limit" });
}
const validatedConfig = validateUpdatedTemplate(displayConfig, resolvedTemplate);
await ctx.db
.update(customWidgetDefinitions)
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.id));
logger.info("Updated custom widget template", { id: input.id });
return {
id: input.id,
template: resolvedTemplate,
templateLines: resolvedTemplate.split("\n"),
revision: getTemplateRevision(resolvedTemplate),
};
}),
patchTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.",
},
})
.input(
z.object({
id: z.string(),
expectedRevision: z.string().length(16),
edits: z
.array(
z.object({
startLine: z.number().int().min(1),
deleteCount: z.number().int().min(0),
replacementLines: z.array(z.string()),
}),
)
.min(1)
.max(100),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const displayConfig = parseDisplayConfig(
existing.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget patchTemplate",
);
if (displayConfig.type !== "customJsx") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Widget is not using customJsx display type" });
}
const currentTemplate = (displayConfig.template as string | undefined) ?? "";
if (getTemplateRevision(currentTemplate) !== input.expectedRevision) {
throw new TRPCError({
code: "CONFLICT",
message: "Template changed since it was read. Read the template again and retry the patch.",
});
}
const lines = currentTemplate.split("\n");
const edits = input.edits.toSorted((left, right) => right.startLine - left.startLine);
let nextHigherStart = lines.length + 2;
for (const edit of edits) {
if (edit.startLine > lines.length + 1 || edit.startLine + edit.deleteCount - 1 > lines.length) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Edit at line ${edit.startLine} is out of range` });
}
if (edit.startLine + Math.max(edit.deleteCount, 1) > nextHigherStart) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template patch edits must not overlap" });
}
nextHigherStart = edit.startLine;
lines.splice(edit.startLine - 1, edit.deleteCount, ...edit.replacementLines);
}
const template = lines.join("\n");
const validatedConfig = validateUpdatedTemplate(displayConfig, template);
await ctx.db
.update(customWidgetDefinitions)
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.id));
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
return {
id: input.id,
template,
templateLines: lines,
revision: getTemplateRevision(template),
};
}),
export: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Export one custom widget as a homarr-custom-widget-v3 object suitable for validation, modification, and re-import. REQUIRED: id. Secrets are excluded.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return {
$schema: "homarr-custom-widget-v3" as const,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: definition.requestBody,
displayType: definition.displayType,
displayConfig: parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig during export",
),
};
}),
import: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Create a custom widget directly from a complete homarr-custom-widget-v3 import object. Admin only. Use customWidget_validate first. This is the preferred creation tool for AI-generated authoring bundles because secrets are omitted and configured separately.",
},
})
.input(customWidgetImportSchema)
.mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
url: input.url,
authType: input.authType,
headerName: input.headerName,
method: input.method,
requestBody: input.requestBody,
displayType: input.displayType,
displayConfig: stringifySuperJson(input.displayConfig),
creatorId: ctx.session.user.id,
});
logger.info("Imported custom widget definition", { id, name: input.name });
return { id };
}),
preview: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.",
},
})
.input(
z.object({
url: z.string().url(),
method: z.enum(customWidgetMethods),
authType: z.enum(customWidgetAuthTypes),
headerName: z.string().optional(),
requestBody: z.string().optional(),
displayType: z.enum(customWidgetDisplayTypes),
displayConfig: displayConfigSchema,
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })).default([]),
definitionId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const secrets = [...input.secrets];
if (input.definitionId) {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (existing) {
for (const dbSecret of existing.secrets) {
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
}
}
}
}
const v2Config = customJsxDisplayConfigV2Schema.safeParse(input.displayConfig);
const previewSession = v2Config.success
? await createPreviewSession({
userId: ctx.session.user.id,
baseUrl: input.url,
authType: input.authType,
headerName: input.headerName,
secrets,
networkScope: v2Config.data.networkScope,
requests: v2Config.data.requests,
definitionId: input.definitionId,
})
: null;
const storedPreviewSession = previewSession
? await getPreviewSession(previewSession.id, ctx.session.user.id)
: null;
if (input.method !== "GET") {
return {
success: true as const,
simulated: true as const,
responseInfo: null,
rawResponse: null,
displayData: input.displayType === "actionButton" ? extractActionButtonDisplay(input.displayConfig) : null,
previewSession,
};
}
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
itemId: `preview:${ctx.session.user.id}`,
definitionId: input.definitionId ?? `preview:${ctx.session.user.id}`,
});
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: input.url,
method: input.method,
body: undefined,
auth: { type: input.authType, secrets, headerName: input.headerName },
networkScope: v2Config.success ? v2Config.data.networkScope : "private",
kind: "query",
});
if (storedPreviewSession) {
await recordPreviewJournal(storedPreviewSession, {
requestId: "base",
kind: "query",
method: "GET",
pathTemplate: new URL(input.url).pathname,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
});
}
const responseInfo = { status: response.status, statusText: response.statusText };
if (!response.ok) {
return {
success: false as const,
error: `HTTP ${response.status}: ${response.statusText}`,
responseInfo,
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
previewSession,
};
}
const displayData = extractDisplayDataWithFallback(response.data, input.displayType, input.displayConfig);
return {
success: true as const,
responseInfo,
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
displayData,
previewSession,
};
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Custom widget preview failed", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to fetch data",
responseInfo: null,
rawResponse: null,
previewSession,
};
} finally {
await release();
}
}),
previewQuery: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Run one named GET query from a short-lived custom-widget preview session. REQUIRED: sessionId from customWidget_preview, requestId declared as a query, and typed params. Uses the hardened executor and returns sanitized response data for another validate/update iteration.",
},
})
.input(previewSessionRequestSchema)
.query(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
itemId: `preview:${session.id}`,
definitionId: session.definitionId ?? `preview:${session.id}`,
});
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: session.baseUrl,
targetUrl,
method: "GET",
staticHeaders: request.staticHeaders,
auth:
request.auth === "none"
? undefined
: {
type: session.authType,
secrets: getPreviewSessionSecrets(session),
headerName: session.headerName,
},
networkScope: session.networkScope,
kind: "query",
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(input.params)}`,
cacheTtlSeconds: request.cacheTtlSeconds,
});
await recordPreviewJournal(session, {
requestId: request.id,
kind: "query",
method: "GET",
pathTemplate: request.pathTemplate,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
});
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
};
} finally {
await release();
}
}),
setPreviewLiveActions: adminProcedure
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) =>
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
),
previewJournal: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Read the redacted request journal for a custom-widget preview session. REQUIRED: sessionId. Use it to inspect statuses and durations; credentials and parameter values are never returned.",
},
})
.input(z.object({ sessionId: z.string().min(1) }))
.query(async ({ ctx, input }) => getPreviewJournal(input.sessionId, ctx.session.user.id)),
simulatePreviewAction: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. Returns requiredPermission as the action's minimum board permission (view, modify, or full), not as an execution result. This MCP tool never enables or executes live actions.",
},
})
.input(previewSessionRequestSchema)
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
renderRequestTarget(session.baseUrl, request, input.params);
renderRequestBody(request.bodyTemplate, input.params);
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
status: null,
durationMs: 0,
simulated: true,
});
return {
ok: true,
simulated: true as const,
requestId: request.id,
method: request.method,
requiredPermission: request.minimumBoardPermission,
};
}),
previewAction: adminProcedure
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
const body = renderRequestBody(request.bodyTemplate, input.params);
if (!session.liveActions) {
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
status: null,
durationMs: 0,
simulated: true,
});
return { ok: true, status: 0, statusText: "Simulated", data: null, simulated: true as const };
}
if (request.method === "DELETE" && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
}
const release = await acquireCustomWidgetRequestLimit({
category: request.method === "DELETE" ? "delete" : "action",
userId: ctx.session.user.id,
itemId: `preview:${session.id}`,
definitionId: session.definitionId ?? `preview:${session.id}`,
});
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: session.baseUrl,
targetUrl,
method: request.method,
body,
staticHeaders: request.staticHeaders,
auth:
request.auth === "none"
? undefined
: {
type: session.authType,
secrets: getPreviewSessionSecrets(session),
headerName: session.headerName,
},
networkScope: session.networkScope,
kind: "action",
});
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
});
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
simulated: false as const,
};
} finally {
await release();
}
}),
...previewBaseProcedures,
...previewQueryProcedures,
...previewActionProcedures,
duplicate: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -1,155 +0,0 @@
import { JSONPath } from "jsonpath-plus";
type Config = Record<string, unknown>;
type Extractor = (json: unknown, config: Config) => unknown;
const query = (json: unknown, path: string) => JSONPath({ path, json: json as object, wrap: false });
const queryRows = (json: unknown, path: string): unknown[] => {
const rows = JSONPath({ path, json: json as object, wrap: true }) as unknown[];
const first = rows[0];
if (Array.isArray(first)) return first as unknown[];
return rows;
};
const toNumber = (value: unknown, fallback: number): number => {
const num = Number(value);
if (num) return num;
return fallback;
};
const optionalMax = (max: unknown): number | undefined => {
if (max === undefined) return undefined;
return toNumber(max, 100);
};
const isGoodValue = (value: unknown, goodValues: string[]): boolean =>
goodValues.some((gv) => String(value).toLowerCase() === gv.toLowerCase());
const extractors: Record<string, Extractor> = {
singleValue: (json, c) => ({
type: "singleValue",
label: (c.label as string) ?? "",
unit: (c.unit as string) ?? "",
value: query(json, (c.jsonPath as string) ?? "$"),
valueSize: c.valueSize ?? "lg",
labelPosition: c.labelPosition ?? "below",
}),
keyValue: (json, c) => ({
type: "keyValue",
entries: ((c.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((m) => ({
label: m.label,
unit: m.unit,
value: query(json, m.jsonPath),
})),
layout: c.layout ?? "list",
columns: c.columns ?? 2,
}),
table: (json, c) => {
const columns = (c.columns as Array<{ header: string; jsonPath: string }>) ?? [];
const flatRows = queryRows(json, (c.tablePath as string) ?? "$");
return {
type: "table",
columns: columns.map((col) => col.header),
rows: flatRows.map((row) => columns.map((col) => query(row, col.jsonPath))),
striped: c.striped ?? true,
compact: c.compact ?? false,
};
},
statGrid: (json, c) => ({
type: "statGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []).map(
(item) => ({
label: item.label,
unit: item.unit,
color: item.color ?? "blue",
value: query(json, item.jsonPath),
}),
),
columns: c.columns ?? 2,
cardStyle: c.cardStyle ?? "filled",
}),
progressBars: (json, c) => ({
type: "progressBars",
bars: (
(c.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ?? []
).map((bar) => {
const value = query(json, bar.valuePath);
const max = bar.maxPath ? query(json, bar.maxPath) : undefined;
return {
label: bar.label,
unit: bar.unit,
color: bar.color ?? "blue",
value: toNumber(value, 0),
max: optionalMax(max),
};
}),
showPercentage: c.showPercentage ?? true,
barSize: c.barSize ?? "md",
}),
statusIndicator: (json, c) => ({
type: "statusIndicator",
items: ((c.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map((item) => {
const value = query(json, item.jsonPath);
return { label: item.label, value: String(value ?? "unknown"), isGood: isGoodValue(value, item.goodValues) };
}),
layout: c.layout ?? "list",
dotSize: c.dotSize ?? "md",
}),
countGrid: (json, c) => ({
type: "countGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((item) => ({
label: item.label,
unit: item.unit,
value: query(json, item.jsonPath),
})),
columns: c.columns ?? 2,
valueSize: c.valueSize ?? "md",
}),
raw: (json, c) => ({
type: "raw",
data: query(json, (c.jsonPath as string) ?? "$"),
maxHeight: c.maxHeight ?? 300,
}),
actionButton: (_json, c) => ({
type: "actionButton",
buttonLabel: c.buttonLabel ?? "Execute",
buttonColor: c.buttonColor ?? "blue",
confirmText: c.confirmText ?? "",
successMessage: c.successMessage ?? "",
}),
customJsx: (json, config) => ({
type: "customJsx" as const,
template: config.template as string,
data: json,
jsxApiVersion: config.jsxApiVersion === 2 ? 2 : 1,
requestCapabilities:
config.jsxApiVersion === 2 && Array.isArray(config.requests)
? (config.requests as Array<Record<string, unknown>>).map((request) => ({
id: request.id,
kind: request.kind,
method: request.method,
minimumBoardPermission: request.minimumBoardPermission,
}))
: [],
}),
};
export function extractDisplayData(json: unknown, displayType: string, displayConfig: Config): unknown {
const type = (displayConfig.type as string) ?? displayType;
const extractor = extractors[type];
if (!extractor) {
throw new Error(`Unknown display type: ${type}`);
}
return extractor(json, displayConfig);
}
export function extractDisplayDataWithFallback(json: unknown, displayType: string, displayConfig: Config): unknown {
const type = (displayConfig.type as string) ?? displayType;
const extractor = extractors[type] ?? extractors.singleValue;
return extractor?.(json, displayConfig);
}
export function extractActionButtonDisplay(displayConfig: Config): unknown {
return extractors.actionButton?.(null, displayConfig);
}
@@ -0,0 +1,16 @@
import { TRPCError } from "@trpc/server";
import { FlattenError } from "@homarr/common";
import { CustomWidgetDomainError } from "@homarr/custom-widgets/server";
class RequestLimitTransportError extends FlattenError {}
export function toTrpcError(error: unknown): never {
if (error instanceof CustomWidgetDomainError) {
const cause = error.retryAfterMs
? new RequestLimitTransportError(error.message, { retryAfterMs: error.retryAfterMs })
: error;
throw new TRPCError({ code: error.code, message: error.message, cause });
}
throw error;
}
@@ -0,0 +1,84 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq, or } from "@homarr/db";
import { boards, customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { parseDisplayConfig } from "./parse-display-config";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const logger = createLogger({ module: "custom-widget" });
export const managementQueryProcedures = {
all: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"List all custom-widget definitions. Admin only. Use the returned ID to inspect or update a widget.",
},
})
.query(async ({ ctx }) => {
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
orderBy: (table, { asc }) => asc(table.name),
});
return definitions.map((definition) => ({
id: definition.id,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
method: definition.method,
displayType: definition.displayType,
authType: definition.authType,
enabled: definition.enabled,
}));
}),
byId: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Get one complete custom-widget definition for iterative editing. REQUIRED: id. Stored secret values are never returned; only their presence is reported.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
with: { secrets: true },
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
return {
...definition,
displayConfig: parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget",
),
secrets: definition.secrets.map((secret) => ({
kind: secret.kind,
hasValue: true,
updatedAt: secret.updatedAt,
})),
};
}),
available: protectedProcedure
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
return ctx.db.query.customWidgetDefinitions.findMany({
where: input.currentId
? or(eq(customWidgetDefinitions.enabled, true), eq(customWidgetDefinitions.id, input.currentId))
: eq(customWidgetDefinitions.enabled, true),
orderBy: (table, { asc }) => asc(table.name),
columns: { id: true, name: true, description: true, iconUrl: true, displayType: true },
});
}),
};
@@ -0,0 +1,54 @@
import { z } from "zod/v4";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure, publicProcedure } from "../../trpc";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
let schemaCache: Record<string, unknown> | undefined;
function getImportJsonSchema() {
schemaCache ??= {
...z.toJSONSchema(customWidgetImportSchema),
title: "Homarr Custom Widget",
description:
"Schema for importing/exporting custom widget definitions in Homarr. All jsonPath fields use JSONPath syntax. The displayConfig must match displayType. Secrets are configured separately after import.",
};
return schemaCache;
}
export const metadataProcedures = {
schema: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get the JSON Schema for Homarr custom-widget imports. Use this before generating a widget and before calling validate, create, or update.",
},
})
.query(() => getImportJsonSchema()),
validate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Validate a complete custom-widget JSON draft without saving or making network requests. Returns structured issue paths so an agent can correct and validate again.",
},
})
.input(z.object({ widget: z.unknown() }))
.query(({ input }) => {
const result = customWidgetImportSchema.safeParse(input.widget);
if (!result.success) {
return {
valid: false as const,
issues: result.error.issues.map((issue) => ({
path: issue.path.map(String).join("."),
code: issue.code,
message: issue.message,
})),
};
}
return { valid: true as const, issues: [], widget: result.data };
}),
};
@@ -1,13 +1,15 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { parse as parseSuperJson } from "superjson";
import type { createLogger } from "@homarr/core/infrastructure/logs";
import { displayConfigSchema } from "@homarr/custom-widgets/core";
import type { DisplayConfig } from "@homarr/custom-widgets/core";
type Logger = ReturnType<typeof createLogger>;
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): Record<string, unknown> {
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): DisplayConfig {
try {
return superjson.parse(raw) as Record<string, unknown>;
return displayConfigSchema.parse(parseSuperJson(raw));
} catch {
logger.error(context, { id });
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
@@ -0,0 +1,119 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { permissionRequiredProcedure } from "../../trpc";
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { renderRequestBody, renderRequestTarget } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
export const previewActionProcedures = {
simulatePreviewAction: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. Returns requiredPermission as the action's minimum board permission (view, modify, or full), not as an execution result. This MCP tool never enables or executes live actions.",
},
})
.input(previewSessionRequestSchema)
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
renderRequestTarget(session.baseUrl, request, input.params);
renderRequestBody(request.bodyTemplate, input.params);
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
status: null,
durationMs: 0,
simulated: true,
});
return {
ok: true,
simulated: true as const,
requestId: request.id,
method: request.method,
requiredPermission: request.minimumBoardPermission,
};
}),
previewAction: adminProcedure
.input(previewSessionRequestSchema.extend({ confirmed: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "action",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview action was not found" });
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
const body = renderRequestBody(request.bodyTemplate, input.params);
if (!session.liveActions) {
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
status: null,
durationMs: 0,
simulated: true,
});
return { ok: true, status: 0, statusText: "Simulated", data: null, simulated: true as const };
}
if (request.method === "DELETE" && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "DELETE actions require confirmation" });
}
const release = await acquireCustomWidgetRequestLimit({
category: request.method === "DELETE" ? "delete" : "action",
userId: ctx.session.user.id,
itemId: `preview:${session.id}`,
definitionId: session.definitionId ?? `preview:${session.id}`,
});
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: session.baseUrl,
targetUrl,
method: request.method,
body,
staticHeaders: request.staticHeaders,
auth:
request.auth === "none"
? undefined
: {
type: session.authType,
secrets: getPreviewSessionSecrets(session),
headerName: session.headerName,
},
networkScope: session.networkScope,
kind: "action",
});
await recordPreviewJournal(session, {
requestId: request.id,
kind: "action",
method: request.method,
pathTemplate: request.pathTemplate,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
});
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
simulated: false as const,
};
} finally {
await release();
}
}),
};
@@ -0,0 +1,159 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import {
customWidgetAuthTypes,
customWidgetDisplayTypes,
customWidgetMethods,
customWidgetSecretKinds,
customJsxDisplayConfigV2Schema,
displayConfigSchema,
extractActionButtonDisplay,
extractDisplayDataWithFallback,
} from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { recordPreviewJournal } from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const logger = createLogger({ module: "custom-widget" });
export const previewBaseProcedures = {
preview: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.",
},
})
.input(
z.object({
url: z.string().url(),
method: z.enum(customWidgetMethods),
authType: z.enum(customWidgetAuthTypes),
headerName: z.string().optional(),
requestBody: z.string().optional(),
displayType: z.enum(customWidgetDisplayTypes),
displayConfig: displayConfigSchema,
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })).default([]),
definitionId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const secrets = [...input.secrets];
if (input.definitionId) {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (existing) {
for (const dbSecret of existing.secrets) {
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
}
}
}
}
const v2Config = customJsxDisplayConfigV2Schema.safeParse(input.displayConfig);
const previewSession = v2Config.success
? await createPreviewSession({
userId: ctx.session.user.id,
baseUrl: input.url,
authType: input.authType,
headerName: input.headerName,
secrets,
networkScope: v2Config.data.networkScope,
requests: v2Config.data.requests,
definitionId: input.definitionId,
})
: null;
const storedPreviewSession = previewSession
? await getPreviewSession(previewSession.id, ctx.session.user.id)
: null;
if (input.method !== "GET") {
return {
success: true as const,
simulated: true as const,
responseInfo: null,
rawResponse: null,
displayData: input.displayType === "actionButton" ? extractActionButtonDisplay(input.displayConfig) : null,
previewSession,
};
}
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
itemId: `preview:${ctx.session.user.id}`,
definitionId: input.definitionId ?? `preview:${ctx.session.user.id}`,
});
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: input.url,
method: input.method,
body: undefined,
auth: { type: input.authType, secrets, headerName: input.headerName },
networkScope: v2Config.success ? v2Config.data.networkScope : "private",
kind: "query",
});
if (storedPreviewSession) {
await recordPreviewJournal(storedPreviewSession, {
requestId: "base",
kind: "query",
method: "GET",
pathTemplate: new URL(input.url).pathname,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
});
}
const responseInfo = { status: response.status, statusText: response.statusText };
if (!response.ok) {
return {
success: false as const,
error: `HTTP ${response.status}: ${response.statusText}`,
responseInfo,
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
previewSession,
};
}
const displayData = extractDisplayDataWithFallback(response.data, input.displayType, input.displayConfig);
return {
success: true as const,
responseInfo,
rawResponse: typeof response.data === "string" ? response.data : JSON.stringify(response.data, null, 2),
displayData,
previewSession,
};
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Custom widget preview failed", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to fetch data",
responseInfo: null,
rawResponse: null,
previewSession,
};
} finally {
await release();
}
}),
};
@@ -0,0 +1,23 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { z } from "zod/v4";
import { appendPreviewJournal } from "./preview-sessions";
const logger = createLogger({ module: "custom-widget-preview" });
export const previewSessionRequestSchema = z.object({
sessionId: z.string().min(1),
requestId: z.string().min(1).max(64),
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
});
export const recordPreviewJournal = async (...args: Parameters<typeof appendPreviewJournal>) => {
try {
await appendPreviewJournal(...args);
} catch (error) {
logger.warn("Failed to append custom widget preview journal", {
sessionId: args[0].id,
errorName: error instanceof Error ? error.name : "UnknownError",
});
}
};
@@ -0,0 +1,98 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { permissionRequiredProcedure } from "../../trpc";
import { previewSessionRequestSchema, recordPreviewJournal } from "./preview-procedure-helpers";
import { executeCustomWidgetRequest } from "./request-executor";
import { hashRuntimeParams, renderRequestTarget } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import {
getPreviewJournal,
getPreviewSession,
getPreviewSessionSecrets,
setPreviewSessionLiveActions,
} from "./preview-sessions";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
export const previewQueryProcedures = {
previewQuery: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Run one named GET query from a short-lived custom-widget preview session. REQUIRED: sessionId from customWidget_preview, requestId declared as a query, and typed params. Uses the hardened executor and returns sanitized response data for another validate/update iteration.",
},
})
.input(previewSessionRequestSchema)
.query(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
(candidate) => candidate.id === input.requestId && candidate.kind === "query",
);
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const targetUrl = renderRequestTarget(session.baseUrl, request, input.params);
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
itemId: `preview:${session.id}`,
definitionId: session.definitionId ?? `preview:${session.id}`,
});
const startedAt = Date.now();
try {
const response = await executeCustomWidgetRequest({
baseUrl: session.baseUrl,
targetUrl,
method: "GET",
staticHeaders: request.staticHeaders,
auth:
request.auth === "none"
? undefined
: {
type: session.authType,
secrets: getPreviewSessionSecrets(session),
headerName: session.headerName,
},
networkScope: session.networkScope,
kind: "query",
cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(input.params)}`,
cacheTtlSeconds: request.cacheTtlSeconds,
});
await recordPreviewJournal(session, {
requestId: request.id,
kind: "query",
method: "GET",
pathTemplate: request.pathTemplate,
status: response.status,
durationMs: Date.now() - startedAt,
simulated: false,
});
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: response.data,
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
};
} finally {
await release();
}
}),
setPreviewLiveActions: adminProcedure
.input(z.object({ sessionId: z.string().min(1), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) =>
setPreviewSessionLiveActions(input.sessionId, ctx.session.user.id, input.enabled),
),
previewJournal: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Read the redacted request journal for a custom-widget preview session. REQUIRED: sessionId. Use it to inspect statuses and durations; credentials and parameter values are never returned.",
},
})
.input(z.object({ sessionId: z.string().min(1) }))
.query(async ({ ctx, input }) => getPreviewJournal(input.sessionId, ctx.session.user.id)),
};
@@ -1,175 +1,79 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import {
customJsxNetworkScopes,
customJsxRequestSchema,
customWidgetAuthTypes,
customWidgetSecretKinds,
} from "@homarr/validation/custom-widget";
import type { CustomJsxNetworkScope, CustomJsxRequest, CustomWidgetAuthType } from "@homarr/validation/custom-widget";
import { CustomWidgetPreviewSessionService } from "@homarr/custom-widgets/server";
import type {
CreatePreviewSessionInput,
CustomWidgetPreviewJournalEntry,
CustomWidgetPreviewSession,
PreviewSessionStore,
} from "@homarr/custom-widgets/server";
const PREVIEW_SESSION_TTL_MS = 5 * 60_000;
const PREVIEW_SESSION_PREFIX = "custom-widget:preview-session:";
const PREVIEW_JOURNAL_PREFIX = "custom-widget:preview-journal:";
const MAX_PREVIEW_JOURNAL_ENTRIES = 50;
import { toTrpcError } from "./domain-error";
const storedPreviewSessionSchema = 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(),
});
const SESSION_PREFIX = "custom-widget:preview-session:";
const JOURNAL_PREFIX = "custom-widget:preview-journal:";
export type CustomWidgetPreviewSession = z.infer<typeof storedPreviewSessionSchema>;
class RedisPreviewSessionStore implements PreviewSessionStore {
public constructor(private readonly redis: RedisClient) {}
const previewJournalEntrySchema = 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 previewJournalEntrySchema>;
public async saveSession(id: string, value: unknown, ttlMs: number) {
await this.redis.set(`${SESSION_PREFIX}${id}`, JSON.stringify(value), "PX", ttlMs);
}
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;
public getSession(id: string) {
return this.redis.get(`${SESSION_PREFIX}${id}`);
}
public async deleteSession(id: string) {
await this.redis.del(`${SESSION_PREFIX}${id}`);
}
public async appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number) {
const key = `${JOURNAL_PREFIX}${id}`;
await this.redis
.multi()
.lpush(key, JSON.stringify(value))
.ltrim(key, 0, maxEntries - 1)
.pexpire(key, ttlMs)
.exec();
}
public getJournal(id: string, maxEntries: number) {
return this.redis.lrange(`${JOURNAL_PREFIX}${id}`, 0, maxEntries - 1);
}
}
let redisClient: RedisClient | null | undefined;
const localSessions = new Map<string, CustomWidgetPreviewSession>();
const localJournals = new Map<string, CustomWidgetPreviewJournalEntry[]>();
let service: CustomWidgetPreviewSessionService | undefined;
function getService() {
if (service) return service;
const useLocal = process.env.CI !== undefined || process.env.NODE_ENV === "test";
service = new CustomWidgetPreviewSessionService({
createId,
encrypt: encryptSecret,
decrypt: (value) => decryptSecret(value as `${string}.${string}`),
store: useLocal ? undefined : new RedisPreviewSessionStore(createRedisClient()),
});
return service;
}
const getRedisClient = () => {
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return null;
redisClient ??= createRedisClient();
return redisClient;
};
const sessionKey = (id: string) => `${PREVIEW_SESSION_PREFIX}${id}`;
const journalKey = (id: string) => `${PREVIEW_JOURNAL_PREFIX}${id}`;
const saveSession = async (session: CustomWidgetPreviewSession) => {
const ttlMs = Math.max(0, session.expiresAt - Date.now());
if (ttlMs === 0) throw new TRPCError({ code: "NOT_FOUND", message: "Preview session expired" });
const redis = getRedisClient();
if (!redis) {
localSessions.set(session.id, session);
return;
async function call<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
toTrpcError(error);
}
await redis.set(sessionKey(session.id), JSON.stringify(session), "PX", ttlMs);
};
}
export const createPreviewSession = async (input: CreatePreviewSessionInput) => {
const session: CustomWidgetPreviewSession = {
id: createId(),
userId: input.userId,
expiresAt: Date.now() + PREVIEW_SESSION_TTL_MS,
baseUrl: input.baseUrl,
authType: input.authType,
headerName: input.headerName ?? null,
secrets: input.secrets.map((secret) => ({ kind: secret.kind, value: encryptSecret(secret.value) })),
networkScope: input.networkScope,
requests: input.requests,
definitionId: input.definitionId,
liveActions: false,
};
await saveSession(session);
return { id: session.id, expiresAt: session.expiresAt, liveActions: false as const };
};
export type { CreatePreviewSessionInput, CustomWidgetPreviewJournalEntry, CustomWidgetPreviewSession };
export const getPreviewSession = async (id: string, userId: string): Promise<CustomWidgetPreviewSession> => {
const redis = getRedisClient();
const raw = redis ? await redis.get(sessionKey(id)) : localSessions.get(id);
let candidate: unknown = raw;
if (typeof raw === "string") {
try {
candidate = JSON.parse(raw) as unknown;
} catch {
candidate = null;
}
}
const parsed = storedPreviewSessionSchema.safeParse(candidate);
if (!parsed.success || parsed.data.userId !== userId || parsed.data.expiresAt <= Date.now()) {
if (!redis) localSessions.delete(id);
throw new TRPCError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
}
return parsed.data;
};
export const setPreviewSessionLiveActions = async (id: string, userId: string, enabled: boolean) => {
const session = await getPreviewSession(id, userId);
const updated = { ...session, liveActions: enabled };
await saveSession(updated);
return { id, expiresAt: updated.expiresAt, liveActions: enabled };
};
export const appendPreviewJournal = async (
export const createPreviewSession = (input: CreatePreviewSessionInput) => call(() => getService().create(input));
export const getPreviewSession = (id: string, userId: string) => call(() => getService().get(id, userId));
export const setPreviewSessionLiveActions = (id: string, userId: string, enabled: boolean) =>
call(() => getService().setLiveActions(id, userId, enabled));
export const appendPreviewJournal = (
session: CustomWidgetPreviewSession,
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
) => {
const value = previewJournalEntrySchema.parse({ ...entry, id: createId(), timestamp: Date.now() });
const redis = getRedisClient();
if (!redis) {
localJournals.set(
session.id,
[value, ...(localJournals.get(session.id) ?? [])].slice(0, MAX_PREVIEW_JOURNAL_ENTRIES),
);
return;
}
const ttlMs = Math.max(1, session.expiresAt - Date.now());
await redis
.multi()
.lpush(journalKey(session.id), JSON.stringify(value))
.ltrim(journalKey(session.id), 0, MAX_PREVIEW_JOURNAL_ENTRIES - 1)
.pexpire(journalKey(session.id), ttlMs)
.exec();
};
export const getPreviewJournal = async (id: string, userId: string) => {
await getPreviewSession(id, userId);
const redis = getRedisClient();
const entries = redis
? await redis.lrange(journalKey(id), 0, MAX_PREVIEW_JOURNAL_ENTRIES - 1)
: (localJournals.get(id) ?? []);
return entries.flatMap((entry) => {
let candidate: unknown = entry;
if (typeof entry === "string") {
try {
candidate = JSON.parse(entry) as unknown;
} catch {
return [];
}
}
const parsed = previewJournalEntrySchema.safeParse(candidate);
return parsed.success ? [parsed.data] : [];
});
};
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession) =>
session.secrets.map((secret) => ({
kind: secret.kind,
value: decryptSecret(secret.value as `${string}.${string}`),
}));
) => call(() => getService().appendJournal(session, entry));
export const getPreviewJournal = (id: string, userId: string) => call(() => getService().getJournal(id, userId));
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession) => getService().getSecrets(session);
@@ -1,429 +1,83 @@
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
import { TRPCError } from "@trpc/server";
import { Agent, fetch, Headers } from "undici";
import type { LookupFunction } from "node:net";
import type { Response } from "undici";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "@homarr/validation/custom-widget";
import {
assertJsonBudget as assertDomainJsonBudget,
assertSafeStaticHeaders as assertDomainSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest as executeDomainRequest,
MAX_REQUEST_BODY_BYTES,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
MAX_RESPONSE_JSON_NODES,
resolveAndValidateHost as resolveDomainHost,
resolveSameOriginTarget as resolveDomainTarget,
validateCustomWidgetUrl as validateDomainUrl,
} from "@homarr/custom-widgets/server";
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "@homarr/custom-widgets/server";
import { applyAuth } from "./auth";
import { toTrpcError } from "./domain-error";
export type {
CustomWidgetAuthConfig,
CustomWidgetHttpRequest,
CustomWidgetHttpResponse,
} from "@homarr/custom-widgets/server";
export {
classifyAddress,
MAX_REQUEST_BODY_BYTES,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
MAX_RESPONSE_JSON_NODES,
};
const logger = createLogger({ module: "custom-widget:http" });
export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
export const MAX_RESPONSE_JSON_DEPTH = 32;
export const MAX_RESPONSE_JSON_NODES = 50_000;
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_QUERY_REDIRECTS = 3;
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 alwaysBlockedAddresses = new BlockList();
alwaysBlockedAddresses.addSubnet("0.0.0.0", 8, "ipv4");
alwaysBlockedAddresses.addSubnet("100.64.0.0", 10, "ipv4");
alwaysBlockedAddresses.addSubnet("169.254.0.0", 16, "ipv4");
alwaysBlockedAddresses.addSubnet("192.0.0.0", 24, "ipv4");
alwaysBlockedAddresses.addSubnet("192.0.2.0", 24, "ipv4");
alwaysBlockedAddresses.addSubnet("198.18.0.0", 15, "ipv4");
alwaysBlockedAddresses.addSubnet("198.51.100.0", 24, "ipv4");
alwaysBlockedAddresses.addSubnet("203.0.113.0", 24, "ipv4");
alwaysBlockedAddresses.addSubnet("224.0.0.0", 4, "ipv4");
alwaysBlockedAddresses.addSubnet("240.0.0.0", 4, "ipv4");
alwaysBlockedAddresses.addAddress("::", "ipv6");
alwaysBlockedAddresses.addSubnet("64:ff9b::", 96, "ipv6");
alwaysBlockedAddresses.addSubnet("100::", 64, "ipv6");
alwaysBlockedAddresses.addSubnet("2001:db8::", 32, "ipv6");
alwaysBlockedAddresses.addSubnet("fe80::", 10, "ipv6");
alwaysBlockedAddresses.addSubnet("ff00::", 8, "ipv6");
alwaysBlockedAddresses.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;
type ResolvedAddress = { address: string; family: AddressFamily };
type AddressClass = "public" | "private" | "loopback" | "blocked";
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;
}
export interface CustomWidgetHttpResponse {
ok: boolean;
status: number;
statusText: string;
data: unknown;
}
const responseCache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
const inFlightRequests = new Map<string, Promise<CustomWidgetHttpResponse>>();
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
const pruneResponseCache = () => {
const now = Date.now();
for (const [key, entry] of responseCache) {
if (entry.expiresAt <= now) responseCache.delete(key);
}
while (responseCache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
const oldestKey = responseCache.keys().next().value as string | undefined;
if (!oldestKey) break;
responseCache.delete(oldestKey);
}
};
const normalizeHostname = (hostname: string) =>
hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
const familyName = (family: AddressFamily) => (family === 4 ? "ipv4" : "ipv6");
export const classifyAddress = (address: string): AddressClass => {
const normalized = normalizeHostname(address).toLowerCase();
const family = isIP(normalized) as AddressFamily | 0;
if (family === 0) return "blocked";
if (normalized.includes("::ffff:")) return "blocked";
if (alwaysBlockedAddresses.check(normalized, familyName(family))) return "blocked";
if (loopbackAddresses.check(normalized, familyName(family))) return "loopback";
if (privateAddresses.check(normalized, familyName(family))) return "private";
return "public";
};
const isAddressAllowed = (classification: AddressClass, scope: CustomJsxNetworkScope) => {
if (classification === "blocked") return false;
if (classification === "public") return true;
if (classification === "private") return scope === "private" || scope === "loopback";
return scope === "loopback";
};
export const resolveAndValidateHost = async (
hostname: string,
scope: CustomJsxNetworkScope,
): Promise<ResolvedAddress[]> => {
const normalized = normalizeHostname(hostname);
const literalFamily = isIP(normalized) as AddressFamily | 0;
const addresses = literalFamily
? [{ address: normalized, family: literalFamily }]
: ((await lookup(normalized, { all: true, verbatim: true })) as ResolvedAddress[]);
if (addresses.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
}
for (const resolved of addresses) {
const classification = classifyAddress(resolved.address);
if (!isAddressAllowed(classification, scope)) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Target address is not allowed by the ${scope} network scope`,
});
}
}
return addresses;
};
export const validateCustomWidgetUrl = (value: string | URL): URL => {
let url: URL;
export function assertJsonBudget(value: unknown): void {
try {
url = value instanceof URL ? new URL(value) : new URL(value);
} catch {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid custom widget URL" });
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only HTTP and HTTPS URLs are allowed" });
}
if (url.username || url.password) {
throw new TRPCError({ code: "BAD_REQUEST", message: "URL credentials are not allowed" });
}
if (url.hash) {
throw new TRPCError({ code: "BAD_REQUEST", message: "URL fragments are not allowed" });
}
return url;
};
export const resolveSameOriginTarget = (baseUrlValue: string, targetUrlValue?: string | URL): URL => {
const baseUrl = validateCustomWidgetUrl(baseUrlValue);
const targetUrl = validateCustomWidgetUrl(targetUrlValue ?? baseUrl);
if (targetUrl.origin !== baseUrl.origin) {
throw new TRPCError({ code: "FORBIDDEN", message: "Named requests must stay on the widget's origin" });
}
return targetUrl;
};
export const assertSafeStaticHeaders = (headers: Record<string, string> | undefined): void => {
for (const name of Object.keys(headers ?? {})) {
const normalized = name.trim().toLowerCase();
if (
RESERVED_HEADERS.has(normalized) ||
normalized.startsWith("proxy-") ||
normalized.startsWith("sec-") ||
normalized.startsWith("x-forwarded-")
) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Header '${name}' is reserved` });
}
}
};
const createPinnedAgent = (addresses: ResolvedAddress[]) => {
const customLookup: LookupFunction = (_hostname, options, callback) => {
const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined;
const candidates = requestedFamily ? addresses.filter((address) => address.family === requestedFamily) : 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);
return;
}
if (options.all) {
callback(null, candidates);
return;
}
callback(null, selected.address, selected.family);
};
return new Agent({
connect: { lookup: customLookup },
connectTimeout: REQUEST_TIMEOUT_MS,
headersTimeout: REQUEST_TIMEOUT_MS,
bodyTimeout: REQUEST_TIMEOUT_MS,
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
});
};
const readLimitedBody = async (response: Response): Promise<string> => {
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BODY_BYTES) {
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
}
if (!response.body) return "";
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
totalBytes += chunk.value.byteLength;
if (totalBytes > MAX_RESPONSE_BODY_BYTES) {
await reader.cancel();
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
}
chunks.push(chunk.value);
}
const bytes = new Uint8Array(totalBytes);
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 const assertJsonBudget = (value: unknown): void => {
let nodes = 0;
const visit = (current: unknown, depth: number): void => {
nodes += 1;
if (nodes > MAX_RESPONSE_JSON_NODES) {
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too large" });
}
if (depth > MAX_RESPONSE_JSON_DEPTH) {
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too deeply nested" });
}
if (Array.isArray(current)) {
for (const entry of current) visit(entry, depth + 1);
return;
}
if (current !== null && typeof current === "object") {
for (const entry of Object.values(current)) visit(entry, depth + 1);
}
};
visit(value, 0);
};
const parseResponseBody = async (response: Response): Promise<unknown> => {
const text = await readLimitedBody(response);
if (text === "") return null;
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
if (contentType.includes("json")) {
let json: unknown;
try {
json = JSON.parse(text) as unknown;
} catch {
throw new TRPCError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
}
assertJsonBudget(json);
return json;
}
try {
const json = JSON.parse(text) as unknown;
assertJsonBudget(json);
return json;
assertDomainJsonBudget(value);
} catch (error) {
if (error instanceof TRPCError) throw error;
return text;
toTrpcError(error);
}
};
}
const performRequest = async (input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> => {
if (input.kind === "query" && input.method !== "GET") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Queries must use GET" });
export function assertSafeStaticHeaders(headers: Record<string, string> | undefined): void {
try {
assertDomainSafeStaticHeaders(headers);
} catch (error) {
toTrpcError(error);
}
if (input.kind === "action" && input.method === "GET") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Actions cannot use GET" });
}
export function validateCustomWidgetUrl(value: string | URL): URL {
try {
return validateDomainUrl(value);
} catch (error) {
toTrpcError(error);
}
if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES) {
throw new TRPCError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" });
}
export function resolveSameOriginTarget(baseUrl: string, target?: string | URL): URL {
try {
return resolveDomainTarget(baseUrl, target);
} catch (error) {
toTrpcError(error);
}
assertSafeStaticHeaders(input.staticHeaders);
}
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
const initialOrigin = baseUrl.origin;
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
for (let redirectCount = 0; ; redirectCount += 1) {
const addresses = await resolveAndValidateHost(currentUrl.hostname, input.networkScope);
const dispatcher = createPinnedAgent(addresses);
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, currentUrl, input.auth.type, input.auth.secrets, input.auth.headerName);
}
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 TRPCError) throw error;
logger.error("Custom widget request failed", {
origin: currentUrl.origin,
method: input.method,
errorName: error instanceof Error ? error.name : "UnknownError",
});
throw new TRPCError({
code: "BAD_GATEWAY",
message: error instanceof Error ? error.message : "External request failed",
});
} finally {
clearTimeout(timeout);
}
const isRedirect = [301, 302, 303, 307, 308].includes(response.status);
if (!isRedirect) {
try {
const data = await parseResponseBody(response);
return { ok: response.ok, status: response.status, statusText: response.statusText, data };
} finally {
await dispatcher.close();
}
}
await response.body?.cancel();
await dispatcher.close();
if (redirectCount >= maxRedirects) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
}
const location = response.headers.get("location");
if (!location) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Upstream redirect is missing a location" });
}
const redirected = validateCustomWidgetUrl(new URL(location, currentUrl));
if (redirected.origin !== initialOrigin) {
throw new TRPCError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
}
currentUrl = redirected;
export async function resolveAndValidateHost(hostname: string, scope: CustomWidgetHttpRequest["networkScope"]) {
try {
return await resolveDomainHost(hostname, scope);
} catch (error) {
toTrpcError(error);
}
};
}
export const executeCustomWidgetRequest = async (input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> => {
const cacheKey = input.method === "GET" ? input.cacheKey : undefined;
if (!cacheKey) return performRequest(input);
const cached = responseCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.response;
if (cached) responseCache.delete(cacheKey);
const inFlight = inFlightRequests.get(cacheKey);
if (inFlight) return inFlight;
const promise = performRequest(input)
.then((response) => {
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0) {
pruneResponseCache();
responseCache.set(cacheKey, {
expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000,
response,
});
}
return response;
})
.finally(() => inFlightRequests.delete(cacheKey));
inFlightRequests.set(cacheKey, promise);
return promise;
};
export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
try {
return await executeDomainRequest({
...input,
logError: (event) => logger.error("Custom widget request failed", event),
});
} catch (error) {
toTrpcError(error);
}
}
@@ -1,168 +1,74 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { FlattenError } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createRedisClient } from "@homarr/core/infrastructure/redis";
import type { RedisClient } from "@homarr/core/infrastructure/redis";
import { CustomWidgetRequestLimiter } from "@homarr/custom-widgets/server";
import type { RequestLimitInput, RequestLimitStore } from "@homarr/custom-widgets/server";
import { toTrpcError } from "./domain-error";
const logger = createLogger({ module: "custom-widget:limits" });
const WINDOW_MS = 60_000;
const CONCURRENCY_TTL_MS = 30_000;
const categoryLimits = {
query: 60,
action: 10,
delete: 3,
} as const;
type RequestCategory = keyof typeof categoryLimits;
export interface RequestLimitInput {
category: RequestCategory;
userId?: string;
itemId: string;
definitionId: string;
}
class RequestLimitError extends FlattenError {
constructor(message: string, retryAfterMs: number) {
super(message, { retryAfterMs });
}
}
let redisClient: RedisClient | null | undefined;
const getRedisClient = () => {
if (process.env.CI !== undefined || process.env.NODE_ENV === "test") return null;
redisClient ??= createRedisClient();
return redisClient;
};
const hashKey = (...parts: string[]) => createHash("sha256").update(parts.join("\0")).digest("hex");
const rateScript = `
const RATE_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return { count, redis.call('PTTL', KEYS[1]) }
`;
const concurrencyScript = `
const ACQUIRE_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
if count > tonumber(ARGV[1]) then
redis.call('DECR', KEYS[1])
return 0
end
if count > tonumber(ARGV[1]) then redis.call('DECR', KEYS[1]); return 0 end
return count
`;
const releaseConcurrencyScript = `
const RELEASE_SCRIPT = `
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
if count <= 1 then
redis.call('DEL', KEYS[1])
return 0
end
if count <= 1 then redis.call('DEL', KEYS[1]); return 0 end
return redis.call('DECR', KEYS[1])
`;
const localRateBuckets = new Map<string, number[]>();
const localConcurrency = new Map<string, number>();
class RedisRequestLimitStore implements RequestLimitStore {
public constructor(private readonly redis: RedisClient) {}
const throwLimitExceeded = (retryAfterMs: number): never => {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Custom widget request limit exceeded",
cause: new RequestLimitError("Custom widget request limit exceeded", Math.max(1, retryAfterMs)),
});
};
const enforceLocalRateLimit = (key: string, limit: number) => {
const now = Date.now();
const recent = (localRateBuckets.get(key) ?? []).filter((timestamp) => now - timestamp < WINDOW_MS);
if (recent.length >= limit) {
throwLimitExceeded(WINDOW_MS - (now - (recent[0] ?? now)));
}
recent.push(now);
localRateBuckets.set(key, recent);
};
const acquireLocalConcurrency = (key: string, limit: number) => {
const current = localConcurrency.get(key) ?? 0;
if (current >= limit) throwLimitExceeded(1_000);
localConcurrency.set(key, current + 1);
};
const releaseLocalConcurrency = (key: string) => {
const current = localConcurrency.get(key) ?? 0;
if (current <= 1) localConcurrency.delete(key);
else localConcurrency.set(key, current - 1);
};
const enforceRedisRateLimit = async (redis: RedisClient, key: string, limit: number) => {
const result = (await redis.eval(rateScript, 1, key, WINDOW_MS)) as [number, number];
const [count, ttl] = result.map(Number) as [number, number];
if (count > limit) throwLimitExceeded(ttl > 0 ? ttl : WINDOW_MS);
};
const acquireRedisConcurrency = async (redis: RedisClient, key: string, limit: number) => {
const count = Number(await redis.eval(concurrencyScript, 1, key, limit, CONCURRENCY_TTL_MS));
if (count === 0) throwLimitExceeded(1_000);
};
const releaseRedisConcurrency = async (redis: RedisClient, key: string) => {
await redis.eval(releaseConcurrencyScript, 1, key);
};
export const acquireCustomWidgetRequestLimit = async (input: RequestLimitInput): Promise<() => Promise<void>> => {
const identity = input.userId ?? "anonymous";
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
const userConcurrencyKey = `custom-widget:concurrency:user-item:${hashKey(identity, input.itemId)}`;
const definitionConcurrencyKey = `custom-widget:concurrency:definition:${hashKey(input.definitionId)}`;
const redis = getRedisClient();
if (!redis) {
enforceLocalRateLimit(rateKey, categoryLimits[input.category]);
acquireLocalConcurrency(userConcurrencyKey, 4);
try {
acquireLocalConcurrency(definitionConcurrencyKey, 8);
} catch (error) {
releaseLocalConcurrency(userConcurrencyKey);
throw error;
}
return async () => {
releaseLocalConcurrency(userConcurrencyKey);
releaseLocalConcurrency(definitionConcurrencyKey);
};
public async incrementRate(key: string, windowMs: number) {
const [count, retryAfterMs] = (await this.redis.eval(RATE_SCRIPT, 1, key, windowMs)) as [number, number];
return { count: Number(count), retryAfterMs: Number(retryAfterMs) || windowMs };
}
try {
await enforceRedisRateLimit(redis, rateKey, categoryLimits[input.category]);
await acquireRedisConcurrency(redis, userConcurrencyKey, 4);
try {
await acquireRedisConcurrency(redis, definitionConcurrencyKey, 8);
} catch (error) {
await releaseRedisConcurrency(redis, userConcurrencyKey);
throw error;
}
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Custom widget rate limiter is unavailable", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Request limiter is unavailable" });
public async acquireConcurrency(key: string, limit: number, ttlMs: number) {
return Number(await this.redis.eval(ACQUIRE_SCRIPT, 1, key, limit, ttlMs)) > 0;
}
return async () => {
try {
await Promise.all([
releaseRedisConcurrency(redis, userConcurrencyKey),
releaseRedisConcurrency(redis, definitionConcurrencyKey),
]);
} catch (error) {
logger.error("Failed to release custom widget request capacity", {
public async releaseConcurrency(key: string) {
await this.redis.eval(RELEASE_SCRIPT, 1, key);
}
}
let limiter: CustomWidgetRequestLimiter | undefined;
function getLimiter() {
if (limiter) return limiter;
const useLocal = process.env.CI !== undefined || process.env.NODE_ENV === "test";
const store = useLocal ? undefined : new RedisRequestLimitStore(createRedisClient());
limiter = new CustomWidgetRequestLimiter({
store,
onStoreError: (error) =>
logger.error("Custom widget rate limiter is unavailable", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
}
};
};
}),
});
return limiter;
}
export async function acquireCustomWidgetRequestLimit(input: RequestLimitInput): Promise<() => Promise<void>> {
try {
const release = await getLimiter().acquire(input);
return async () => {
try {
await release();
} catch (error) {
logger.error("Failed to release custom widget request capacity", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
}
};
} catch (error) {
toTrpcError(error);
}
}
@@ -1,70 +1,37 @@
import { createHash } from "node:crypto";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import {
hashRuntimeParams,
renderRequestBody as renderDomainBody,
renderRequestTarget as renderDomainTarget,
validateRuntimeParams as validateDomainParams,
} from "@homarr/custom-widgets/server";
import type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
import { TRPCError } from "@trpc/server";
import { toTrpcError } from "./domain-error";
import type { CustomJsxRequest } from "@homarr/validation/custom-widget";
export type { CustomJsxRuntimeParams } from "@homarr/custom-widgets/server";
export { hashRuntimeParams };
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 TRPCError({ code: "BAD_REQUEST", message: "Request parameters do not match the manifest" });
export function validateRuntimeParams(request: CustomJsxRequest, params: CustomJsxRuntimeParams): void {
try {
validateDomainParams(request, params);
} catch (error) {
toTrpcError(error);
}
for (const [name, expectedType] of Object.entries(request.parameters)) {
if (typeof params[name] !== expectedType) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Request parameter '${name}' must be ${expectedType}` });
}
}
export function renderRequestBody(request: CustomJsxRequest["bodyTemplate"], params: CustomJsxRuntimeParams) {
try {
return renderDomainBody(request, params);
} catch (error) {
toTrpcError(error);
}
};
}
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 TRPCError({ 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 TRPCError({ code: "BAD_REQUEST", message: `Unknown path parameter '${name}'` });
}
return encodeURIComponent(String(value));
});
if (/[{}]/.test(path)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Path template contains an invalid placeholder" });
export function renderRequestTarget(baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) {
try {
return renderDomainTarget(baseUrl, request, params);
} catch (error) {
toTrpcError(error);
}
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,226 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { stringify as stringifySuperJson } from "superjson";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { displayConfigSchema } from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { parseDisplayConfig } from "./parse-display-config";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const logger = createLogger({ module: "custom-widget" });
const getTemplateRevision = (template: string) => createHash("sha256").update(template).digest("hex").slice(0, 16);
const validateUpdatedTemplate = (displayConfig: Record<string, unknown>, template: string) => {
displayConfig.template = template;
const result = displayConfigSchema.safeParse(displayConfig);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: result.error.issues
.map((issue) => `${issue.path.map(String).join(".") || "template"}: ${issue.message}`)
.join("; "),
});
}
return result.data;
};
export const templateProcedures = {
readTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Read the JSX template of a custom widget definition as plain text. Returns the template string separately from the full widget config, making it easier to inspect and edit.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
const config = parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget readTemplate",
);
if (config.type !== "customJsx") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Widget is not using customJsx display type",
});
}
const template = (config.template as string | undefined) ?? "";
return {
id: definition.id,
name: definition.name,
template,
templateLines: template.split("\n"),
revision: getTemplateRevision(template),
};
}),
writeTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.",
},
})
.input(
z
.object({
id: z.string(),
template: z.string().optional(),
templateLines: z.array(z.string()).optional(),
})
.refine((data) => data.template !== undefined || data.templateLines !== undefined, {
message: "Provide either template or templateLines",
})
.refine((data) => !(data.template !== undefined && data.templateLines !== undefined), {
message: "Provide template or templateLines, not both",
}),
)
.mutation(async ({ ctx, input }) => {
const resolvedTemplate =
input.templateLines !== undefined ? input.templateLines.join("\n") : (input.template ?? "");
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
const displayConfig = parseDisplayConfig(
existing.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget writeTemplate",
);
if (displayConfig.type !== "customJsx") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Widget is not using customJsx display type",
});
}
if (resolvedTemplate.trim().length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template must not be empty" });
}
if (resolvedTemplate.length > 50_000) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template exceeds the 50,000 character limit" });
}
const validatedConfig = validateUpdatedTemplate(displayConfig, resolvedTemplate);
await ctx.db
.update(customWidgetDefinitions)
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.id));
logger.info("Updated custom widget template", { id: input.id });
return {
id: input.id,
template: resolvedTemplate,
templateLines: resolvedTemplate.split("\n"),
revision: getTemplateRevision(resolvedTemplate),
};
}),
patchTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.",
},
})
.input(
z.object({
id: z.string(),
expectedRevision: z.string().length(16),
edits: z
.array(
z.object({
startLine: z.number().int().min(1),
deleteCount: z.number().int().min(0),
replacementLines: z.array(z.string()),
}),
)
.min(1)
.max(100),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const displayConfig = parseDisplayConfig(
existing.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget patchTemplate",
);
if (displayConfig.type !== "customJsx") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Widget is not using customJsx display type" });
}
const currentTemplate = (displayConfig.template as string | undefined) ?? "";
if (getTemplateRevision(currentTemplate) !== input.expectedRevision) {
throw new TRPCError({
code: "CONFLICT",
message: "Template changed since it was read. Read the template again and retry the patch.",
});
}
const lines = currentTemplate.split("\n");
const edits = input.edits.toSorted((left, right) => right.startLine - left.startLine);
let nextHigherStart = lines.length + 2;
for (const edit of edits) {
if (edit.startLine > lines.length + 1 || edit.startLine + edit.deleteCount - 1 > lines.length) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Edit at line ${edit.startLine} is out of range` });
}
if (edit.startLine + Math.max(edit.deleteCount, 1) > nextHigherStart) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Template patch edits must not overlap" });
}
nextHigherStart = edit.startLine;
lines.splice(edit.startLine - 1, edit.deleteCount, ...edit.replacementLines);
}
const template = lines.join("\n");
const validatedConfig = validateUpdatedTemplate(displayConfig, template);
await ctx.db
.update(customWidgetDefinitions)
.set({ displayConfig: stringifySuperJson(validatedConfig), updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.id));
logger.info("Patched custom widget template", { id: input.id, editCount: input.edits.length });
return {
id: input.id,
template,
templateLines: lines,
revision: getTemplateRevision(template),
};
}),
};
@@ -0,0 +1,80 @@
import { TRPCError } from "@trpc/server";
import { stringify as stringifySuperJson } from "superjson";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
import { parseDisplayConfig } from "./parse-display-config";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const logger = createLogger({ module: "custom-widget" });
export const transferProcedures = {
export: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Export one custom widget as a homarr-custom-widget-v3 object. REQUIRED: id. Secrets are excluded.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND" });
return {
$schema: "homarr-custom-widget-v3" as const,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: definition.requestBody,
displayType: definition.displayType,
displayConfig: parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig during export",
),
};
}),
import: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Create a custom widget from a validated homarr-custom-widget-v3 import object. Secrets are omitted and configured separately.",
},
})
.input(customWidgetImportSchema)
.mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
url: input.url,
authType: input.authType,
headerName: input.headerName,
method: input.method,
requestBody: input.requestBody,
displayType: input.displayType,
displayConfig: stringifySuperJson(input.displayConfig),
creatorId: ctx.session.user.id,
});
logger.info("Imported custom widget definition", { id, name: input.name });
return { id };
}),
};
@@ -1,252 +0,0 @@
import { describe, expect, it } from "vitest";
import { JSONPath } from "jsonpath-plus";
import {
sonarrResponse,
sonarrStatGridConfig,
proxmoxResponse,
proxmoxProgressBarsConfig,
diskUsageResponse,
diskTableConfig,
piholeResponse,
piholeStatusIndicatorConfig,
piholeCountGridConfig,
radarrResponse,
radarrStatGridConfig,
jellyfinItemCountsResponse,
jellyfinCountGridConfig,
} from "./custom-api-fixtures";
const extractors: Record<string, (json: unknown, config: Record<string, unknown>) => unknown> = {
singleValue: (json, c) => ({
type: "singleValue",
label: (c.label as string) ?? "",
unit: (c.unit as string) ?? "",
value: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
valueSize: c.valueSize ?? "lg",
labelPosition: c.labelPosition ?? "below",
}),
keyValue: (json, c) => ({
type: "keyValue",
entries: ((c.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((m) => ({
label: m.label,
unit: m.unit,
value: JSONPath({ path: m.jsonPath, json: json as object, wrap: false }),
})),
layout: c.layout ?? "list",
columns: c.columns ?? 2,
}),
table: (json, c) => {
const tablePath = (c.tablePath as string) ?? "$";
const columns = (c.columns as Array<{ header: string; jsonPath: string }>) ?? [];
const rows = JSONPath({ path: tablePath, json: json as object, wrap: true }) as unknown[];
const flatRows = Array.isArray(rows[0]) ? (rows[0] as unknown[]) : rows;
return {
type: "table",
columns: columns.map((col) => col.header),
rows: flatRows.map((row) =>
columns.map((col) => JSONPath({ path: col.jsonPath, json: row as object, wrap: false })),
),
striped: c.striped ?? true,
compact: c.compact ?? false,
};
},
statGrid: (json, c) => ({
type: "statGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []).map(
(item) => ({
label: item.label,
unit: item.unit,
color: item.color ?? "blue",
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
}),
),
columns: c.columns ?? 2,
cardStyle: c.cardStyle ?? "filled",
}),
progressBars: (json, c) => ({
type: "progressBars",
bars: (
(c.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ?? []
).map((bar) => {
const value = JSONPath({ path: bar.valuePath, json: json as object, wrap: false });
const max = bar.maxPath ? JSONPath({ path: bar.maxPath, json: json as object, wrap: false }) : undefined;
return {
label: bar.label,
unit: bar.unit,
color: bar.color ?? "blue",
value: Number(value) || 0,
max: max !== undefined ? Number(max) || 100 : undefined,
};
}),
showPercentage: c.showPercentage ?? true,
barSize: c.barSize ?? "md",
}),
statusIndicator: (json, c) => ({
type: "statusIndicator",
items: ((c.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map((item) => {
const value = JSONPath({ path: item.jsonPath, json: json as object, wrap: false });
const isGood = item.goodValues.some((gv) => String(value).toLowerCase() === gv.toLowerCase());
return { label: item.label, value: String(value ?? "unknown"), isGood };
}),
layout: c.layout ?? "list",
dotSize: c.dotSize ?? "md",
}),
countGrid: (json, c) => ({
type: "countGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((item) => ({
label: item.label,
unit: item.unit,
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
})),
columns: c.columns ?? 2,
valueSize: c.valueSize ?? "md",
}),
raw: (json, c) => ({
type: "raw",
data: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
maxHeight: c.maxHeight ?? 300,
}),
customJsx: (json, config) => ({
type: "customJsx" as const,
template: config.template as string,
data: json,
}),
};
function extract(type: string, json: unknown, config: Record<string, unknown>) {
const fn = extractors[type];
if (!fn) throw new Error(`No extractor for type: ${type}`);
return fn(json, config);
}
describe("custom-api extractors", () => {
it("sonarr statGrid extracts missing and queue counts", () => {
const result = extract("statGrid", sonarrResponse, sonarrStatGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; color: string }>;
};
expect(result.type).toBe("statGrid");
expect(result.items).toHaveLength(2);
expect(result.items[0]?.value).toBe(42);
expect(result.items[0]?.label).toBe("Missing");
expect(result.items[0]?.color).toBe("red");
expect(result.items[1]?.value).toBe(5);
});
it("proxmox progressBars extracts memory and disk usage", () => {
const result = extract("progressBars", proxmoxResponse, proxmoxProgressBarsConfig) as {
type: string;
bars: Array<{ label: string; value: number; max: number }>;
};
expect(result.type).toBe("progressBars");
expect(result.bars).toHaveLength(2);
expect(result.bars[0]?.value).toBe(12_884_901_888);
expect(result.bars[0]?.max).toBe(34_359_738_368);
expect(result.bars[1]?.label).toBe("Disk");
});
it("disk table extracts filesystem rows", () => {
const result = extract("table", diskUsageResponse, diskTableConfig) as {
type: string;
columns: string[];
rows: unknown[][];
};
expect(result.type).toBe("table");
expect(result.columns).toEqual(["Mount", "Size", "Used"]);
expect(result.rows).toHaveLength(3);
expect(result.rows[0]?.[0]).toBe("/");
expect(result.rows[2]?.[2]).toBe("95%");
});
it("pihole statusIndicator detects enabled state", () => {
const result = extract("statusIndicator", piholeResponse, piholeStatusIndicatorConfig) as {
type: string;
items: Array<{ label: string; value: string; isGood: boolean }>;
};
expect(result.type).toBe("statusIndicator");
expect(result.items).toHaveLength(1);
expect(result.items[0]?.isGood).toBe(true);
expect(result.items[0]?.value).toBe("enabled");
});
it("pihole countGrid extracts all 4 stats", () => {
const result = extract("countGrid", piholeResponse, piholeCountGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; unit: string }>;
};
expect(result.type).toBe("countGrid");
expect(result.items).toHaveLength(4);
expect(result.items[0]?.value).toBe(12_847);
expect(result.items[1]?.value).toBe(48_291);
expect(result.items[2]?.value).toBe(26.6);
expect(result.items[2]?.unit).toBe("%");
expect(result.items[3]?.value).toBe(14);
});
it("radarr statGrid with outline card style", () => {
const result = extract("statGrid", radarrResponse, radarrStatGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown }>;
cardStyle: string;
};
expect(result.type).toBe("statGrid");
expect(result.cardStyle).toBe("outline");
expect(result.items[0]?.value).toBe(15);
expect(result.items[1]?.value).toBe(3);
});
it("raw extractor returns raw data at path", () => {
const result = extract("raw", piholeResponse, { type: "raw", jsonPath: "$.status", maxHeight: 200 }) as {
type: string;
data: unknown;
maxHeight: number;
};
expect(result.type).toBe("raw");
expect(result.data).toBe("enabled");
expect(result.maxHeight).toBe(200);
});
it("customJsx passes full JSON and template through", () => {
const json = { title: "Status", items: [{ name: "CPU", value: 42 }] };
const config = {
type: "customJsx" as const,
template: "<Stack><Title>{data.title}</Title></Stack>",
};
const result = extract("customJsx", json, config) as {
type: string;
template: string;
data: typeof json;
};
expect(result.type).toBe("customJsx");
expect(result.template).toBe(config.template);
expect(result.data).toEqual(json);
});
it("jellyfin countGrid extracts movies/series/episodes/songs in 4-column grid", () => {
const result = extract("countGrid", jellyfinItemCountsResponse, jellyfinCountGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; unit: string }>;
columns: number;
valueSize: string;
};
expect(result.type).toBe("countGrid");
expect(result.columns).toBe(4);
expect(result.valueSize).toBe("lg");
expect(result.items).toHaveLength(4);
expect(result.items[0]).toEqual({ label: "Movies", value: 185, unit: "" });
expect(result.items[1]).toEqual({ label: "Series", value: 38, unit: "" });
expect(result.items[2]).toEqual({ label: "Episodes", value: 770, unit: "" });
expect(result.items[3]).toEqual({ label: "Songs", value: 0, unit: "" });
});
});
@@ -1,142 +0,0 @@
export const sonarrResponse = {
wanted: { totalRecords: 42 },
queue: { totalRecords: 5 },
series: [
{ title: "Breaking Bad", seasons: 5, episodeCount: 62, episodeFileCount: 60 },
{ title: "The Wire", seasons: 5, episodeCount: 60, episodeFileCount: 55 },
],
diskspace: [
{ path: "/tv", label: "TV Shows", freeSpace: 150_000_000_000, totalSpace: 500_000_000_000 },
{ path: "/movies", label: "Movies", freeSpace: 80_000_000_000, totalSpace: 250_000_000_000 },
],
};
export const sonarrStatGridConfig = {
type: "statGrid" as const,
items: [
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "blue" },
],
columns: 2,
cardStyle: "filled" as const,
};
export const proxmoxResponse = {
data: {
cpu: 0.23,
memory: { used: 12_884_901_888, total: 34_359_738_368 },
rootfs: { used: 42_949_672_960, total: 107_374_182_400 },
uptime: 864_000,
status: "running",
maxcpu: 8,
maxmem: 34_359_738_368,
name: "proxmox-node1",
},
};
export const proxmoxProgressBarsConfig = {
type: "progressBars" as const,
bars: [
{ label: "Memory", valuePath: "$.data.memory.used", maxPath: "$.data.memory.total", unit: "B", color: "blue" },
{ label: "Disk", valuePath: "$.data.rootfs.used", maxPath: "$.data.rootfs.total", unit: "B", color: "orange" },
],
showPercentage: true,
barSize: "md" as const,
};
export const diskUsageResponse = {
filesystems: [
{ filesystem: "/dev/sda1", size: "500G", used: "350G", avail: "150G", usePct: "70%", mountedOn: "/" },
{ filesystem: "/dev/sdb1", size: "2T", used: "1.2T", avail: "800G", usePct: "60%", mountedOn: "/data" },
{ filesystem: "/dev/sdc1", size: "4T", used: "3.8T", avail: "200G", usePct: "95%", mountedOn: "/media" },
],
};
export const diskTableConfig = {
type: "table" as const,
tablePath: "$.filesystems",
columns: [
{ header: "Mount", jsonPath: "$.mountedOn" },
{ header: "Size", jsonPath: "$.size" },
{ header: "Used", jsonPath: "$.usePct" },
],
striped: true,
compact: false,
};
export const piholeResponse = {
domains_being_blocked: 125_432,
dns_queries_today: 48_291,
ads_blocked_today: 12_847,
ads_percentage_today: 26.6,
unique_clients: 14,
status: "enabled",
gravity_last_updated: { relative: { days: 2, hours: 3 } },
};
export const piholeStatusIndicatorConfig = {
type: "statusIndicator" as const,
items: [{ label: "Pi-Hole", jsonPath: "$.status", goodValues: ["enabled"] }],
layout: "list" as const,
dotSize: "md" as const,
};
export const piholeCountGridConfig = {
type: "countGrid" as const,
items: [
{ label: "Blocked Today", jsonPath: "$.ads_blocked_today", unit: "" },
{ label: "DNS Queries", jsonPath: "$.dns_queries_today", unit: "" },
{ label: "Block Rate", jsonPath: "$.ads_percentage_today", unit: "%" },
{ label: "Clients", jsonPath: "$.unique_clients", unit: "" },
],
columns: 2,
valueSize: "md" as const,
};
export const radarrResponse = {
wanted: { totalRecords: 15 },
queue: { totalRecords: 3 },
movie: [
{ title: "Inception", hasFile: true, monitored: true, runtime: 148, year: 2010 },
{ title: "Interstellar", hasFile: false, monitored: true, runtime: 169, year: 2014 },
{ title: "The Dark Knight", hasFile: true, monitored: false, runtime: 152, year: 2008 },
],
rootfolder: [{ path: "/movies", freeSpace: 200_000_000_000, totalSpace: 1_000_000_000_000 }],
};
export const radarrStatGridConfig = {
type: "statGrid" as const,
items: [
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "teal" },
],
columns: 2,
cardStyle: "outline" as const,
};
export const jellyfinItemCountsResponse = {
MovieCount: 185,
SeriesCount: 38,
EpisodeCount: 770,
ArtistCount: 0,
ProgramCount: 0,
TrailerCount: 0,
SongCount: 0,
AlbumCount: 0,
MusicVideoCount: 0,
BoxSetCount: 12,
BookCount: 0,
ItemCount: 0,
};
export const jellyfinCountGridConfig = {
type: "countGrid" as const,
items: [
{ label: "Movies", jsonPath: "$.MovieCount", unit: "" },
{ label: "Series", jsonPath: "$.SeriesCount", unit: "" },
{ label: "Episodes", jsonPath: "$.EpisodeCount", unit: "" },
{ label: "Songs", jsonPath: "$.SongCount", unit: "" },
],
columns: 4,
valueSize: "lg" as const,
};
@@ -7,12 +7,16 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import { customJsxDisplayConfigV2Schema, displayConfigSchema } from "@homarr/validation/custom-widget";
import type { CustomJsxRequest, CustomWidgetMethod, DisplayConfig } from "@homarr/validation/custom-widget";
import {
customJsxDisplayConfigV2Schema,
displayConfigSchema,
extractActionButtonDisplay,
extractDisplayDataWithFallback,
} from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetMethod, DisplayConfig } from "@homarr/custom-widgets/core";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
import { executeCustomWidgetRequest } from "../custom-widget/request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "../custom-widget/request-manifest";
import { acquireCustomWidgetRequestLimit } from "../custom-widget/request-limits";
+28
View File
@@ -62,3 +62,31 @@ test("MCP tools should have descriptions", () => {
expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
}
});
test("custom widget public procedures remain available", () => {
expect(Object.keys(customWidgetRouter._def.procedures)).toEqual(
expect.arrayContaining([
"all",
"byId",
"available",
"create",
"update",
"toggleEnabled",
"delete",
"duplicate",
"export",
"import",
"schema",
"validate",
"readTemplate",
"writeTemplate",
"patchTemplate",
"preview",
"previewQuery",
"previewAction",
"simulatePreviewAction",
"setPreviewLiveActions",
"previewJournal",
]),
);
});
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@homarr/custom-widgets",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
"./core": "./src/core/index.ts",
"./jsx": "./src/jsx/index.ts",
"./runtime": "./src/runtime/index.ts",
"./server": "./src/server/index.ts",
"./workbench": "./src/workbench/index.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"check:architecture": "node ./scripts/check-architecture.mjs",
"format": "oxfmt .",
"lint": "oxlint",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@mantine/charts": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dates": "catalog:",
"@mantine/hooks": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/react-query": "catalog:",
"acorn": "catalog:",
"acorn-jsx": "catalog:",
"jsonpath-plus": "catalog:",
"react": "catalog:",
"undici": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"jsdom": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
@@ -0,0 +1,98 @@
import { readdir, readFile } from "node:fs/promises";
import { extname, join, relative, resolve } from "node:path";
const packageRoot = resolve(import.meta.dirname, "..");
const repositoryRoot = resolve(packageRoot, "../..");
const sourceRoots = [
join(packageRoot, "src"),
join(repositoryRoot, "packages/api/src/router/custom-widget"),
join(repositoryRoot, "packages/widgets/src/custom-api"),
join(repositoryRoot, "apps/nextjs/src/app/[locale]/manage/custom-widgets"),
];
const productionFiles = [];
async function collect(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
if (entry.name !== "test") await collect(path);
} else if ([".ts", ".tsx"].includes(extname(entry.name)) && !/\.spec\.tsx?$/u.test(entry.name)) {
productionFiles.push(path);
}
}
}
for (const sourceRoot of sourceRoots) await collect(sourceRoot);
const failures = [];
const graph = new Map();
const algorithmicExceptions = new Map([
["packages/custom-widgets/src/jsx/analyzer.ts", 400],
["packages/custom-widgets/src/jsx/interpreter.tsx", 400],
]);
for (const file of productionFiles) {
const source = await readFile(file, "utf8");
const name = relative(repositoryRoot, file);
const lineCount = source.trimEnd().split("\n").length;
const limit = algorithmicExceptions.get(name) ?? 300;
if (lineCount > limit) failures.push(`${name} has ${lineCount} lines (limit ${limit})`);
if (file.startsWith(join(packageRoot, "src")) && /from\s+["']@homarr\/(?:api|widgets)(?:\/|["'])/u.test(source)) {
failures.push(`${name} imports a forbidden adapter package`);
}
if (extname(file) === ".tsx" && /(?:runtime|workbench|custom-widgets|custom-api)/u.test(name)) {
for (const line of source.split("\n")) {
const match = line.match(/>\s*([A-Za-z][A-Za-z0-9 .,!?'-]*)\s*</u);
if (match) failures.push(`${name} contains untranslated JSX text: ${JSON.stringify(match[1]?.trim())}`);
}
if (/\b(?:aria-label|description|label|placeholder|title)=["'][A-Za-z]/u.test(source)) {
failures.push(`${name} contains an untranslated user-facing attribute`);
}
}
const dependencies = [];
for (const match of source.matchAll(/from\s+["'](\.[^"']+)["']/gu)) {
const candidate = resolve(file, "..", match[1]);
const resolved = productionFiles.find(
(item) =>
item === candidate ||
item === `${candidate}.ts` ||
item === `${candidate}.tsx` ||
item === join(candidate, "index.ts"),
);
if (resolved) dependencies.push(resolved);
}
graph.set(file, dependencies);
}
const visiting = new Set();
const visited = new Set();
function visit(file, path = []) {
if (visiting.has(file)) {
failures.push(`circular dependency: ${[...path, file].map((item) => relative(repositoryRoot, item)).join(" -> ")}`);
return;
}
if (visited.has(file)) return;
visiting.add(file);
for (const dependency of graph.get(file) ?? []) visit(dependency, [...path, file]);
visiting.delete(file);
visited.add(file);
}
for (const file of productionFiles) visit(file);
const widgetAdapter = await readFile(join(repositoryRoot, "packages/widgets/src/custom-api/component.tsx"), "utf8");
if (!/dynamic\(\(\)\s*=>\s*import\(["']\.\/custom-jsx-display["']\)/u.test(widgetAdapter)) {
failures.push("Custom JSX board rendering must remain dynamically loaded");
}
const editorAdapter = await readFile(
join(repositoryRoot, "apps/nextjs/src/app/[locale]/manage/custom-widgets/_code-editor.tsx"),
"utf8",
);
if (!/dynamic\(\(\)\s*=>\s*import\(["']@uiw\/react-codemirror["']\)/u.test(editorAdapter)) {
failures.push("CodeMirror must remain dynamically loaded");
}
if (failures.length > 0) {
console.error(failures.join("\n"));
process.exitCode = 1;
} else {
console.log(`Custom Widgets architecture checks passed for ${productionFiles.length} production modules.`);
}
@@ -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,84 @@
export interface ImportReview {
name: string;
origin: string;
authType: string;
networkScope: string;
methods: string[];
permissions: string[];
hasActions: boolean;
}
export function parseCustomWidgetClipboard(text: string): Record<string, unknown> | null {
try {
const jsonBlock = text.match(/```(?:json)?\s*([\s\S]*?)```/iu)?.[1];
const jsxBlock = text.match(/```(?:jsx|tsx)\s*([\s\S]*?)```/iu)?.[1]?.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";
@@ -1,7 +1,13 @@
import { Parser } from "acorn";
import jsx from "acorn-jsx";
import { customJsxComponentByName, customJsxSupportedPropsByName } from "@homarr/definitions";
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;
@@ -19,8 +25,6 @@ export interface CustomJsxTemplateDiagnostic {
}
const JsxParser = Parser.extend(jsx());
const MAX_AST_DEPTH = 64;
const MAX_OPERATIONS = 25_000;
const rootBindings = new Set([
"Array",
@@ -43,34 +47,6 @@ const rootBindings = new Set([
"undefined",
]);
const blockedProperties = new Set([
"__proto__",
"apply",
"arguments",
"bind",
"call",
"callee",
"caller",
"constructor",
"prototype",
]);
const blockedProps = new Set([
"children",
"className",
"classNames",
"classes",
"component",
"dangerouslySetInnerHTML",
"innerRef",
"portalProps",
"ref",
"renderRoot",
"styles",
"unstyled",
"withinPortal",
]);
const nodeOf = (value: unknown): AstNode | null =>
value !== null && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"
? (value as AstNode)
@@ -79,10 +55,23 @@ const nodeOf = (value: unknown): AstNode | null =>
const nodesOf = (value: unknown): AstNode[] =>
Array.isArray(value) ? value.map(nodeOf).filter((node): node is AstNode => node !== null) : [];
const normalizedProperty = (value: unknown): string =>
String(value ?? "")
.normalize("NFKC")
.toLowerCase();
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[] = [];
@@ -101,11 +90,12 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
const checkBudget = (node: AstNode, depth: number) => {
operations += 1;
if (depth > MAX_AST_DEPTH) add(node, `Template exceeds the AST depth limit (${MAX_AST_DEPTH})`);
if (operations === MAX_OPERATIONS + 1) {
add(node, `Template exceeds the operation limit (${MAX_OPERATIONS})`);
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 <= MAX_AST_DEPTH && operations <= MAX_OPERATIONS;
return depth <= CUSTOM_JSX_LIMITS.astDepth && operations <= CUSTOM_JSX_LIMITS.operations;
};
const tagName = (node: AstNode): string | null => {
@@ -159,7 +149,10 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
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)) || blockedProps.has(attributeName)) {
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");
@@ -211,7 +204,11 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
}
const key = nodeOf(property.key);
if (property.computed && key) visit(key, depth + 1, bindings);
if (!property.computed && key && blockedProperties.has(normalizedProperty(key.name ?? key.value))) {
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);
@@ -243,8 +240,11 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
const property = nodeOf(node.property);
if (object) visit(object, depth + 1, bindings);
if (node.computed && property) visit(property, depth + 1, bindings);
if (property && blockedProperties.has(normalizedProperty(property.name ?? property.value))) {
add(property, "Reflective property access is not allowed");
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;
}
@@ -0,0 +1,84 @@
import { createSafeCallable } from "./safe-bindings";
function sanitizeData(value: unknown): unknown {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(sanitizeData);
const safe: Record<string, unknown> = Object.create(null);
for (const [key, child] of Object.entries(value)) {
if (["constructor", "__proto__", "prototype"].includes(key)) continue;
safe[key] = sanitizeData(child);
}
return safe;
}
const safeMath = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
round: createSafeCallable((value: number) => Math.round(value)),
floor: createSafeCallable((value: number) => Math.floor(value)),
ceil: createSafeCallable((value: number) => Math.ceil(value)),
abs: createSafeCallable((value: number) => Math.abs(value)),
min: createSafeCallable((...values: number[]) => Math.min(...values)),
max: createSafeCallable((...values: number[]) => Math.max(...values)),
pow: createSafeCallable((base: number, exponent: number) => Math.pow(base, exponent)),
sqrt: createSafeCallable((value: number) => Math.sqrt(value)),
PI: Math.PI,
}),
);
const safeDate = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
now: createSafeCallable(() => Date.now()),
create: createSafeCallable((value?: string | number) =>
value === undefined ? Date.now() : new Date(value).getTime(),
),
toISOString: createSafeCallable((value: string | number) => new Date(value).toISOString()),
toLocaleDateString: createSafeCallable((value: string | number, locale?: string) =>
new Date(value).toLocaleDateString(locale ?? "en-US"),
),
toLocaleTimeString: createSafeCallable((value: string | number, locale?: string) =>
new Date(value).toLocaleTimeString(locale ?? "en-US"),
),
getTime: createSafeCallable((value: string | number) => new Date(value).getTime()),
getYear: createSafeCallable((value: string | number) => new Date(value).getFullYear()),
getMonth: createSafeCallable((value: string | number) => new Date(value).getMonth()),
getDay: createSafeCallable((value: string | number) => new Date(value).getUTCDay()),
}),
);
export function createCustomJsxBindings(apiData: unknown) {
const safeJson = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
stringify: createSafeCallable((value: unknown) => JSON.stringify(value)),
}),
);
const safeArray = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
isArray: createSafeCallable(Array.isArray),
from: createSafeCallable((value: ArrayLike<unknown> | Iterable<unknown>) => Array.from(value).slice(0, 2_000)),
}),
);
const safeObject = Object.freeze(
Object.assign(Object.create(null) as Record<string, unknown>, {
keys: createSafeCallable(Object.keys),
values: createSafeCallable(Object.values),
entries: createSafeCallable(Object.entries),
}),
);
return {
data: sanitizeData(apiData),
String: createSafeCallable((value: unknown) => String(value)),
Number: createSafeCallable((value: unknown) => Number(value)),
Boolean: createSafeCallable((value: unknown) => Boolean(value)),
Math: safeMath,
JSON: safeJson,
Array: safeArray,
Object: safeObject,
Date: safeDate,
parseInt: createSafeCallable((value: string, radix?: number) => parseInt(value, radix)),
parseFloat: createSafeCallable((value: string) => parseFloat(value)),
encodeURIComponent: createSafeCallable((value: string) => encodeURIComponent(value)),
decodeURIComponent: createSafeCallable((value: string) => decodeURIComponent(value)),
isNaN: createSafeCallable((value: unknown) => Number.isNaN(Number(value))),
isFinite: createSafeCallable((value: unknown) => Number.isFinite(Number(value))),
};
}
@@ -0,0 +1,271 @@
import { isSafeCallable } from "./safe-bindings";
import { isInterpreterCallback, normalizedSliceIndex, numericArgument, SafeJsxError } from "./interpreter-foundation";
import type { Budget, InterpreterCallback } from "./interpreter-foundation";
interface CollectionMethodContext {
budget: Budget;
runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown;
}
export function callCollectionMethod(
receiver: unknown,
method: string,
args: unknown[],
depth: number,
context: CollectionMethodContext,
): { handled: boolean; value?: unknown } {
if (Array.isArray(receiver)) {
if (method === "map" || method === "filter") {
const callback = args[0];
if (isSafeCallable(callback)) {
const result: unknown[] = [];
for (let index = 0; index < receiver.length; index += 1) {
context.budget.collection();
const callbackResult = callback(receiver[index] as never);
if (method === "map") result.push(callbackResult);
else if (callbackResult) result.push(receiver[index]);
}
return { handled: true, value: result };
}
if (!isInterpreterCallback(callback)) throw new SafeJsxError(`${method} requires an inline arrow callback`);
const result: unknown[] = [];
for (let index = 0; index < receiver.length; index += 1) {
context.budget.collection();
const callbackResult = context.runCallback(callback, [receiver[index], index, receiver], depth + 1);
if (method === "map" || callbackResult) result.push(method === "map" ? callbackResult : receiver[index]);
}
return { handled: true, value: result };
}
if (method === "pop") {
context.budget.collection();
return { handled: true, value: receiver.length > 0 ? receiver[receiver.length - 1] : undefined };
}
if (method === "slice") {
const start = normalizedSliceIndex(args[0], receiver.length, 0);
const end = normalizedSliceIndex(args[1], receiver.length, receiver.length);
const result: unknown[] = [];
for (let index = start; index < Math.max(start, end); index += 1) {
context.budget.collection();
result.push(receiver[index]);
}
return { handled: true, value: result };
}
if (method === "join") {
context.budget.collection(receiver.length);
const separator = String(args[0] ?? ",");
let result = "";
receiver.forEach((item, index) => {
result += `${index === 0 ? "" : separator}${String(item ?? "")}`;
context.budget.string(result);
});
return { handled: true, value: result };
}
if (method === "includes") {
context.budget.collection(receiver.length);
return { handled: true, value: receiver.some((item) => Object.is(item, args[0])) };
}
if (method === "indexOf") {
context.budget.collection(receiver.length);
return { handled: true, value: receiver.findIndex((item) => Object.is(item, args[0])) };
}
if (method === "find" || method === "findIndex" || method === "some" || method === "every") {
const callback = args[0];
const useSafe = isSafeCallable(callback);
if (!useSafe && !isInterpreterCallback(callback))
throw new SafeJsxError(`${method} requires an inline arrow callback`);
for (let index = 0; index < receiver.length; index += 1) {
context.budget.collection();
const result = useSafe
? (callback as (...a: never[]) => unknown)(receiver[index] as never)
: context.runCallback(callback as InterpreterCallback, [receiver[index], index, receiver], depth + 1);
if (method === "find" && result) return { handled: true, value: receiver[index] };
if (method === "findIndex" && result) return { handled: true, value: index };
if (method === "some" && result) return { handled: true, value: true };
if (method === "every" && !result) return { handled: true, value: false };
}
const defaults: Record<string, unknown> = { find: undefined, findIndex: -1, some: false, every: true };
return { handled: true, value: defaults[method] };
}
if (method === "sort") {
const callback = args[0];
if (!isInterpreterCallback(callback)) throw new SafeJsxError("sort requires an inline arrow callback");
const copy = [...receiver];
copy.sort((left, right) => {
context.budget.collection();
const delta = Number(context.runCallback(callback, [left, right], depth + 1));
if (!Number.isFinite(delta)) return 0;
return delta;
});
return { handled: true, value: copy };
}
if (method === "reduce") {
const callback = args[0];
if (!isInterpreterCallback(callback)) throw new SafeJsxError("reduce requires an inline arrow callback");
if (receiver.length === 0 && args.length < 2) {
throw new SafeJsxError("Reduce of empty array with no initial value");
}
let startIndex = 0;
let accumulator: unknown;
if (args.length < 2) {
accumulator = receiver[0];
startIndex = 1;
} else {
accumulator = args[1];
}
for (let index = startIndex; index < receiver.length; index += 1) {
context.budget.collection();
accumulator = context.runCallback(callback, [accumulator, receiver[index], index, receiver], depth + 1);
}
return { handled: true, value: accumulator };
}
if (method === "at") {
const index = numericArgument(args[0], 0);
let normalizedIndex = index;
if (index < 0) normalizedIndex = receiver.length + index;
context.budget.collection();
const outOfBounds = normalizedIndex < 0 || normalizedIndex >= receiver.length;
return { handled: true, value: outOfBounds ? undefined : receiver[normalizedIndex] };
}
if (method === "flat") {
// ponytail: only depth=1 supported; deeper nesting requires recursive budget tracking
const flatDepth = numericArgument(args[0], 1);
if (flatDepth !== 1) throw new SafeJsxError("flat() only supports depth 1");
const result: unknown[] = [];
for (const item of receiver) {
context.budget.collection();
if (Array.isArray(item)) {
for (const nested of item) {
context.budget.collection();
result.push(nested);
}
} else {
result.push(item);
}
}
return { handled: true, value: result };
}
if (method === "reverse") {
const copy = [...receiver];
const result: unknown[] = [];
for (let index = copy.length - 1; index >= 0; index -= 1) {
context.budget.collection();
result.push(copy[index]);
}
return { handled: true, value: result };
}
}
if (typeof receiver === "string") {
switch (method) {
case "toUpperCase":
return { handled: true, value: context.budget.string(receiver.toUpperCase()) };
case "toLowerCase":
return { handled: true, value: context.budget.string(receiver.toLowerCase()) };
case "trim":
return { handled: true, value: context.budget.string(receiver.trim()) };
case "trimStart":
return { handled: true, value: context.budget.string(receiver.trimStart()) };
case "trimEnd":
return { handled: true, value: context.budget.string(receiver.trimEnd()) };
case "replace": {
const search = String(args[0] ?? "");
const replacement = String(args[1] ?? "");
const matchIndex = receiver.indexOf(search);
if (matchIndex === -1) return { handled: true, value: context.budget.string(receiver) };
return {
handled: true,
value: context.budget.string(
receiver.slice(0, matchIndex) + replacement + receiver.slice(matchIndex + search.length),
),
};
}
case "replaceAll": {
const search = String(args[0] ?? "");
const replacement = String(args[1] ?? "");
if (search.length === 0) return { handled: true, value: context.budget.string(receiver) };
let result = "";
let cursor = 0;
while (cursor < receiver.length) {
const matchIndex = receiver.indexOf(search, cursor);
if (matchIndex === -1) {
result += receiver.slice(cursor);
break;
}
result += receiver.slice(cursor, matchIndex) + replacement;
cursor = matchIndex + search.length;
}
return { handled: true, value: context.budget.string(result) };
}
case "indexOf":
return { handled: true, value: receiver.indexOf(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "lastIndexOf": {
const fromIndex = args[1] !== undefined ? numericArgument(args[1], receiver.length) : undefined;
return { handled: true, value: receiver.lastIndexOf(String(args[0] ?? ""), fromIndex) };
}
case "repeat": {
let maxCount = 0;
if (receiver.length > 0) maxCount = Math.floor(200_001 / receiver.length);
const count = Math.max(0, Math.min(numericArgument(args[0], 0), maxCount));
return { handled: true, value: context.budget.string(receiver.repeat(count).slice(0, 200_001)) };
}
case "slice":
return {
handled: true,
value: context.budget.string(receiver.slice(numericArgument(args[0], 0), args[1] as number | undefined)),
};
case "substring":
return {
handled: true,
value: context.budget.string(receiver.substring(numericArgument(args[0], 0), args[1] as number | undefined)),
};
case "includes":
return { handled: true, value: receiver.includes(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "startsWith":
return { handled: true, value: receiver.startsWith(String(args[0] ?? ""), numericArgument(args[1], 0)) };
case "endsWith":
return { handled: true, value: receiver.endsWith(String(args[0] ?? ""), args[1] as number | undefined) };
case "padStart":
return {
handled: true,
value: context.budget.string(
receiver.padStart(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
),
};
case "padEnd":
return {
handled: true,
value: context.budget.string(
receiver.padEnd(Math.min(numericArgument(args[0], 0), 200_001), String(args[1] ?? " ")),
),
};
case "charAt":
return { handled: true, value: receiver.charAt(numericArgument(args[0], 0)) };
case "split": {
const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 2_000)));
const split = args[0] === undefined ? [receiver] : receiver.split(String(args[0]), limit);
context.budget.collection(split.length);
return { handled: true, value: split };
}
}
}
if (typeof receiver === "number") {
if (method === "toFixed") {
const digits = Math.max(0, Math.min(20, numericArgument(args[0], 0)));
return { handled: true, value: receiver.toFixed(digits) };
}
if (method === "toPrecision") {
const precision = Math.max(1, Math.min(21, numericArgument(args[0], 1)));
return { handled: true, value: receiver.toPrecision(precision) };
}
}
if (
(typeof receiver === "number" || typeof receiver === "boolean" || typeof receiver === "string") &&
method === "toString"
) {
return { handled: true, value: context.budget.string(String(receiver)) };
}
return { handled: false };
}
+8
View File
@@ -0,0 +1,8 @@
export * from "./analyzer";
export * from "./interpreter";
export * from "./policy";
export * from "./safe-bindings";
export * from "./bindings";
export * from "./runtime-components";
export * from "./interactive-components";
export { sanitizeCustomJsxProps } from "./safe-properties";
@@ -0,0 +1,108 @@
import { CUSTOM_JSX_LIMITS } from "./policy";
export interface AstNode {
type: string;
start?: number;
end?: number;
[key: string]: unknown;
}
export interface EvaluationBudgets {
maxAstDepth: number;
maxOperations: number;
maxCollectionItems: number;
maxRenderedNodes: number;
maxStringLength: number;
}
export interface SafeJsxBudgets extends Partial<EvaluationBudgets> {}
export const INTERPRETER_CALLBACK = Symbol("custom-jsx-interpreter-callback");
export const DEFAULT_BUDGETS: EvaluationBudgets = {
maxAstDepth: CUSTOM_JSX_LIMITS.astDepth,
maxOperations: CUSTOM_JSX_LIMITS.operations,
maxCollectionItems: CUSTOM_JSX_LIMITS.collectionItems,
maxRenderedNodes: CUSTOM_JSX_LIMITS.renderedNodes,
maxStringLength: CUSTOM_JSX_LIMITS.stringLength,
};
export interface InterpreterCallback {
readonly kind: typeof INTERPRETER_CALLBACK;
readonly params: readonly string[];
readonly body: AstNode;
readonly environment: Environment;
}
export class SafeJsxError extends Error {
public constructor(message: string) {
super(message);
this.name = "SafeJsxError";
}
}
export class Environment {
public constructor(
private readonly values: Readonly<Record<string, unknown>>,
private readonly parent?: Environment,
) {}
public get(name: string): unknown {
if (Object.hasOwn(this.values, name)) return this.values[name];
if (this.parent) return this.parent.get(name);
if (name === "undefined") return undefined;
if (name === "NaN") return Number.NaN;
if (name === "Infinity") return Number.POSITIVE_INFINITY;
throw new SafeJsxError(`Unknown binding: ${name}`);
}
}
export class Budget {
private operations = 0;
private collectionItems = 0;
private renderedNodes = 0;
public constructor(private readonly limits: EvaluationBudgets) {}
public operation(depth: number): void {
if (depth > this.limits.maxAstDepth)
throw new SafeJsxError(`Template exceeded the AST depth limit (${this.limits.maxAstDepth})`);
if (++this.operations > this.limits.maxOperations)
throw new SafeJsxError(`Template exceeded the operation limit (${this.limits.maxOperations})`);
}
public collection(count = 1): void {
this.collectionItems += count;
if (this.collectionItems > this.limits.maxCollectionItems)
throw new SafeJsxError(`Template exceeded the collection limit (${this.limits.maxCollectionItems})`);
}
public rendered(): void {
if (++this.renderedNodes > this.limits.maxRenderedNodes)
throw new SafeJsxError(`Template exceeded the rendered node limit (${this.limits.maxRenderedNodes})`);
}
public string(value: string): string {
if (value.length > this.limits.maxStringLength)
throw new SafeJsxError(`Template exceeded the string length limit (${this.limits.maxStringLength})`);
return value;
}
}
export function asNode(value: unknown, context: string): AstNode {
if (!value || typeof value !== "object" || typeof (value as { type?: unknown }).type !== "string")
throw new SafeJsxError(`Invalid ${context} node`);
return value as AstNode;
}
export function asNodeArray(value: unknown, context: string): AstNode[] {
if (!Array.isArray(value)) throw new SafeJsxError(`Invalid ${context}`);
return value.map((item) => asNode(item, context));
}
export const isInterpreterCallback = (value: unknown): value is InterpreterCallback =>
typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === INTERPRETER_CALLBACK;
export function numericArgument(value: unknown, fallback: number): number {
const number = value === undefined ? fallback : Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.trunc(number);
}
export function normalizedSliceIndex(value: unknown, length: number, fallback: number): number {
const index = numericArgument(value, fallback);
if (index < 0) return Math.max(length + index, 0);
return Math.min(index, length);
}
@@ -0,0 +1,387 @@
import type { ComponentType, ReactNode } from "react";
import { Parser } from "acorn";
import jsx from "acorn-jsx";
import { callCollectionMethod } from "./collection-methods";
import { emitJsxElement, emitJsxFragment } from "./jsx-emitter";
import type { JsxEmitterContext } from "./jsx-emitter";
import { isSafeCallable } from "./safe-bindings";
import {
asNode,
asNodeArray,
Budget,
DEFAULT_BUDGETS,
Environment,
INTERPRETER_CALLBACK,
SafeJsxError,
} from "./interpreter-foundation";
import type { AstNode, EvaluationBudgets, InterpreterCallback, SafeJsxBudgets } from "./interpreter-foundation";
import { normalizedProperty, ownProperty } from "./safe-properties";
import { CUSTOM_JSX_LIMITS } from "./policy";
export { SafeJsxError } from "./interpreter-foundation";
export type { SafeJsxBudgets } from "./interpreter-foundation";
export { sanitizeCustomJsxProps } from "./safe-properties";
export interface RenderSafeJsxOptions {
template: string;
components: Readonly<Record<string, ComponentType<never>>>;
bindings: Readonly<Record<string, unknown>>;
budgets?: SafeJsxBudgets;
}
export interface SafeJsxRenderResult {
node: ReactNode;
warnings: string[];
}
const JsxParser = Parser.extend(jsx());
function jsxText(value: string): string {
const lines = value.replace(/\r/g, "").split("\n");
let result = "";
for (let index = 0; index < lines.length; index += 1) {
let line = lines[index] ?? "";
line = line.replace(/\t/g, " ");
if (index !== 0) line = line.replace(/^\s+/, "");
if (index !== lines.length - 1) line = line.replace(/\s+$/, "");
if (!line) continue;
if (result) result += " ";
result += line;
}
return result;
}
class Interpreter {
private readonly budget: Budget;
private readonly warnings = new Set<string>();
public constructor(
private readonly components: Readonly<Record<string, ComponentType<never>>>,
private readonly environment: Environment,
private readonly limits: EvaluationBudgets,
) {
this.budget = new Budget(limits);
}
public render(root: AstNode): SafeJsxRenderResult {
return { node: this.evaluate(root, this.environment, 0) as ReactNode, warnings: [...this.warnings] };
}
private evaluate(node: AstNode, environment: Environment, depth: number): unknown {
this.budget.operation(depth);
switch (node.type) {
case "Literal":
return node.value;
case "Identifier":
return environment.get(String(node.name));
case "JSXElement":
return this.evaluateJsxElement(node, environment, depth + 1);
case "JSXFragment":
return this.evaluateJsxFragment(node, environment, depth + 1);
case "JSXText": {
const text = this.budget.string(jsxText(String(node.value ?? "")));
if (text) this.budget.rendered();
return text || null;
}
case "JSXExpressionContainer": {
const expression = asNode(node.expression, "JSX expression");
if (expression.type === "JSXEmptyExpression") return null;
const value = this.evaluate(expression, environment, depth + 1);
return typeof value === "string" ? this.budget.string(value) : value;
}
case "ArrayExpression":
return this.evaluateArray(node, environment, depth + 1);
case "ObjectExpression":
return this.evaluateObject(node, environment, depth + 1);
case "UnaryExpression":
return this.evaluateUnary(node, environment, depth + 1);
case "BinaryExpression":
return this.evaluateBinary(node, environment, depth + 1);
case "LogicalExpression":
return this.evaluateLogical(node, environment, depth + 1);
case "ConditionalExpression":
return this.evaluate(node.test as AstNode, environment, depth + 1)
? this.evaluate(asNode(node.consequent, "conditional consequent"), environment, depth + 1)
: this.evaluate(asNode(node.alternate, "conditional alternate"), environment, depth + 1);
case "MemberExpression":
return this.evaluateMember(node, environment, depth + 1);
case "CallExpression":
return this.evaluateCall(node, environment, depth + 1);
case "ArrowFunctionExpression":
return this.createCallback(node, environment);
case "TemplateLiteral":
return this.evaluateTemplateLiteral(node, environment, depth + 1);
case "ChainExpression":
return this.evaluate(asNode(node.expression, "chain expression"), environment, depth + 1);
default:
throw new SafeJsxError(`Unsupported expression: ${node.type}`);
}
}
private evaluateJsxFragment(node: AstNode, environment: Environment, depth: number): ReactNode {
return emitJsxFragment(node, environment, depth, this.emitterContext());
}
private evaluateJsxElement(node: AstNode, environment: Environment, depth: number): ReactNode {
return emitJsxElement(node, environment, depth, this.emitterContext());
}
private emitterContext(): JsxEmitterContext {
return {
components: this.components,
budget: this.budget,
warnings: this.warnings,
evaluate: (node, environment, depth) => this.evaluate(node, environment, depth),
renderCallback: (callback, args) =>
new Interpreter(this.components, callback.environment, this.limits).runCallback(callback, args, 0) as ReactNode,
};
}
private evaluateArray(node: AstNode, environment: Environment, depth: number): unknown[] {
const result: unknown[] = [];
const elements = node.elements;
if (!Array.isArray(elements)) throw new SafeJsxError("Invalid array expression");
for (const elementValue of elements) {
if (elementValue == null) {
result.push(undefined);
continue;
}
const element = asNode(elementValue, "array element");
if (element.type === "SpreadElement") {
const spread = this.evaluate(asNode(element.argument, "array spread"), environment, depth + 1);
if (!Array.isArray(spread)) throw new SafeJsxError("Only arrays can be spread into arrays");
this.budget.collection(spread.length);
result.push(...spread);
} else {
this.budget.collection();
result.push(this.evaluate(element, environment, depth + 1));
}
}
return result;
}
private evaluateObject(node: AstNode, environment: Environment, depth: number): Record<string, unknown> {
const result: Record<string, unknown> = Object.create(null);
for (const property of asNodeArray(node.properties, "object properties")) {
if (property.type === "SpreadElement") {
const spread = this.evaluate(asNode(property.argument, "object spread"), environment, depth + 1);
if (!spread || typeof spread !== "object" || Array.isArray(spread)) {
throw new SafeJsxError("Only objects can be spread into objects");
}
for (const [key, value] of Object.entries(spread)) result[normalizedProperty(key)] = value;
continue;
}
if (property.type !== "Property" || property.kind !== "init" || property.method || property.shorthand) {
throw new SafeJsxError("Only explicit object properties are supported");
}
const keyNode = asNode(property.key, "object key");
const key = normalizedProperty(
property.computed
? this.evaluate(keyNode, environment, depth + 1)
: keyNode.type === "Identifier"
? keyNode.name
: keyNode.value,
);
result[key] = this.evaluate(asNode(property.value, "object value"), environment, depth + 1);
}
return result;
}
private evaluateUnary(node: AstNode, environment: Environment, depth: number): unknown {
const value = this.evaluate(asNode(node.argument, "unary argument"), environment, depth + 1);
switch (node.operator) {
case "!":
return !value;
case "+":
return Number(value);
case "-":
return -Number(value);
case "typeof":
return typeof value;
default:
throw new SafeJsxError(`Unsupported unary operator: ${String(node.operator)}`);
}
}
private evaluateBinary(node: AstNode, environment: Environment, depth: number): unknown {
const left = this.evaluate(asNode(node.left, "binary left operand"), environment, depth + 1);
const right = this.evaluate(asNode(node.right, "binary right operand"), environment, depth + 1);
switch (node.operator) {
case "+":
return typeof left === "string" || typeof right === "string"
? this.budget.string(String(left) + String(right))
: Number(left) + Number(right);
case "-":
return Number(left) - Number(right);
case "*":
return Number(left) * Number(right);
case "/":
return Number(left) / Number(right);
case "%":
return Number(left) % Number(right);
case "**":
return Number(left) ** Number(right);
case "<":
return (left as number) < (right as number);
case "<=":
return (left as number) <= (right as number);
case ">":
return (left as number) > (right as number);
case ">=":
return (left as number) >= (right as number);
case "==":
return String(left) === String(right);
case "!=":
return String(left) !== String(right);
case "===":
return left === right;
case "!==":
return left !== right;
default:
throw new SafeJsxError(`Unsupported binary operator: ${String(node.operator)}`);
}
}
private evaluateLogical(node: AstNode, environment: Environment, depth: number): unknown {
const left = this.evaluate(asNode(node.left, "logical left operand"), environment, depth + 1);
if (node.operator === "&&") {
return left ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
}
if (node.operator === "||") {
return left ? left : this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1);
}
if (node.operator === "??") {
return left == null ? this.evaluate(asNode(node.right, "logical right operand"), environment, depth + 1) : left;
}
throw new SafeJsxError(`Unsupported logical operator: ${String(node.operator)}`);
}
private memberProperty(node: AstNode, environment: Environment, depth: number): string {
const propertyNode = asNode(node.property, "member property");
return normalizedProperty(
node.computed
? this.evaluate(propertyNode, environment, depth + 1)
: propertyNode.type === "Identifier"
? propertyNode.name
: propertyNode.value,
);
}
private evaluateMember(node: AstNode, environment: Environment, depth: number): unknown {
const object = this.evaluate(asNode(node.object, "member object"), environment, depth + 1);
const property = this.memberProperty(node, environment, depth + 1);
return ownProperty(object, property);
}
private evaluateCall(node: AstNode, environment: Environment, depth: number): unknown {
if (node.optional) throw new SafeJsxError("Optional calls are not supported");
const args = asNodeArray(node.arguments, "call arguments").map((argument) => {
if (argument.type === "SpreadElement") throw new SafeJsxError("Spread call arguments are not supported");
return this.evaluate(argument, environment, depth + 1);
});
const callee = asNode(node.callee, "call target");
if (callee.type === "MemberExpression") {
const receiver = this.evaluate(asNode(callee.object, "method receiver"), environment, depth + 1);
const method = this.memberProperty(callee, environment, depth + 1);
const collectionResult = this.callCollectionMethod(receiver, method, args, depth + 1);
if (collectionResult.handled) return collectionResult.value;
const callable = ownProperty(receiver, method);
if (!isSafeCallable(callable)) throw new SafeJsxError(`Calling method '${method}' is not allowed`);
return this.accountHostResult(callable(...(args as never[])));
}
const callable = this.evaluate(callee, environment, depth + 1);
if (!isSafeCallable(callable)) throw new SafeJsxError("Calling this value is not allowed");
return this.accountHostResult(callable(...(args as never[])));
}
private callCollectionMethod(
receiver: unknown,
method: string,
args: unknown[],
depth: number,
): { handled: boolean; value?: unknown } {
return callCollectionMethod(receiver, method, args, depth, {
budget: this.budget,
runCallback: (callback, callbackArgs, callbackDepth) => this.runCallback(callback, callbackArgs, callbackDepth),
});
}
private accountHostResult(value: unknown): unknown {
if (typeof value === "string") return this.budget.string(value);
if (Array.isArray(value)) this.budget.collection(value.length);
return value;
}
private createCallback(node: AstNode, environment: Environment): InterpreterCallback {
if (node.async || node.generator || node.expression === false) {
throw new SafeJsxError("Only synchronous expression-bodied arrow callbacks are supported");
}
const params = asNodeArray(node.params, "callback parameters").map((param) => {
if (param.type !== "Identifier") throw new SafeJsxError("Callback parameters must be identifiers");
return normalizedProperty(param.name);
});
return {
kind: INTERPRETER_CALLBACK,
params,
body: asNode(node.body, "callback body"),
environment,
};
}
private runCallback(callback: InterpreterCallback, args: unknown[], depth: number): unknown {
const values: Record<string, unknown> = Object.create(null);
callback.params.forEach((name, index) => {
values[name] = args[index];
});
return this.evaluate(callback.body, new Environment(values, callback.environment), depth + 1);
}
private evaluateTemplateLiteral(node: AstNode, environment: Environment, depth: number): string {
const quasis = asNodeArray(node.quasis, "template literal parts");
const expressions = asNodeArray(node.expressions, "template literal expressions");
let result = "";
quasis.forEach((quasi, index) => {
const value = quasi.value;
if (!value || typeof value !== "object") throw new SafeJsxError("Invalid template literal part");
result += String((value as { cooked?: unknown }).cooked ?? "");
const expression = expressions[index];
if (expression) result += String(this.evaluate(expression, environment, depth + 1));
});
return this.budget.string(result);
}
}
function parseTemplate(template: string): AstNode {
if (template.length > CUSTOM_JSX_LIMITS.templateLength) {
throw new SafeJsxError(`Template exceeds the ${CUSTOM_JSX_LIMITS.templateLength} character limit`);
}
let program: AstNode;
try {
program = JsxParser.parse(`<>${template}</>`, {
ecmaVersion: "latest",
sourceType: "module",
allowAwaitOutsideFunction: false,
allowReturnOutsideFunction: false,
}) as unknown as AstNode;
} catch (error) {
throw new SafeJsxError(error instanceof Error ? error.message : "Unable to parse JSX template");
}
const body = asNodeArray(program.body, "program body");
if (body.length !== 1 || body[0]?.type !== "ExpressionStatement") {
throw new SafeJsxError("Template must contain JSX only");
}
return asNode(body[0].expression, "template expression");
}
export function renderSafeJsx({ template, components, bindings, budgets }: RenderSafeJsxOptions): SafeJsxRenderResult {
const limits: EvaluationBudgets = { ...DEFAULT_BUDGETS, ...budgets };
for (const [name, value] of Object.entries(limits)) {
if (!Number.isSafeInteger(value) || value <= 0) throw new SafeJsxError(`Invalid interpreter budget: ${name}`);
}
const root = parseTemplate(template);
return new Interpreter(components, new Environment(bindings), limits).render(root);
}
@@ -0,0 +1,93 @@
import type { ComponentType, ReactNode } from "react";
import { createElement, Fragment } from "react";
import { asNode, asNodeArray, isInterpreterCallback, SafeJsxError } from "./interpreter-foundation";
import type { AstNode, Budget, Environment, InterpreterCallback } from "./interpreter-foundation";
import { normalizedProperty, sanitizeCustomJsxProps } from "./safe-properties";
import { CUSTOM_JSX_BLOCKED_TAGS } from "./policy";
export interface JsxEmitterContext {
components: Readonly<Record<string, ComponentType<never>>>;
budget: Budget;
warnings: Set<string>;
evaluate(node: AstNode, environment: Environment, depth: number): unknown;
renderCallback(callback: InterpreterCallback, args: unknown[]): ReactNode;
}
export function emitJsxFragment(
node: AstNode,
environment: Environment,
depth: number,
context: JsxEmitterContext,
): ReactNode {
const children = asNodeArray(node.children, "fragment children").map((child) =>
context.evaluate(child, environment, depth + 1),
);
context.budget.rendered();
return createElement(Fragment, null, ...(children as ReactNode[]));
}
export function emitJsxElement(
node: AstNode,
environment: Environment,
depth: number,
context: JsxEmitterContext,
): ReactNode {
const opening = asNode(node.openingElement, "JSX opening element");
const tag = jsxTagName(asNode(opening.name, "JSX tag"));
if (CUSTOM_JSX_BLOCKED_TAGS.has(tag.toLowerCase())) {
context.warnings.add(`Blocked element: ${tag}`);
return null;
}
const component = context.components[tag];
if (!component) {
context.warnings.add(`Unknown or unavailable component: ${tag}`);
return null;
}
const rawProps: Record<string, unknown> = {};
for (const attribute of asNodeArray(opening.attributes, "JSX attributes")) {
if (attribute.type === "JSXSpreadAttribute") {
const spread = context.evaluate(asNode(attribute.argument, "spread attribute"), environment, depth + 1);
if (spread && typeof spread === "object" && !Array.isArray(spread)) Object.assign(rawProps, spread);
continue;
}
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
const nameNode = asNode(attribute.name, "attribute name");
if (nameNode.type !== "JSXIdentifier") throw new SafeJsxError("Namespaced JSX attributes are not supported");
const name = normalizedProperty(nameNode.name);
if (attribute.value == null) rawProps[name] = true;
else {
const value = asNode(attribute.value, "attribute value");
rawProps[name] = value.type === "Literal" ? value.value : context.evaluate(value, environment, depth + 1);
}
}
let children = asNodeArray(node.children, "JSX children").map((child) =>
context.evaluate(child, environment, depth + 1),
);
const props = sanitizeCustomJsxProps(rawProps, tag);
if (tag === "SubFetch") {
const callbackChildren = children.filter(isInterpreterCallback);
const callbackProp = rawProps.render;
if (callbackProp !== undefined && !isInterpreterCallback(callbackProp)) {
throw new SafeJsxError("SubFetch render must be an inline arrow callback");
}
if (callbackChildren.length > 1 || (callbackChildren.length === 1 && callbackProp !== undefined)) {
throw new SafeJsxError("SubFetch accepts only one render callback");
}
const callback = isInterpreterCallback(callbackProp) ? callbackProp : callbackChildren[0];
if (callback) {
props.render = (value: unknown, metadata: unknown) => context.renderCallback(callback, [value, metadata]);
children = children.filter((child) => child !== callback);
}
}
context.budget.rendered();
return createElement(component, props as never, ...(children as ReactNode[]));
}
function jsxTagName(node: AstNode): string {
if (node.type === "JSXIdentifier") return String(node.name);
if (node.type === "JSXMemberExpression") {
return `${jsxTagName(asNode(node.object, "JSX member object"))}.${jsxTagName(asNode(node.property, "JSX member property"))}`;
}
throw new SafeJsxError(`Unsupported JSX tag: ${node.type}`);
}
+73
View File
@@ -0,0 +1,73 @@
export const CUSTOM_JSX_LIMITS = Object.freeze({
templateLength: 50_000,
astDepth: 64,
operations: 25_000,
collectionItems: 2_000,
renderedNodes: 10_000,
stringLength: 200_000,
});
export const CUSTOM_JSX_BLOCKED_PROPERTIES: ReadonlySet<string> = new Set([
"__proto__",
"apply",
"arguments",
"bind",
"call",
"callee",
"caller",
"constructor",
"prototype",
]);
export const CUSTOM_JSX_BLOCKED_PROPS: ReadonlySet<string> = new Set([
"children",
"className",
"classNames",
"classes",
"component",
"dangerouslySetInnerHTML",
"innerRef",
"portalProps",
"ref",
"renderRoot",
"styles",
"unstyled",
"withinPortal",
]);
export const CUSTOM_JSX_BLOCKED_TAGS: ReadonlySet<string> = new Set([
"base",
"embed",
"form",
"iframe",
"link",
"meta",
"object",
"script",
"style",
]);
export const CUSTOM_JSX_URL_PROPS: ReadonlySet<string> = new Set(["backgroundImage", "href", "src"]);
export const CUSTOM_JSX_BLOCKED_STYLE_KEYS: ReadonlySet<string> = new Set([
"backdropFilter",
"behavior",
"bottom",
"clipPath",
"content",
"filter",
"inset",
"left",
"mask",
"pointerEvents",
"position",
"right",
"top",
"zIndex",
]);
export function normalizeCustomJsxProperty(value: unknown): string {
return String(value ?? "")
.normalize("NFKC")
.toLowerCase();
}
@@ -0,0 +1,149 @@
import type { ComponentType, ReactNode } from "react";
import { createElement, useEffect, useRef, useState } from "react";
import * as Core from "@mantine/core";
import * as Charts from "@mantine/charts";
import * as Dates from "@mantine/dates";
import { enabledCustomJsxComponents } from "../core/component-registry";
import { ActionButton, ToggleSwitch } from "../runtime/actions";
import { SubData } from "../runtime/data";
import { RefreshButton } from "../runtime/refresh-button";
import { SubFetch } from "../runtime/sub-fetch";
import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge } from "./interactive-components";
import { sanitizeCustomJsxProps } from "./safe-properties";
type Namespace = Readonly<Record<string, unknown>>;
function resolveExport(namespace: Namespace, name: string): ComponentType<never> | undefined {
let value: unknown = namespace;
for (const segment of name.split(".")) {
if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) {
return undefined;
}
value = (value as Record<string, unknown>)[segment];
}
return typeof value === "function" || (typeof value === "object" && value !== null)
? (value as ComponentType<never>)
: undefined;
}
function safeUrl(value: unknown) {
return typeof value === "string" && (/^https?:\/\//iu.test(value) || value.startsWith("/") || value.startsWith("#"));
}
function wrap(component: ComponentType<never>, additions: Record<string, unknown> = {}) {
return function SafeComponent(props: Record<string, unknown>) {
return createElement(component, { ...sanitizeCustomJsxProps(props), ...additions } as never);
};
}
function SafeLink({ component, props }: { component: ComponentType<never>; props: Record<string, unknown> }) {
const sanitized = sanitizeCustomJsxProps(props);
return createElement(component, {
...sanitized,
href: safeUrl(props.href) ? props.href : undefined,
rel: sanitized.target === "_blank" ? "noopener noreferrer" : sanitized.rel,
} as never);
}
function createCopyButton(labels: { copy: string; copied: string }) {
return function SafeCopyButton({ value }: { value?: string; children?: ReactNode }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const copy = async () => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 2_000);
} catch {
setCopied(false);
}
};
return (
<Core.Button
size="xs"
variant={copied ? "filled" : "light"}
color={copied ? "teal" : "blue"}
onClick={() => void copy()}
>
{copied ? labels.copied : labels.copy}
</Core.Button>
);
};
}
export interface CustomJsxComponentAdapters {
TablerIcon: ComponentType<never>;
copyLabels: { copy: string; copied: string };
}
export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record<string, ComponentType<never>> {
const namespaces: Record<string, Namespace> = {
"@mantine/core": Core as unknown as Namespace,
"@mantine/charts": Charts as unknown as Namespace,
"@mantine/dates": Dates as unknown as Namespace,
};
const components: Record<string, ComponentType<never>> = {};
for (const descriptor of enabledCustomJsxComponents) {
const namespace = namespaces[descriptor.package];
const component = namespace ? resolveExport(namespace, descriptor.name) : undefined;
if (component) components[descriptor.name] = component;
}
const core = Core as unknown as Namespace;
for (const name of [
"Notification",
"LoadingOverlay",
"Overlay",
"Breadcrumbs",
"Stepper",
"Tree",
"Button",
"ActionIcon",
"Burger",
"CloseButton",
"Chip",
] as const) {
const component = resolveExport(core, name);
if (component) components[name] = wrap(component);
}
const anchor = resolveExport(core, "Anchor");
const navLink = resolveExport(core, "NavLink");
if (anchor) components.Anchor = (props) => <SafeLink component={anchor} props={props} />;
if (navLink) components.NavLink = (props) => <SafeLink component={navLink} props={props} />;
for (const [name, additions] of Object.entries({
Rating: { readOnly: true },
Slider: { readOnly: true },
Switch: { readOnly: true },
SegmentedControl: { readOnly: true },
Pagination: { disabled: true },
})) {
const component = resolveExport(core, name);
if (component) components[name] = wrap(component, additions);
}
const dates = Dates as unknown as Namespace;
for (const descriptor of enabledCustomJsxComponents.filter(
(item) => item.package === "@mantine/dates" && item.safety === "wrapped",
)) {
const component = resolveExport(dates, descriptor.name);
if (component) components[descriptor.name] = wrap(component, { static: true });
}
Object.assign(components, {
CopyButton: createCopyButton(adapters.copyLabels),
PaginatedList,
TabsContainer,
TabPanel,
Collapsible,
StatBar,
TypeBadge,
TablerIcon: adapters.TablerIcon,
SubFetch,
SubData,
ActionButton,
ToggleSwitch,
RefreshButton,
});
return components;
}
@@ -0,0 +1,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");
});
});
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { customJsxComponentByName, customJsxComponentRegistry } from "../custom-jsx";
import { customJsxComponentByName, customJsxComponentRegistry } from "../core/component-registry";
const mantineCore941ComponentExports = [
"RemoveScroll",
@@ -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,56 @@
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>',
);
});
});
@@ -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();
});
});
@@ -1,9 +1,8 @@
import { describe, expect, test } from "vitest";
import { customJsxExamples } from "@homarr/definitions";
import { customJsxDisplayConfigV2Schema, customJsxRequestSchema, displayConfigSchema } from "./custom-widget";
import { validateCustomJsxTemplate } from "./custom-jsx-template";
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) => {
@@ -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>
);
}
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more