feat(custom-widgets): add extensible widget platform

This commit is contained in:
ajnart committed 2026-09-12 13:54:05 +02:00
1 parent 6cca757493
commit 8f152f1929
317 files changed
+32326 -1788

No files matched your search

+8 -2
View File
@@ -15,7 +15,7 @@
".": "./index.ts"
},
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --outfile=cli.cjs --external:bcrypt --external:cpu-features --loader:.html=text --loader:.node=text",
"build": "esbuild src/index.ts --bundle --platform=node --define:import.meta.url=__filename --outfile=cli.cjs --external:bcrypt --external:cpu-features --external:esbuild --loader:.html=text --loader:.node=text",
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"lint": "oxlint",
@@ -25,11 +25,17 @@
},
"dependencies": {
"@drizzle-team/brocli": "catalog:",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/custom-widgets": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "catalog:"
"@homarr/widget-sdk": "workspace:^0.1.0",
"@trpc/client": "catalog:",
"dotenv": "catalog:",
"superjson": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
@@ -0,0 +1,133 @@
import { readFile, stat, mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { command, positional, string } from "@drizzle-team/brocli";
import { createTRPCClient, httpLink } from "@trpc/client";
import superjson from "superjson";
import { z } from "zod/v4";
import type { AppRouter } from "@homarr/api";
import {
customWidgetArchiveSchema,
widgetCollectionConnectionsSchema,
widgetCollectionEntrySchema,
widgetCollectionKeySchema,
widgetCollectionManifestSchema,
WIDGET_COLLECTION_MAX_BYTES,
} from "@homarr/custom-widgets/package";
const archiveInput = () => ({ file: positional("path").required().desc("Collection archive JSON") });
const projectSchema = z.strictObject({
manifest: widgetCollectionManifestSchema,
connections: widgetCollectionConnectionsSchema.default({}),
widgets: z
.array(widgetCollectionEntrySchema.omit({ archive: true }).extend({ archive: z.string().min(1) }))
.min(1)
.max(32),
});
async function readJson(path: string): Promise<unknown> {
if ((await stat(path)).size > WIDGET_COLLECTION_MAX_BYTES) throw new Error("Collection input exceeds 128 MiB");
return JSON.parse(await readFile(path, "utf8"));
}
const pack = command({
name: "pack",
desc: "Combine compiled widget archives and shared connection requirements without executing code",
options: {
file: positional("path").required().desc("Collection manifest JSON with member archive file paths"),
output: string("output").alias("o").required().desc("New output collection archive JSON"),
},
handler: async ({ file, output }) => {
const { createWidgetCollectionArchive } = await import("@homarr/custom-widgets/package/server");
const path = resolve(file);
const project = projectSchema.parse(await readJson(path));
const widgets = [];
let totalBytes = (await stat(path)).size;
for (const entry of project.widgets) {
try {
const memberPath = resolve(dirname(path), entry.archive);
totalBytes += (await stat(memberPath)).size;
if (totalBytes > WIDGET_COLLECTION_MAX_BYTES) throw new Error("Combined collection inputs exceed 128 MiB");
const archive = customWidgetArchiveSchema.parse(await readJson(memberPath));
widgets.push({ ...entry, archive });
} catch (error) {
throw new Error(`Widget '${entry.key}': ${error instanceof Error ? error.message : "Invalid archive"}`, {
cause: error,
});
}
}
const archive = createWidgetCollectionArchive({ ...project, format: "homarr-widget-collection-v1", widgets });
const target = resolve(output);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, JSON.stringify(archive, null, 2), { flag: "wx", mode: 0o600 });
console.log(`Packed ${archive.widgets.length} widgets: ${target}`);
},
});
const validate = command({
name: "validate",
desc: "Check collection structure, connection mapping and every artifact checksum",
options: archiveInput(),
handler: async ({ file }) => {
const { loadWidgetCollectionArchive } = await import("@homarr/custom-widgets/package/server");
const archive = await loadWidgetCollectionArchive(resolve(file));
console.log(
`Valid collection: ${archive.manifest.id}@${archive.manifest.version} (${archive.widgets.length} widgets)`,
);
},
});
const install = command({
name: "import",
desc: "Import every collection member as a disabled draft in an installed Homarr",
options: {
...archiveInput(),
url: string("url").desc("Homarr URL; defaults to HOMARR_URL"),
keyEnv: string("api-key-env")
.default("HOMARR_API_KEY")
.desc("Environment variable containing an admin Homarr API key"),
bindings: string("bindings").desc(
"Local JSON mapping collection connection slots to existing Homarr connection IDs",
),
},
handler: async ({ file, url, keyEnv, bindings }) => {
const base = new URL(url ?? process.env.HOMARR_URL ?? "http://localhost:7575");
if (!["http:", "https:"].includes(base.protocol) || base.username || base.password)
throw new Error("Use an HTTP(S) Homarr URL without embedded credentials");
const apiKey = process.env[keyEnv];
if (!apiKey) throw new Error(`Set ${keyEnv} to an admin API key from your Homarr instance`);
const { loadWidgetCollectionArchive } = await import("@homarr/custom-widgets/package/server");
const archive = await loadWidgetCollectionArchive(resolve(file));
const mapping = z
.record(widgetCollectionKeySchema, z.string().min(1))
.parse(bindings ? await readJson(resolve(bindings)) : {});
const client = createTRPCClient<AppRouter>({
links: [
httpLink({
url: new URL("api/trpc", `${base.href.replace(/\/$/u, "")}/`).href,
transformer: superjson,
headers: { ApiKey: apiKey },
fetch: (input, init) =>
fetch(input, {
...init,
redirect: "error",
signal: AbortSignal.any([AbortSignal.timeout(300_000), ...(init?.signal ? [init.signal] : [])]),
}),
}),
],
});
const installed = await client.customWidget.package.importCollection.mutate({ archive, bindings: mapping });
console.log(`Imported ${installed.entries.length} disabled widgets. Collection import: ${installed.importId}`);
for (const entry of installed.entries)
console.log(
`${entry.key}: ${new URL(entry.managementPath.replace(/^\//u, ""), `${base.href.replace(/\/$/u, "")}/`).href}`,
);
console.log("Review each package and explicitly trust it before previewing or activating it.");
},
});
export const widgetCollectionCommand = command({
name: "collection",
desc: "Package and import several independent dashboard widgets with shared local connections",
subcommands: [pack, validate, install],
});
+37
View File
@@ -0,0 +1,37 @@
import { randomUUID } from "node:crypto";
import { command, positional, string } from "@drizzle-team/brocli";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { widgetReferencePackages } from "@homarr/widget-sdk/examples";
export const widgetInitCommand = command({
name: "init",
aliases: ["scaffold"],
desc: "Create an editable local widget project from a reference package",
options: {
directory: positional("directory").required().desc("New project directory (must not already exist)"),
example: string("example")
.default("clock")
.desc(`Reference package: ${Object.keys(widgetReferencePackages).join(", ")}`),
id: string("id").desc("Package identity; a unique local identity is generated by default"),
name: string("name").desc("Widget display name"),
},
handler: async ({ directory, example, id, name }) => {
const reference = widgetReferencePackages[example as keyof typeof widgetReferencePackages];
if (!reference)
throw new Error(`Unknown example '${example}'. Choose ${Object.keys(widgetReferencePackages).join(", ")}`);
const source = customWidgetPackageSchema.parse({
...reference,
manifest: {
...reference.manifest,
id: id ?? `local.${randomUUID()}`,
name: name ?? reference.manifest.name,
version: "1.0.0",
},
});
const { writeCustomWidgetProject } = await import("@homarr/custom-widgets/package/server");
const path = await writeCustomWidgetProject(source, directory);
console.log(`Created ${source.manifest.id}: ${path}`);
console.log(`Edit homarr-widget.json and the source files, then run: homarr widgets validate ${directory}`);
},
});
+124
View File
@@ -0,0 +1,124 @@
import { readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { boolean, command, positional, string } from "@drizzle-team/brocli";
import { createTRPCClient, httpLink } from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@homarr/api";
interface ProjectConnection {
url: string;
installationId: string;
previewId?: string;
}
export const widgetPreviewCommand = command({
name: "preview",
desc: "Preview local source using an installed Homarr and its owner-bound connections",
options: {
file: positional("path").required().desc("Widget project directory or package JSON"),
url: string("url").desc("Homarr URL; defaults to HOMARR_URL"),
keyEnv: string("api-key-env").default("HOMARR_API_KEY").desc("Variable holding an admin Homarr API key"),
installationId: string("installation-id").desc(
"Existing local installation; otherwise reuse this project's preview installation",
),
bindings: string("bindings").desc("JSON file mapping named connections to Homarr connection IDs"),
options: string("options").desc("JSON file containing preview option values"),
scenario: string("scenario").desc("Named synthetic preview scenario declared in the package"),
trust: boolean("trust").desc("Trust this source to execute with Homarr's privileges"),
liveActions: boolean("live-actions").desc("Enable real actions in this preview session"),
saveDraft: boolean("save-draft").desc("Also replace the selected installation's saved source draft"),
},
handler: async ({
file,
url,
keyEnv,
installationId,
bindings,
options,
scenario,
trust,
liveActions,
saveDraft,
}) => {
if (trust !== true) throw new Error("Pass --trust to preview this code with Homarr's privileges");
const base = new URL(url ?? process.env.HOMARR_URL ?? "http://localhost:7575");
if (!["http:", "https:"].includes(base.protocol) || base.username || base.password)
throw new Error("Use an HTTP(S) Homarr URL without embedded credentials");
const apiKey = process.env[keyEnv];
if (!apiKey) throw new Error(`Set ${keyEnv} to an admin API key from your Homarr instance`);
const { loadCustomWidgetProject } = await import("@homarr/custom-widgets/package/server");
const project = await loadCustomWidgetProject(file);
const statePath = join(project.projectDirectory, ".homarr-widget.local.json");
const previous = await readProjectConnection(statePath);
const client = createTRPCClient<AppRouter>({
links: [
httpLink({
url: new URL("api/trpc", `${base.href.replace(/\/$/u, "")}/`).href,
transformer: superjson,
headers: { ApiKey: apiKey },
fetch: (input, init) =>
fetch(input, {
...init,
redirect: "error",
signal: AbortSignal.any([AbortSignal.timeout(300_000), ...(init?.signal ? [init.signal] : [])]),
}),
}),
],
});
let id = installationId;
if (!id && previous?.url === base.href) id = previous.installationId;
if (!id || saveDraft)
id = (
await client.customWidget.package.saveDraft.mutate({
id,
name: project.source.manifest.name,
source: project.source,
})
).id;
const preview = await client.customWidget.package.preview.mutate({
id,
source: project.source,
trusted: true,
liveActions: liveActions === true,
bindings: await readJsonRecord<string>(bindings),
options: await readJsonRecord(options),
scenario,
});
await writeFile(statePath, JSON.stringify({ url: base.href, installationId: id, previewId: preview.previewId }), {
mode: 0o600,
});
if (previous?.previewId && previous.url === base.href)
await client.customWidget.package.discardPreview.mutate({ previewId: previous.previewId }).catch(() => undefined);
console.log(
`Preview: ${new URL(`manage/custom-widgets/packages/preview/${preview.previewId}`, `${base.href.replace(/\/$/u, "")}/`).href}`,
);
console.log(`Installation: ${id}. Open the preview while signed in as the API key's owner.`);
},
});
async function readProjectConnection(path: string): Promise<ProjectConnection | null> {
try {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (
value &&
typeof value === "object" &&
"url" in value &&
"installationId" in value &&
typeof value.url === "string" &&
typeof value.installationId === "string"
)
return value as ProjectConnection;
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
}
return null;
}
async function readJsonRecord<T = unknown>(path?: string): Promise<Record<string, T>> {
if (!path) return {};
const value: unknown = JSON.parse(await readFile(resolve(path), "utf8"));
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("Preview bindings/options must be JSON objects");
return value as Record<string, T>;
}
@@ -0,0 +1,45 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { command, number, positional, string } from "@drizzle-team/brocli";
import { customWidgetArchiveSchema } from "@homarr/custom-widgets/package";
export const widgetPublishCommand = command({
name: "publish",
desc: "Publish the exact packed archive as an immutable authenticated Workshop release",
options: {
file: positional("archive").required().desc("Packed homarr-widget archive JSON"),
url: string("url").desc("Workshop API URL; defaults to WORKSHOP_API_URL or https://homarr.dev"),
tokenEnv: string("token-env").default("HOMARR_WORKSHOP_TOKEN").desc("Variable holding a Workshop account token"),
submissionId: string("submission-id").desc("Owned Workshop listing to update; omit for a new package"),
revision: number("expected-revision").int().min(1).desc("Current listing revision, required for updates"),
forkedFrom: string("forked-from").desc("Original immutable release ID when publishing a fork"),
changelog: string("changelog").desc("Release changes (up to 2000 characters)"),
},
handler: async ({ file, url, tokenEnv, submissionId, revision, forkedFrom, changelog }) => {
const archive = customWidgetArchiveSchema.parse(JSON.parse(await readFile(resolve(file), "utf8")));
const { assertWidgetArtifactIntegrity } = await import("@homarr/custom-widgets/package/server");
assertWidgetArtifactIntegrity(archive.artifact, archive.source);
const token = process.env[tokenEnv];
if (!token) throw new Error(`Set ${tokenEnv} to a Workshop account token before publishing`);
if (submissionId && !revision) throw new Error("Updating a listing requires --expected-revision");
const base = new URL(url ?? process.env.WORKSHOP_API_URL ?? "https://homarr.dev");
if (!["http:", "https:"].includes(base.protocol) || base.username || base.password)
throw new Error("Use an HTTP(S) Workshop URL without embedded credentials");
const response = await fetch(new URL("api/workshop/package-releases", `${base.href.replace(/\/$/u, "")}/`), {
method: "POST",
redirect: "error",
signal: AbortSignal.timeout(60_000),
headers: { authorization: token, "content-type": "application/json" },
body: JSON.stringify({ ...archive, submissionId, expectedRevision: revision, forkedFrom, changelog }),
});
if (!response.ok)
throw new Error(
`Workshop rejected the release (HTTP ${response.status}); check account ownership, revision and version`,
);
const result: unknown = await response.json();
if (!result || typeof result !== "object" || !("id" in result) || typeof result.id !== "string")
throw new Error("Workshop returned an invalid release");
console.log(`Published ${archive.source.manifest.id}@${archive.source.manifest.version}, release ${result.id}`);
},
});
@@ -0,0 +1,47 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { boolean, command, number, positional, string } from "@drizzle-team/brocli";
import { customWidgetArchiveSchema } from "@homarr/custom-widgets/package";
export const widgetRunnerCommand = command({
name: "serve",
desc: "Run one installed trusted widget archive on this machine without registry access",
options: {
file: positional("file").required().desc("Trusted widget archive JSON"),
trust: boolean("trust").desc("Allow the package to execute with this process's operating system privileges"),
host: string("host").default("127.0.0.1").desc("Interface to listen on"),
port: number("port").int().min(0).max(65535).default(8787).desc("HTTP port"),
tokenEnv: string("token-env")
.default("HOMARR_WIDGET_RUNNER_TOKEN")
.desc("Environment variable containing the runner bearer token"),
},
handler: async ({ file, trust, host, port, tokenEnv }) => {
if (trust !== true) throw new Error("Pass --trust to execute this package with this machine's privileges");
const token = process.env[tokenEnv];
if (!token) throw new Error(`Set ${tokenEnv} to a random token with at least 32 characters`);
const archive = customWidgetArchiveSchema.parse(JSON.parse(await readFile(resolve(file), "utf8")));
const { assertWidgetArtifactIntegrity, startWidgetPackageRunner } =
await import("@homarr/custom-widgets/package/server");
assertWidgetArtifactIntegrity(archive.artifact, archive.source);
const runner = await startWidgetPackageRunner({
artifact: archive.artifact,
rootDirectory: resolve(dirname(file), ".homarr-widgets"),
token,
host,
port,
});
console.log(
`Serving ${archive.source.manifest.id}@${archive.source.manifest.version} at ${JSON.stringify(runner.address)}`,
);
await new Promise<void>((done, reject) => {
const stop = () => {
process.removeListener("SIGINT", stop);
process.removeListener("SIGTERM", stop);
void runner.close().then(done, reject);
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
});
},
});
+93
View File
@@ -0,0 +1,93 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { boolean, command, positional, string } from "@drizzle-team/brocli";
import { customWidgetArchiveSchema } from "@homarr/custom-widgets/package";
import { widgetRunnerCommand } from "./widget-runner";
import { widgetInitCommand } from "./widget-init";
import { widgetPreviewCommand } from "./widget-preview";
import { widgetPublishCommand } from "./widget-publish";
import { widgetCollectionCommand } from "./widget-collection";
const inputOptions = () => ({
file: positional("path").required().desc("Widget project directory, package JSON, or archive"),
});
const buildOptions = () => ({
...inputOptions(),
output: string("output").alias("o").desc("Output JSON file (must not already exist)"),
trust: boolean("trust").desc("Trust the package and allow installing its exact dependencies"),
});
const validate = command({
name: "validate",
desc: "Validate a widget package or offline archive without executing its code",
options: inputOptions(),
handler: async ({ file }) => {
const { loadCustomWidgetProject } = await import("@homarr/custom-widgets/package/server");
const { source, archive } = await loadCustomWidgetProject(file);
if (archive) {
console.log(`Valid archive: ${source.manifest.id}@${source.manifest.version}`);
return;
}
console.log(`Valid package: ${source.manifest.id}@${source.manifest.version}`);
},
});
const build = command({
name: "build",
desc: "Compile a widget package to an immutable browser/server artifact",
options: buildOptions(),
handler: async ({ file, output, trust }) => {
const { compileCustomWidgetPackage, loadCustomWidgetProject } =
await import("@homarr/custom-widgets/package/server");
const project = await loadCustomWidgetProject(file);
const artifact =
project.archive?.artifact ??
(await compileCustomWidgetPackage(project.source, {
rootDirectory: join(project.projectDirectory, ".homarr-widgets"),
allowDependencyInstall: trust === true,
}));
const target = resolve(output ?? join(project.projectDirectory, "dist", "widget.artifact.json"));
await mkdir(dirname(target), { recursive: true });
await writeFile(target, JSON.stringify(artifact, null, 2), { flag: "wx", mode: 0o600 });
console.log(`Built ${artifact.digest}: ${target}`);
},
});
const pack = command({
name: "pack",
desc: "Package source, assets, dependency lock and compiled code for offline installation",
options: buildOptions(),
handler: async ({ file, output, trust }) => {
const { compileCustomWidgetPackage, loadCustomWidgetProject } =
await import("@homarr/custom-widgets/package/server");
const project = await loadCustomWidgetProject(file);
const source = project.source;
const artifact =
project.archive?.artifact ??
(await compileCustomWidgetPackage(source, {
rootDirectory: join(project.projectDirectory, ".homarr-widgets"),
allowDependencyInstall: trust === true,
}));
const archive = customWidgetArchiveSchema.parse({ format: "homarr-widget-archive-v3", source, artifact });
const target = resolve(output ?? join(project.projectDirectory, "dist", "widget.homarr-widget.json"));
await mkdir(dirname(target), { recursive: true });
await writeFile(target, JSON.stringify(archive, null, 2), { flag: "wx", mode: 0o600 });
console.log(`Packed ${source.manifest.id}@${source.manifest.version}: ${target}`);
},
});
export const widgetsRoot = command({
name: "widgets",
desc: "Validate, build and package trusted custom widgets",
subcommands: [
widgetInitCommand,
validate,
build,
pack,
widgetPreviewCommand,
widgetPublishCommand,
widgetRunnerCommand,
widgetCollectionCommand,
],
});
+4 -1
View File
@@ -2,6 +2,7 @@ import { run } from "@drizzle-team/brocli";
import type { Command } from "@drizzle-team/brocli";
import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness";
import { widgetsRoot } from "./commands/widgets";
const harnessCommandNames = new Set(["harness", "local-harness"]);
@@ -16,6 +17,8 @@ async function main(): Promise<void> {
if (harnessInvocation) {
commands = [harnessRoot];
} else if (process.argv[2] === "widgets") {
commands = [widgetsRoot];
} else {
const [{ fixUsernames }, { integrationsRoot }, { recreateAdmin }, { resetPassword }, { usersRoot }] =
await Promise.all([
@@ -25,7 +28,7 @@ async function main(): Promise<void> {
import("./commands/reset-password"),
import("./commands/users"),
]);
commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot];
commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot, widgetsRoot];
}
let argSource = process.argv;