feat: plugin-sdk safety all around (#29323)

This commit is contained in:
Daniel Dietzler 2026-06-26 00:23:55 +02:00 committed by GitHub
parent cb1af3a8ec
commit 688241a462
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 200 additions and 184 deletions

View file

@ -5,8 +5,8 @@
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"build": "pnpm build:tsc && pnpm build:wasm", "build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js", "build:tsc": "plugin-sdk prepareBuild && tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm" "build:wasm": "extism-js dist/index.js -i dist/index.d.ts -o dist/plugin.wasm"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

View file

@ -1,27 +0,0 @@
// keep in sync with plugin-sdk/host-functions.ts';
declare module 'extism:host' {
interface user {
searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
}
}
// keep in sync with manifest.json
declare module 'main' {
// filters
export function assetFileFilter(): I32;
export function assetMissingTimeZoneFilter(): I32;
export function assetLocationFilter(): I32;
export function assetTypeFilter(): I32;
// updates
export function assetFavorite(): I32;
export function assetVisibility(): I32;
export function assetArchive(): I32;
export function assetLock(): I32;
export function assetTimeline(): I32;
// export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
}

View file

@ -1,11 +1,10 @@
import { getWrapper } from '@immich/plugin-sdk'; import { getWrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk'; import { AssetVisibility } from '@immich/sdk';
import type manifestType from '../dist/manifest'; import type { Manifest } from '../dist/index.d.ts';
const wrapper = getWrapper<manifestType>(); const wrapper = getWrapper<Manifest>();
export const assetFileFilter = () => { export const assetFileFilter = wrapper<'assetFileFilter'>(({ data, config }) => {
return wrapper<'assetFileFilter'>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config; const { pattern, matchType = 'contains', caseSensitive = false } = config;
const { asset } = data; const { asset } = data;
@ -38,18 +37,14 @@ export const assetFileFilter = () => {
} }
} }
}); });
};
export const assetMissingTimeZoneFilter = () => { export const assetMissingTimeZoneFilter = wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone; const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false; const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } }; return { workflow: { continue: hasTimeZone === needsTimeZone } };
}); });
};
export const assetLocationFilter = () => { export const assetLocationFilter = wrapper<'assetLocationFilter'>(({ config, data }) => {
return wrapper<'assetLocationFilter'>(({ config, data }) => {
if ( if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) || (config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) || (config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
@ -85,16 +80,12 @@ export const assetLocationFilter = () => {
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } }; return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
}); });
};
export const assetTypeFilter = () => { export const assetTypeFilter = wrapper<'assetTypeFilter'>(({ config, data }) => {
return wrapper<'assetTypeFilter'>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
}); });
};
export const assetFavorite = () => { export const assetFavorite = wrapper<'assetFavorite'>(({ config, data }) => {
return wrapper<'assetFavorite'>(({ config, data }) => {
const target = config.inverse ? false : true; const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) { if (target !== data.asset.isFavorite) {
return { return {
@ -104,16 +95,12 @@ export const assetFavorite = () => {
}; };
} }
}); });
};
export const assetVisibility = () => { export const assetVisibility = wrapper<'assetVisibility'>(({ config }) => ({
return wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } }, changes: { asset: { visibility: config.visibility as AssetVisibility } },
})); }));
};
export const assetArchive = () => { export const assetArchive = wrapper<'assetArchive'>(({ config, data }) => {
return wrapper<'assetArchive'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) { if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } }; return { changes: { asset: { visibility: AssetVisibility.Archive } } };
} }
@ -124,10 +111,8 @@ export const assetArchive = () => {
return {}; return {};
}); });
};
export const assetLock = () => { export const assetLock = wrapper<'assetLock'>(({ config, data }) => {
return wrapper<'assetLock'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) { if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } }; return { changes: { asset: { visibility: AssetVisibility.Locked } } };
} }
@ -138,15 +123,13 @@ export const assetLock = () => {
return {}; return {};
}); });
};
// export const assetTrash = () => { // export const assetTrash = () => {
// // TODO use trash/untrash host functions // // TODO use trash/untrash host functions
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({})); // return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
// }; // };
export const assetAddToAlbums = () => { export const assetAddToAlbums = wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
const assetId = data.asset.id; const assetId = data.asset.id;
if (config.albumIds.length === 0) { if (config.albumIds.length === 0) {
@ -172,4 +155,3 @@ export const assetAddToAlbums = () => {
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] }); functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
return {}; return {};
}); });
};

View file

@ -13,7 +13,7 @@
"skipLibCheck": true, // Skip type checking of declaration files "skipLibCheck": true, // Skip type checking of declaration files
"strict": true, // Enable all strict type-checking options "strict": true, // Enable all strict type-checking options
"target": "es2020", // Specify ECMAScript target version "target": "es2020", // Specify ECMAScript target version
"types": ["./src/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation "types": ["./dist/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation
}, },
"exclude": [ "exclude": [
"node_modules" // Exclude the node_modules directory "node_modules" // Exclude the node_modules directory

View file

@ -1,11 +1,12 @@
import esbuild from 'esbuild'; import esbuild from 'esbuild';
esbuild.build({ esbuild.build({
entryPoints: ['src/index.ts'], entryPoints: ['src/index.ts', 'src/cli.ts'],
outdir: 'dist', outdir: 'dist',
bundle: true, bundle: true,
sourcemap: false, sourcemap: false,
minify: false, minify: false,
format: 'esm', format: 'esm',
platform: 'node',
target: ['es2020'], target: ['es2020'],
}); });

View file

@ -21,6 +21,9 @@
"files": [ "files": [
"dist" "dist"
], ],
"bin": {
"plugin-sdk": "./plugin-sdk.mjs"
},
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
@ -35,5 +38,8 @@
}, },
"peerDependencies": { "peerDependencies": {
"@extism/js-pdk": "^1.1.1" "@extism/js-pdk": "^1.1.1"
},
"dependencies": {
"commander": "^15.0.0"
} }
} }

View file

@ -0,0 +1,2 @@
#!/usr/bin/env node
import "./dist/cli.js";

View file

@ -0,0 +1,43 @@
import { Command } from 'commander';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { availableFunctions } from 'src/host-functions.js';
const program = new Command('plugin-sdk');
program
.command('prepareBuild')
.description('Generate .d.ts file required for extism')
.argument(
'[manifest]',
"Path to the plugins's manifest file",
'manifest.json',
)
.option('-o --output', 'Output file for generated types', 'dist/index.d.ts')
.action((manifest: string, { output }) => {
const content = readFileSync(manifest, { encoding: 'utf-8' });
const methods = (
JSON.parse(content) as { methods: { name: string }[] }
).methods.map(({ name }) => name);
mkdirSync(dirname(output), { recursive: true });
writeFileSync(
output,
`
declare module 'extism:host' {
interface user {
${availableFunctions.map((functionName) => ` ${functionName}(ptr: PTR): I64;`).join('\n')}
}
}
declare module 'main' {
${methods.map((method) => ` export function ${method}(): I32;`).join('\n')}
}
export type Manifest = ${content};
`,
);
});
program.parse();

View file

@ -6,14 +6,11 @@ import {
type CreateAlbumDto, type CreateAlbumDto,
} from '@immich/sdk'; } from '@immich/sdk';
// keep in sync with plugin-core/src/index.d.ts';
declare module 'extism:host' { declare module 'extism:host' {
interface user { interface user extends Record<
searchAlbums(ptr: PTR): I64; (typeof availableFunctions)[number],
createAlbum(ptr: PTR): I64; (ptr: PTR) => I64
addAssetsToAlbum(ptr: PTR): I64; > {}
addAssetsToAlbums(ptr: PTR): I64;
}
} }
type AlbumsToAssets = { type AlbumsToAssets = {
@ -34,6 +31,13 @@ type HostFunctionResult<T> =
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0]; type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
type AlbumSearchDto = QueryParams<typeof getAllAlbums>; type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
export const availableFunctions = [
'searchAlbums',
'createAlbum',
'addAssetsToAlbum',
'addAssetsToAlbums',
] as const;
export const hostFunctions = (authToken: string) => { export const hostFunctions = (authToken: string) => {
const host = Host.getFunctions(); const host = Host.getFunctions();
type HostFunctionName = keyof typeof host; type HostFunctionName = keyof typeof host;
@ -75,5 +79,5 @@ export const hostFunctions = (authToken: string) => {
), ),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) => addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]), call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
}; } satisfies Record<(typeof availableFunctions)[number], unknown>;
}; };

View file

@ -67,7 +67,8 @@ export const getWrapper =
functions: ReturnType<typeof hostFunctions>; functions: ReturnType<typeof hostFunctions>;
}, },
) => WorkflowResponse<L> | undefined, ) => WorkflowResponse<L> | undefined,
) => { ) =>
() => {
const input = Host.inputString(); const input = Host.inputString();
try { try {

4
pnpm-lock.yaml generated
View file

@ -336,6 +336,10 @@ importers:
version: 6.0.3 version: 6.0.3
packages/plugin-sdk: packages/plugin-sdk:
dependencies:
commander:
specifier: ^15.0.0
version: 15.0.0
devDependencies: devDependencies:
'@extism/js-pdk': '@extism/js-pdk':
specifier: ^1.1.1 specifier: ^1.1.1