fix: simplify custom widget package workbench

This commit is contained in:
ajnart committed 2026-09-12 15:33:51 +02:00
1 parent 9a52a035aa
commit 48042a08ff
37 files changed
+1155 -608

No files matched your search

@@ -7,8 +7,18 @@ import { assertWidgetArtifactIntegrity } from "@homarr/custom-widgets/package/se
import { getArtifact, getInstallation, readPackageDraft } from "./records";
import { widgetPackageAdminProcedure } from "./procedure";
import { getWidgetPackageSupervisor } from "./invocations";
export const packageArtifactReviewProcedures = {
runtimeStatus: widgetPackageAdminProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const installation = await getInstallation(ctx, input.id);
if (!installation.activeArtifactId) return { state: "unactivated" as const };
const { artifact } = await getArtifact(ctx, installation.activeArtifactId);
if (!artifact.server) return { state: "browserOnly" as const };
return getWidgetPackageSupervisor().inspect(artifact.digest);
}),
inspectArtifact: widgetPackageAdminProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
@@ -42,10 +42,13 @@ export async function getBoundConnection(ctx: PackageContext, bindings: Record<s
const row = await ctx.db.query.customWidgetConnections.findFirst({ where: eq(customWidgetConnections.id, id) });
if (!row) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Bound connection is unavailable" });
const configuration = connectionConfigurationSchema.parse(JSON.parse(row.configuration));
const integration = row.integrationId
? await ctx.db.query.integrations.findFirst({ where: eq(integrations.id, row.integrationId) })
: undefined;
const secrets = z
.record(z.string(), z.string())
.parse(JSON.parse(decryptSecret(row.encryptedSecrets as `${string}.${string}`)));
return { row, configuration, secrets };
return { row, configuration: { ...configuration, integrationKind: integration?.kind }, secrets };
}
export async function getBindingsDigest(ctx: PackageContext, bindings: Record<string, string>) {
@@ -81,9 +84,14 @@ const admin = widgetPackageAdminProcedure;
export const packageConnectionProcedures = {
connections: admin.query(async ({ ctx }) => {
const rows = await ctx.db.query.customWidgetConnections.findMany();
const nativeIntegrations = await ctx.db.query.integrations.findMany({ columns: { id: true, kind: true } });
const kinds = new Map(nativeIntegrations.map(({ id, kind }) => [id, kind]));
return rows.map(({ encryptedSecrets: _secrets, ...row }) => ({
...row,
configuration: JSON.parse(row.configuration) as unknown,
configuration: {
...connectionConfigurationSchema.parse(JSON.parse(row.configuration)),
integrationKind: row.integrationId ? kinds.get(row.integrationId) : undefined,
},
}));
}),
saveConnection: admin
@@ -1,4 +1,4 @@
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { getWidgetConnectionRequirement, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { widgetPackageAdminProcedure } from "./procedure";
import { TRPCError } from "@trpc/server";
import { nativeScaffoldProcedures } from "./native-scaffold";
@@ -187,7 +187,7 @@ export const customWidgetPackageRouter = createTRPCRouter({
const draft = customWidgetPackageSchema.safeParse(JSON.parse(installation.draft));
for (const [name] of Object.entries(input.bindings)) {
const connection = await getBoundConnection(ctx, input.bindings, name);
const requirement = draft.success ? draft.data.connections[name] : undefined;
const requirement = draft.success ? getWidgetConnectionRequirement(draft.data.connections, name) : undefined;
if (requirement && !isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "BAD_REQUEST",
@@ -1,4 +1,4 @@
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { getWidgetConnectionBindingNames, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { TRPCError } from "@trpc/server";
import { parse, stringify } from "superjson";
import { z } from "zod/v4";
@@ -60,13 +60,17 @@ export async function prepareWidgetMigration(
throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Placement ${placement.id}: ${issues[0]?.message}` });
const mergedBindings = { ...bindings, ...placement.packageOptions.connectionBindings };
for (const [name, requirement] of Object.entries(source.connections)) {
if (requirement.optional && !mergedBindings[name]) continue;
const connection = await getBoundConnection(ctx, mergedBindings, name);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Connection '${name}' requires ${requirement.serviceType ?? requirement.kind}`,
});
const names = getWidgetConnectionBindingNames(name, requirement, mergedBindings);
if (!names.length && !requirement.optional)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Connection '${name}' needs setup` });
for (const bindingName of names) {
const connection = await getBoundConnection(ctx, mergedBindings, bindingName);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Connection '${name}' requires ${requirement.serviceType ?? requirement.kind}`,
});
}
}
const raw: unknown = parse(placement.options);
if (!isRecord(raw)) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Placement options are invalid" });
@@ -59,29 +59,34 @@ export const nativeScaffoldProcedures = {
for (const [name, requirement] of Object.entries(source.connections)) {
if (requirement.kind !== "integration") continue;
const accepted = acceptedIntegrationKinds(input.starter, name);
const integration = bound.find(
const matches = bound.filter(
(candidate) => accepted.includes(candidate.kind) && !usedIntegrationIds.has(candidate.id),
);
if (!integration) continue;
usedIntegrationIds.add(integration.id);
let connection = await ctx.db.query.customWidgetConnections.findFirst({
where: eq(customWidgetConnections.integrationId, integration.id),
});
if (!connection) {
const id = createId();
const row = {
id,
name: integration.name,
integrationId: integration.id,
configuration: JSON.stringify(connectionConfigurationSchema.parse({ kind: "integration" })),
encryptedSecrets: encryptSecret("{}"),
createdAt: new Date(),
updatedAt: new Date(),
};
await ctx.db.insert(customWidgetConnections).values(row);
connection = row;
let candidates = matches;
if (!requirement.multiple) candidates = matches.slice(0, 1);
for (const [index, integration] of candidates.entries()) {
usedIntegrationIds.add(integration.id);
let connection = await ctx.db.query.customWidgetConnections.findFirst({
where: eq(customWidgetConnections.integrationId, integration.id),
});
if (!connection) {
const id = createId();
const row = {
id,
name: integration.name,
integrationId: integration.id,
configuration: JSON.stringify(connectionConfigurationSchema.parse({ kind: "integration" })),
encryptedSecrets: encryptSecret("{}"),
createdAt: new Date(),
updatedAt: new Date(),
};
await ctx.db.insert(customWidgetConnections).values(row);
connection = row;
}
let bindingName = name;
if (index > 0) bindingName = `${name}:${connection.id}`;
bindings[bindingName] = connection.id;
}
bindings[name] = connection.id;
}
return { name: `${source.manifest.name} (custom)`, source, bindings, sourceBoardId: item.boardId };
}),
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetInstallations } from "@homarr/db/schema";
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { getWidgetConnectionBindingNames, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { publishWidgetPackageChange } from "./events";
import { getWidgetCollection } from "./portable-collection-records";
@@ -34,7 +34,11 @@ export async function setWidgetCollectionBindings(
message: `Widget '${entry.key}' has an invalid source draft. Repair it first.`,
});
const nextBindings = { ...entry.bindings };
for (const name of Object.keys(entry.mapping)) delete nextBindings[name];
for (const name of Object.keys(entry.mapping)) {
for (const key of Object.keys(nextBindings)) {
if (key === name || key.startsWith(`${name}:`)) delete nextBindings[key];
}
}
Object.assign(nextBindings, mapCollectionBindings(entry.mapping, bindings));
for (const name of Object.keys(entry.mapping)) {
const requirement = entry.requirements[name];
@@ -43,13 +47,14 @@ export async function setWidgetCollectionBindings(
code: "PRECONDITION_FAILED",
message: `Widget '${entry.key}' no longer declares connection '${name}'. Review its package mapping.`,
});
if (!nextBindings[name]) continue;
const connection = await getBoundConnection(ctx, nextBindings, name);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "BAD_REQUEST",
message: `Widget '${entry.key}' connection '${name}' is incompatible with the selected shared connection`,
});
for (const bindingName of getWidgetConnectionBindingNames(name, requirement, nextBindings)) {
const connection = await getBoundConnection(ctx, nextBindings, bindingName);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "BAD_REQUEST",
message: `Widget '${entry.key}' connection '${name}' is incompatible with the selected shared connection`,
});
}
}
changes.push({
id: entry.id,
@@ -78,7 +78,11 @@ export async function getWidgetCollection(ctx: PackageContext, importId: string)
for (const entry of entries) {
for (const [name, slot] of Object.entries(entry.mapping)) {
const bindings = values.get(slot) ?? new Set<string>();
bindings.add(entry.bindings[name] ?? "");
const members = Object.entries(entry.bindings)
.filter(([binding]) => binding === name || binding.startsWith(`${name}:`))
.map(([binding, id]) => [binding.slice(name.length), id])
.toSorted(([left], [right]) => (left ?? "").localeCompare(right ?? ""));
bindings.add(JSON.stringify(members));
values.set(slot, bindings);
}
}
@@ -87,8 +91,8 @@ export async function getWidgetCollection(ctx: PackageContext, importId: string)
for (const [slot, ids] of values) {
if (ids.size > 1) bindingConflicts.push(slot);
else {
const id = [...ids][0];
if (id) bindings[slot] = id;
const members = JSON.parse([...ids][0] ?? "[]") as [string, string][];
for (const [suffix, id] of members) bindings[`${slot}${suffix}`] = id;
}
}
return {
@@ -8,7 +8,7 @@ import type {
WidgetCollectionContents,
WidgetCollectionManifest,
} from "@homarr/custom-widgets/package";
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { getWidgetConnectionBindingNames, getWidgetConnectionRequirement, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { createWidgetCollectionArchive, validateWidgetCollectionArchive } from "@homarr/custom-widgets/package/server";
import { withWidgetArtifactReferenceLock } from "./artifact-references";
@@ -37,7 +37,7 @@ export async function validateCollectionBindings(
bindings: Record<string, string>,
) {
for (const [name] of Object.entries(bindings)) {
const requirement = collection.connections[name];
const requirement = getWidgetConnectionRequirement(collection.connections, name);
if (!requirement) throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown collection connection '${name}'` });
try {
const connection = await getBoundConnection(ctx, bindings, name);
@@ -55,9 +55,9 @@ export async function validateCollectionBindings(
export function mapCollectionBindings(mapping: Record<string, string>, bindings: Record<string, string>) {
return Object.fromEntries(
Object.entries(mapping).flatMap(([name, slot]) => {
const id = bindings[slot];
if (!id) return [];
return [[name, id]];
return Object.entries(bindings)
.filter(([binding]) => binding === slot || binding.startsWith(`${slot}:`))
.map(([binding, id]) => [`${name}${binding.slice(slot.length)}`, id]);
}),
);
}
@@ -145,7 +145,8 @@ export async function exportWidgetCollection(ctx: PackageContext, manifest: Widg
const bindings = parseBindings(row.bindings);
const mapping: Record<string, string> = {};
for (const [name, requirement] of Object.entries(source.connections)) {
const identity = `${requirement.kind}:${requirement.serviceType ?? ""}:${bindings[name] ?? `${id}:${name}`}`;
const members = getWidgetConnectionBindingNames(name, requirement, bindings).map((key) => bindings[key]).toSorted();
const identity = JSON.stringify([requirement.kind, requirement.serviceType, requirement.integrationKind, requirement.multiple, members.length ? members : [id, name]]);
let slot = shared.get(identity);
if (!slot) {
slot = `connection${shared.size + 1}`;
@@ -6,7 +6,7 @@ import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { decryptSecret } from "@homarr/common/server";
import { createId } from "@homarr/common";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
import { customWidgetConnections, groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
import type { WidgetSdkInvocation } from "@homarr/custom-widgets/package/server";
import { readPackageHistory, widgetHistoryInputSchema } from "./history";
@@ -56,13 +56,24 @@ export function createWidgetSdkBridge(
}
signal?.throwIfAborted();
if (operation === "legacy.request") return invokeLegacyWidgetRequest(ctx, resolved, kind, input, signal);
if (operation === "widget.context")
if (operation === "widget.context") {
const ids = Object.values(resolved.bindings);
let labels = new Map<string, string>();
if (ids.length) {
const rows = await ctx.db.query.customWidgetConnections.findMany({
where: inArray(customWidgetConnections.id, ids),
columns: { id: true, name: true },
});
labels = new Map(rows.map((row) => [row.id, row.name]));
}
return {
options: resolved.configuration,
bindingNames: Object.keys(resolved.bindings),
bindingLabels: Object.fromEntries(Object.entries(resolved.bindings).map(([name, id]) => [name, labels.get(id) ?? name])),
installationId: resolved.installation.id,
isPreview: Boolean(resolved.preview),
};
}
if (operation === "integration.next" || operation === "integration.unsubscribe") {
const { id } = z.object({ id: z.string() }).parse(input);
const iterator = subscriptions.get(id);