From 9751530af83d1ead5b708442faf4435ac25ca05e Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:22:35 +0200 Subject: [PATCH] feat: plugin wrapper type safety (#29300) --- packages/plugin-core/manifest.json | 11 +- packages/plugin-core/package.json | 2 +- packages/plugin-core/src/index.d.ts | 2 +- packages/plugin-core/src/index.ts | 46 +++--- packages/plugin-sdk/src/sdk.ts | 139 +++++++++++++------ server/src/repositories/plugin.repository.ts | 1 + 6 files changed, 127 insertions(+), 74 deletions(-) diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index 0f1b88827c..15d705b885 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -222,7 +222,16 @@ "name": "assetLock", "title": "Move to locked folder", "description": "Change visibility to locked", - "types": ["AssetV1"] + "types": ["AssetV1"], + "schema": { + "properties": { + "inverse": { + "title": "Inverse", + "description": "When true will unarchive any archived assets", + "type": "boolean" + } + } + } }, { "name": "assetTimeline", diff --git a/packages/plugin-core/package.json b/packages/plugin-core/package.json index 26b5124426..baddf4a6eb 100644 --- a/packages/plugin-core/package.json +++ b/packages/plugin-core/package.json @@ -5,7 +5,7 @@ "main": "src/index.ts", "scripts": { "build": "pnpm build:tsc && pnpm build:wasm", - "build:tsc": "tsc --noEmit && node esbuild.js", + "build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js", "build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm" }, "keywords": [], diff --git a/packages/plugin-core/src/index.d.ts b/packages/plugin-core/src/index.d.ts index 107bcc7aa0..636cb03047 100644 --- a/packages/plugin-core/src/index.d.ts +++ b/packages/plugin-core/src/index.d.ts @@ -22,6 +22,6 @@ declare module 'main' { export function assetArchive(): I32; export function assetLock(): I32; export function assetTimeline(): I32; - export function assetTrash(): I32; + // export function assetTrash(): I32; export function assetAddToAlbums(): I32; } diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index fa956b8cec..12eaab404b 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -1,13 +1,11 @@ -import { wrapper } from '@immich/plugin-sdk'; -import { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk'; +import { getWrapper } from '@immich/plugin-sdk'; +import { AssetVisibility } from '@immich/sdk'; +import type manifestType from '../dist/manifest'; + +const wrapper = getWrapper(); -type AssetFileFilterConfig = { - pattern: string; - matchType?: 'contains' | 'exact' | 'regex' | 'startsWith'; - caseSensitive?: boolean; -}; export const assetFileFilter = () => { - return wrapper(({ data, config }) => { + return wrapper<'assetFileFilter'>(({ data, config }) => { const { pattern, matchType = 'contains', caseSensitive = false } = config; const { asset } = data; @@ -43,7 +41,7 @@ export const assetFileFilter = () => { }; export const assetMissingTimeZoneFilter = () => { - return wrapper(({ config, data }) => { + return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => { const hasTimeZone = !!data.asset?.exifInfo?.timeZone; const needsTimeZone = config.inverse ? true : false; return { workflow: { continue: hasTimeZone === needsTimeZone } }; @@ -51,13 +49,7 @@ export const assetMissingTimeZoneFilter = () => { }; export const assetLocationFilter = () => { - return wrapper< - WorkflowType.AssetV1, - { - region?: { country?: string; state?: string; city?: string }; - coordinate?: { latitude?: string; longitude?: string; radius?: number }; - } - >(({ config, data }) => { + return wrapper<'assetLocationFilter'>(({ config, data }) => { if ( (config.region?.country && config.region.country !== data.asset.exifInfo?.country) || (config.region?.state && config.region.state !== data.asset.exifInfo?.state) || @@ -96,13 +88,13 @@ export const assetLocationFilter = () => { }; export const assetTypeFilter = () => { - return wrapper(({ config, data }) => { + return wrapper<'assetTypeFilter'>(({ config, data }) => { return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; }); }; export const assetFavorite = () => { - return wrapper(({ config, data }) => { + return wrapper<'assetFavorite'>(({ config, data }) => { const target = config.inverse ? false : true; if (target !== data.asset.isFavorite) { return { @@ -115,13 +107,13 @@ export const assetFavorite = () => { }; export const assetVisibility = () => { - return wrapper(({ config }) => ({ - changes: { asset: { visibility: config.visibility } }, + return wrapper<'assetVisibility'>(({ config }) => ({ + changes: { asset: { visibility: config.visibility as AssetVisibility } }, })); }; export const assetArchive = () => { - return wrapper(({ config, data }) => { + return wrapper<'assetArchive'>(({ config, data }) => { if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) { return { changes: { asset: { visibility: AssetVisibility.Archive } } }; } @@ -135,7 +127,7 @@ export const assetArchive = () => { }; export const assetLock = () => { - return wrapper(({ config, data }) => { + return wrapper<'assetLock'>(({ config, data }) => { if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) { return { changes: { asset: { visibility: AssetVisibility.Locked } } }; } @@ -148,13 +140,13 @@ export const assetLock = () => { }); }; -export const assetTrash = () => { - // TODO use trash/untrash host functions - return wrapper(() => ({})); -}; +// export const assetTrash = () => { +// // TODO use trash/untrash host functions +// return wrapper(() => ({})); +// }; export const assetAddToAlbums = () => { - return wrapper(({ config, data, functions }) => { + return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => { const assetId = data.asset.id; if (config.albumIds.length === 0) { diff --git a/packages/plugin-sdk/src/sdk.ts b/packages/plugin-sdk/src/sdk.ts index 5b0443513a..e428eafed5 100644 --- a/packages/plugin-sdk/src/sdk.ts +++ b/packages/plugin-sdk/src/sdk.ts @@ -1,53 +1,104 @@ import type { WorkflowType } from '@immich/sdk'; import { hostFunctions } from 'src/host-functions.js'; import type { - ConfigValue, WorkflowEventPayload, WorkflowResponse, WorkflowStepConfig, } from 'src/types.js'; -export const wrapper = < - T extends WorkflowType, - TConfig extends ConfigValue = ConfigValue, ->( - fn: ( - payload: WorkflowEventPayload & { - functions: ReturnType; - }, - ) => WorkflowResponse | undefined, -) => { - const input = Host.inputString(); - - try { - const payload = JSON.parse(input) as WorkflowEventPayload; - const event = { - ...payload, - functions: hostFunctions(payload.workflow.authToken), - }; - - const eventConfigBefore = JSON.stringify(event.config); - - console.debug( - `Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`, - ); - - const response = fn(event) ?? {}; - - // if config changed, notify host - const eventConfigAfter = JSON.stringify(event.config); - if (!response.config && eventConfigBefore !== eventConfigAfter) { - response.config = event.config as WorkflowStepConfig; - } - - console.debug( - `Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`, - ); - - const output = JSON.stringify(response); - Host.outputString(output); - } catch (error: Error | any) { - console.error(`Unhandled plugin exception: ${error.message || error}`); - throw error; - } +type Property = { + type: 'string' | 'boolean' | 'number'; + array?: boolean; + enum?: string[]; +} & { + type: 'object'; + properties: { [K: string]: Property }; + required?: string[]; }; + +type RequiredProperties< + Properties extends { [K: string]: unknown }, + Required extends string[] | undefined, + RequiredKeys extends string = Required extends undefined + ? never + : NonNullable[number], +> = { + properties: Pick & + Partial>; +}; + +type GetConfigType = 'enum' extends keyof T + ? NonNullable[number] + : T['type'] extends 'boolean' + ? boolean + : T['type'] extends 'number' + ? number + : T['type'] extends 'string' + ? string + : T['type'] extends 'object' + ? ConfigValue + : never; + +type ConfigValue< + T extends { properties: { [K: string]: Property }; required?: string[] }, + Properties extends { [K: string]: Property } = T['properties'], +> = T extends never + ? never + : RequiredProperties< + { + [K in keyof Properties]: Properties[K]['array'] extends true + ? Array> + : GetConfigType; + }, + 'required' extends keyof T ? T['required'] : undefined + >['properties']; + +export const getWrapper = + >() => + < + K extends T['methods'][number]['name'], + L extends WorkflowType = (T['methods'][number] & { + name: K; + })['types'][number], + TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>, + >( + fn: ( + payload: WorkflowEventPayload & { + functions: ReturnType; + }, + ) => WorkflowResponse | undefined, + ) => { + const input = Host.inputString(); + + try { + const payload = JSON.parse(input) as WorkflowEventPayload; + const event = { + ...payload, + functions: hostFunctions(payload.workflow.authToken), + }; + + const eventConfigBefore = JSON.stringify(event.config); + + console.debug( + `Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`, + ); + + const response = fn(event) ?? {}; + + // if config changed, notify host + const eventConfigAfter = JSON.stringify(event.config); + if (!response.config && eventConfigBefore !== eventConfigAfter) { + response.config = event.config as WorkflowStepConfig; + } + + console.debug( + `Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`, + ); + + const output = JSON.stringify(response); + Host.outputString(output); + } catch (error: Error | any) { + console.error(`Unhandled plugin exception: ${error.message || error}`); + throw error; + } + }; diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts index 43006c6aa6..154266b965 100644 --- a/server/src/repositories/plugin.repository.ts +++ b/server/src/repositories/plugin.repository.ts @@ -224,6 +224,7 @@ export class PluginRepository { error: (message) => logger.error(message), } as Console, logLevel: asExtismLogLevel(logger.getLogLevel()), + enableWasiOutput: true, }, ), destroy: (plugin) => plugin.close(),