fix: simplify custom widget package workbench
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user