Files
homarr/packages/custom-widgets/src/package/schema.ts
T

205 lines
8.1 KiB
TypeScript

import { z } from "zod/v4";
import { customWidgetOptionsSchema } from "../core/options-schema";
import { widgetPreviewScenariosSchema } from "./preview-scenarios";
export const CUSTOM_WIDGET_PACKAGE_SCHEMA = "homarr-widget-package-v3";
export const CUSTOM_WIDGET_SDK_VERSION = "1";
const identifier = z
.string()
.min(1)
.max(128)
.regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
const exactVersion = z.string().regex(/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$/u);
const packageName = z.string().regex(/^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/u);
const safeKeys = (value: Record<string, unknown>) =>
Object.keys(value).every((key) => !["__proto__", "prototype", "constructor"].includes(key));
export const widgetPackagePathSchema = z
.string()
.min(1)
.max(240)
.refine(
(path) =>
!path.startsWith("/") &&
!path.includes("\\") &&
!path.includes("\0") &&
path.split("/").every((part) => part !== ".." && part !== "." && part.length > 0),
"Package files must use relative paths without traversal",
);
export const widgetPackageHandlerSchema = z
.strictObject({
kind: z.enum(["query", "action", "subscription", "migration"]),
permission: z.enum(["view", "modify", "full"]).optional(),
description: z.string().max(2048).optional(),
inputSchema: z.record(z.string(), z.unknown()).optional(),
guestAccess: z.boolean().default(false),
timeoutMs: z.number().int().min(100).max(300_000).default(30_000),
})
.transform((handler) => {
let permission = handler.permission;
if (!permission) {
permission = "view";
if (handler.kind === "action") permission = "modify";
if (handler.kind === "migration") permission = "full";
}
return { ...handler, permission };
});
export const widgetPackageManifestSchema = z.strictObject({
id: identifier,
version: exactVersion,
sdkVersion: z.literal(CUSTOM_WIDGET_SDK_VERSION),
name: z.string().trim().min(1).max(128),
description: z.string().max(2048).optional(),
author: z.string().max(256).optional(),
license: z.string().max(128).optional(),
repository: z.string().url().optional(),
icon: z.string().max(2048).optional(),
screenshots: z.array(z.string().max(2048)).max(16).default([]),
tags: z.array(z.string().max(64)).max(32).default([]),
supportedApps: z.array(z.string().max(128)).max(32).default([]),
storageVersion: z.number().int().positive().default(1),
configurationVersion: z.number().int().positive().default(1),
// Absence means scoped; keep it absent when parsing older archives so their checksums remain valid.
styles: z.enum(["scoped", "global"]).optional(),
migrations: z.strictObject({ configuration: identifier.optional(), storage: identifier.optional() }).optional(),
entrypoints: z.strictObject({
tile: widgetPackagePathSchema,
advanced: widgetPackagePathSchema.optional(),
configuration: widgetPackagePathSchema.optional(),
server: widgetPackagePathSchema.optional(),
}),
handlers: z.record(identifier, widgetPackageHandlerSchema).refine(safeKeys).default({}),
});
export const widgetPackageConnectionSchema = z
.strictObject({
label: z.string().trim().min(1).max(128),
kind: z.enum(["http", "integration", "service"]),
serviceType: identifier.optional(),
integrationKind: identifier.optional(),
// Optional fields preserve the digest of existing package archives.
multiple: z.boolean().optional(),
optional: z.boolean().default(false),
description: z.string().max(2048).optional(),
})
.refine((connection) => connection.kind !== "service" || Boolean(connection.serviceType), {
path: ["serviceType"],
message: "Service connections must declare a serviceType, such as mqtt, sqlite, ssh or custom",
});
/** Shared by binding pickers, activation and portable collection imports. */
export function isWidgetConnectionCompatible(
requirement: { kind: string; serviceType?: string; integrationKind?: string },
configuration: { kind?: unknown; serviceType?: unknown; integrationKind?: unknown },
) {
if (requirement.kind !== configuration.kind) return false;
if (requirement.kind === "service") return requirement.serviceType === configuration.serviceType;
if (requirement.kind === "integration" && requirement.integrationKind)
return requirement.integrationKind === configuration.integrationKind;
return true;
}
/** Repeatable connections retain the original named binding and add stable, namespaced members.
* A colon cannot appear in a manifest connection name, so groups cannot overlap.
*/
export function getWidgetConnectionBindingNames(
name: string,
requirement: { multiple?: boolean },
bindings: Record<string, string>,
) {
return Object.keys(bindings).filter(
(key) => Boolean(bindings[key]) && (key === name || (requirement.multiple && key.startsWith(`${name}:`))),
);
}
export function getWidgetConnectionRequirement<T extends { multiple?: boolean }>(
requirements: Record<string, T>,
name: string,
) {
const exact = requirements[name];
if (exact) return exact;
const prefix = name.split(":")[0];
const group = prefix && requirements[prefix];
if (group && group.multiple) return group;
return undefined;
}
export const customWidgetPackageSchema = z
.strictObject({
$schema: z.literal(CUSTOM_WIDGET_PACKAGE_SCHEMA),
manifest: widgetPackageManifestSchema,
files: z
.record(widgetPackagePathSchema, z.string().max(5_000_000))
.refine(safeKeys)
.refine((files) => Object.keys(files).length <= 256, "A package may contain at most 256 source files")
.refine(
(files) => Object.values(files).reduce((size, file) => size + file.length, 0) <= 20_000_000,
"Package source exceeds the 20 MB limit",
),
dependencies: z.record(packageName, exactVersion).refine(safeKeys).default({}),
connections: z.record(identifier, widgetPackageConnectionSchema).refine(safeKeys).default({}),
options: customWidgetOptionsSchema,
// Keep absent on older packages so their source and archive checksums remain unchanged.
previewScenarios: widgetPreviewScenariosSchema.optional(),
})
.superRefine((value, ctx) => {
for (const [surface, path] of Object.entries(value.manifest.entrypoints)) {
if (!Object.hasOwn(value.files, path)) {
ctx.addIssue({ code: "custom", path: ["manifest", "entrypoints", surface], message: `Missing file '${path}'` });
}
}
if (Object.keys(value.manifest.handlers).length > 0 && !value.manifest.entrypoints.server) {
ctx.addIssue({
code: "custom",
path: ["manifest", "entrypoints", "server"],
message: "Handlers need a server entrypoint",
});
}
for (const [kind, handler] of Object.entries(value.manifest.migrations ?? {})) {
if (value.manifest.handlers[handler]?.kind !== "migration") {
ctx.addIssue({
code: "custom",
path: ["manifest", "migrations", kind],
message: "Migration entries must name a migration handler",
});
}
}
for (const [scenario, fixture] of Object.entries(value.previewScenarios ?? {})) {
for (const name of Object.keys(fixture.queries)) {
if (value.manifest.handlers[name]?.kind !== "query")
ctx.addIssue({
code: "custom",
path: ["previewScenarios", scenario, "queries", name],
message: "Preview query fixtures must reference declared query handlers",
});
}
}
});
export type CustomWidgetPackage = z.infer<typeof customWidgetPackageSchema>;
export type CustomWidgetPackageInput = z.input<typeof customWidgetPackageSchema>;
export type WidgetPackageManifest = z.infer<typeof widgetPackageManifestSchema>;
export type WidgetPackageHandler = z.infer<typeof widgetPackageHandlerSchema>;
export type WidgetPackageSurface = "tile" | "advanced" | "configuration";
export const HOST_WIDGET_MODULES = [
"react",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"react-dom",
"react-dom/client",
"@mantine/core",
"@mantine/hooks",
"@mantine/charts",
"@mantine/dates",
"@tanstack/react-query",
"@homarr/widget-sdk",
"@homarr/widget-sdk/client",
"@homarr/widget-sdk/ui",
"@homarr/widget-sdk/table",
"@homarr/widget-sdk/legacy",
] as const;