feat(custom-widgets): add extensible widget platform
This commit is contained in:
1 parent
6cca757493
commit
8f152f1929
317 files changed
+32326
-1788
No files matched your search
@@ -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,2 +1,3 @@
|
||||
export * from "./backend";
|
||||
export * from "./schema";
|
||||
export * from "./package-releases";
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./backend";
|
||||
export * from "./schema";
|
||||
export * from "./package-releases";
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user