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

+49 -4
View File
@@ -10,6 +10,12 @@ import {
} from "@tanstack/react-query";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
CUSTOM_WIDGET_PACKAGE_SCHEMA,
customWidgetPackageSchema,
stringifyWidgetJson,
} from "@homarr/custom-widgets/package";
import { WorkshopPackageReleases } from "./package-releases";
import type {
WorkshopComment,
@@ -266,10 +272,12 @@ function workshopError(error: unknown, fallback: string) {
export class WorkshopBackend {
public readonly pocketBase: TypedWorkshopPocketBase;
public readonly packages: WorkshopPackageReleases;
public constructor(public readonly baseUrl = WORKSHOP_API_URL) {
this.pocketBase = new PocketBase(baseUrl.replace(/\/$/u, "")) as TypedWorkshopPocketBase;
this.pocketBase.autoCancellation(false);
this.packages = new WorkshopPackageReleases(this.pocketBase);
}
public get currentUser(): WorkshopUser | null {
@@ -429,6 +437,22 @@ export class WorkshopBackend {
public async create(input: WorkshopSubmissionInput, screenshots: File[] = []) {
const parsed = workshopSubmissionInputSchema.parse(input);
workshopScreenshotsSchema.parse(screenshots);
if (parsed.type === "customWidget") {
const source = customWidgetPackageSchema.safeParse(JSON.parse(parsed.content));
if (source.success) {
const release = await this.packages.publish({
source: source.data,
title: parsed.title,
description: parsed.description,
changelog: parsed.changelog,
});
if (screenshots.length > 0)
await this.pocketBase
.collection("submissions")
.update(release.submission, { screenshots, expectedRevision: 1 });
return this.get(release.submission);
}
}
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, typeof value === "string" ? value : String(value)));
data.set("author", this.currentUser?.id ?? "");
@@ -447,16 +471,37 @@ export class WorkshopBackend {
const additions = screenshotChanges.additions ?? [];
const removals = screenshotChanges.removals ?? [];
workshopScreenshotsSchema.parse(additions);
const current = await this.get(id);
let current = await this.get(id);
const removalSet = new Set(removals);
const retainedCount = current.screenshots.filter((filename) => !removalSet.has(filename)).length;
if (retainedCount + additions.length > MAX_WORKSHOP_SCREENSHOTS)
throw new Error(`A submission can have up to ${MAX_WORKSHOP_SCREENSHOTS} screenshots`);
const validateScreenshotCount = () => {
const retainedCount = current.screenshots.filter((filename) => !removalSet.has(filename)).length;
if (retainedCount + additions.length > MAX_WORKSHOP_SCREENSHOTS)
throw new Error(`A submission can have up to ${MAX_WORKSHOP_SCREENSHOTS} screenshots`);
};
// Reject invalid edits before publishing a release that cannot be overwritten on retry.
validateScreenshotCount();
if (current.widgetSchema === CUSTOM_WIDGET_PACKAGE_SCHEMA) {
const source = customWidgetPackageSchema.parse(JSON.parse(parsed.content));
const previous = customWidgetPackageSchema.parse(JSON.parse(current.content));
if (stringifyWidgetJson(source) !== stringifyWidgetJson(previous)) {
await this.packages.publish({
submissionId: id,
expectedRevision: current.revision,
source,
title: parsed.title,
description: parsed.description,
changelog: parsed.changelog,
});
current = await this.get(id);
}
}
validateScreenshotCount();
const data: Record<string, unknown> = {
...parsed,
expectedRevision: current.revision,
};
if (current.widgetSchema === CUSTOM_WIDGET_PACKAGE_SCHEMA) data.content = current.content;
if (additions.length > 0) data["screenshots+"] = additions;
if (removals.length > 0) data["screenshots-"] = removals;
try {
+1
View File
@@ -1,2 +1,3 @@
export * from "./backend";
export * from "./schema";
export * from "./package-releases";
+1
View File
@@ -1,2 +1,3 @@
export * from "./backend";
export * from "./schema";
export * from "./package-releases";
+112
View File
@@ -0,0 +1,112 @@
import type PocketBase from "pocketbase";
import { z } from "zod/v4";
import { customWidgetArtifactSchema, customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import type { CustomWidgetArtifact, CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { WORKSHOP_REQUEST_TIMEOUT_MS } from "./schema";
export const workshopPackageReleaseSchema = z.object({
id: z.string(),
submission: z.string(),
author: z.string(),
packageId: z.string(),
version: z.string(),
sdkVersion: z.string(),
sourceDigest: z.string(),
artifactDigest: z.string().default(""),
content: z.string(),
artifact: z.string().default(""),
changelog: z.string().default(""),
forkedFrom: z.string().default(""),
created: z.string(),
});
export type WorkshopPackageRelease = z.infer<typeof workshopPackageReleaseSchema>;
export const workshopPackageReleaseSummarySchema = workshopPackageReleaseSchema.omit({ content: true, artifact: true });
export type WorkshopPackageReleaseSummary = z.infer<typeof workshopPackageReleaseSummarySchema>;
export interface WorkshopPackagePublishInput {
submissionId?: string;
expectedRevision?: number;
source: CustomWidgetPackage;
artifact?: CustomWidgetArtifact;
title?: string;
description?: string;
changelog?: string;
forkedFrom?: string;
}
/** This client fetches and publishes bytes; it never imports or evaluates widget code. */
export class WorkshopPackageReleases {
public constructor(private readonly pocketBase: PocketBase) {}
public async list(submissionId: string, signal?: AbortSignal): Promise<WorkshopPackageReleaseSummary[]> {
const records = await this.pocketBase.collection("widget_releases").getList(1, 100, {
filter: this.pocketBase.filter("submission = {:submission}", { submission: submissionId }),
sort: "-created",
signal: signal ?? AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS),
requestKey: null,
fields:
"id,submission,author,packageId,version,sdkVersion,sourceDigest,artifactDigest,changelog,forkedFrom,created",
});
return records.items.map((record) => workshopPackageReleaseSummarySchema.parse(record));
}
public async get(releaseId: string, signal?: AbortSignal): Promise<WorkshopPackageRelease> {
return workshopPackageReleaseSchema.parse(
await this.pocketBase
.collection("widget_releases")
.getOne(releaseId, { signal: signal ?? AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS), requestKey: null }),
);
}
public async publish(input: WorkshopPackagePublishInput): Promise<WorkshopPackageRelease> {
const source = customWidgetPackageSchema.parse(input.source);
let artifact: CustomWidgetArtifact | undefined;
if (input.artifact) artifact = customWidgetArtifactSchema.parse(input.artifact);
return workshopPackageReleaseSchema.parse(
await this.pocketBase.send("/api/workshop/package-releases", {
method: "POST",
body: { ...input, source, artifact },
requestKey: null,
signal: AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS),
}),
);
}
public async fork(releaseId: string, newPackageId: string) {
const release = await this.get(releaseId);
const source = customWidgetPackageSchema.parse(JSON.parse(release.content));
const fork = customWidgetPackageSchema.parse({
...source,
manifest: { ...source.manifest, id: newPackageId, version: "1.0.0" },
});
return {
source: fork,
origin: {
submissionId: release.submission,
releaseId: release.id,
forkedFrom: release.id,
version: release.version,
},
};
}
}
export function parseWorkshopPackageRelease(release: WorkshopPackageRelease) {
const source = customWidgetPackageSchema.parse(JSON.parse(release.content));
let artifact: CustomWidgetArtifact | undefined;
if (release.artifact) artifact = customWidgetArtifactSchema.parse(JSON.parse(release.artifact));
return {
source,
artifact,
origin: {
submissionId: release.submission,
releaseId: release.id,
version: release.version,
sourceDigest: release.sourceDigest,
artifactDigest: release.artifactDigest,
author: release.author,
forkedFrom: release.forkedFrom,
},
};
}
+29 -3
View File
@@ -1,4 +1,6 @@
import { CUSTOM_WIDGET_SCHEMA, customWidgetImportSchema } from "@homarr/custom-widgets/core";
import { CUSTOM_WIDGET_PACKAGE_SCHEMA, customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { z } from "zod/v4";
export const HOMARR_WEBSITE_URL = "https://homarr.dev";
@@ -10,7 +12,7 @@ export const WORKSHOP_SCHEMA_BY_TYPE = {
customCss: WORKSHOP_CSS_SCHEMA,
} as const;
export const MAX_WORKSHOP_CSS_LENGTH = 16_384;
export const MAX_WORKSHOP_CONTENT_LENGTH = 1_000_000;
export const MAX_WORKSHOP_CONTENT_LENGTH = 20_000_000;
export const MAX_WORKSHOP_SCREENSHOTS = 5;
export const MAX_WORKSHOP_SCREENSHOT_BYTES = 5 * 1024 * 1024;
export const WORKSHOP_REQUEST_TIMEOUT_MS = 8_000;
@@ -115,7 +117,10 @@ const normalizeWorkshopSubmissionRecord = (value: unknown) => {
const record = value as Record<string, unknown>;
if (record.type === "customWidget" || record.type === "customCss") return value;
if (record.widgetSchema === WORKSHOP_CSS_SCHEMA) return { ...record, type: "customCss" };
if (typeof record.widgetSchema === "string" && record.widgetSchema.startsWith("homarr-custom-widget-"))
if (
record.widgetSchema === CUSTOM_WIDGET_PACKAGE_SCHEMA ||
(typeof record.widgetSchema === "string" && record.widgetSchema.startsWith("homarr-custom-widget-"))
)
return { ...record, type: "customWidget" };
return value;
};
@@ -223,7 +228,7 @@ export type WorkshopWidgetValidationResult =
| { success: true; data: z.infer<typeof customWidgetImportSchema> }
| { success: false; error: string };
export type WorkshopValidationResult =
| { success: true; data: z.infer<typeof customWidgetImportSchema> | string }
| { success: true; data: z.infer<typeof customWidgetImportSchema> | CustomWidgetPackage | string }
| { success: false; error: string };
export function validateWorkshopContent(type: WorkshopSubmissionType, content: string): WorkshopValidationResult {
@@ -234,6 +239,27 @@ export function validateWorkshopContent(type: WorkshopSubmissionType, content: s
return { success: true, data: content };
}
try {
const candidate: unknown = JSON.parse(content);
if (
candidate &&
typeof candidate === "object" &&
"$schema" in candidate &&
candidate.$schema === CUSTOM_WIDGET_PACKAGE_SCHEMA
) {
const result = customWidgetPackageSchema.safeParse(candidate);
if (result.success) return { success: true, data: result.data };
return {
success: false,
error: result.error.issues
.slice(0, 5)
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join("\n"),
};
}
} catch {
return { success: false, error: "Widget content is not valid JSON" };
}
return validateWorkshopWidget(content);
}