chore(api): enable using workspace plugins in production (#1343)

## Summary by CodeRabbit

- **New Features**
- Introduced an automated step in the post-build process to copy plugin
assets.
- Enhanced the plugin import process by supporting multiple sourcing
options.
  - Adds a demo `health` query via a workspace plugin.

- **Documentation**
- Added a detailed guide explaining API plugin configuration and local
workspace integration.

- **Refactor**
- Improved dependency handling by marking certain workspace plugins as
optional.
- Updated deployment synchronization to ensure destination directories
exactly mirror the source.
- Refined logging levels and type-safety for improved reliability and
debugging.
This commit is contained in:
Pujit Mehrotra
2025-04-10 14:17:39 -04:00
committed by GitHub
parent 97ab6fbe32
commit 8bb9efcb68
8 changed files with 168 additions and 55 deletions

View File

@@ -0,0 +1,31 @@
# Working with API plugins
Under the hood, API plugins (i.e. plugins to the `@unraid/api` project) are represented
as npm `peerDependencies`. This is npm's intended package plugin mechanism, and given that
peer dependencies are installed by default as of npm v7, it supports bi-directional plugin functionality,
where the API provides dependencies for the plugin while the plugin provides functionality to the API.
## Private Workspace plugins
### Adding a local workspace package as an API plugin
The challenge with local workspace plugins is that they aren't available via npm during production.
To solve this, we vendor them inside `dist/plugins`. To prevent the build from breaking, however,
you should mark the workspace dependency as optional. For example:
```json
{
"peerDependencies": {
"unraid-api-plugin-connect": "workspace:*"
},
"peerDependenciesMeta": {
"unraid-api-plugin-connect": {
"optional": true
}
},
}
```
By marking the workspace dependency "optional", npm will not attempt to install it.
Thus, even though the "workspace:*" identifier will be invalid during build-time and run-time,
it will not cause problems.

View File

@@ -20,7 +20,7 @@
"command:raw": "./dist/cli.js",
"// Build and Deploy": "",
"build": "vite build --mode=production",
"postbuild": "chmod +x dist/main.js && chmod +x dist/cli.js",
"postbuild": "chmod +x dist/main.js && chmod +x dist/cli.js && node scripts/copy-plugins.js",
"build:watch": "nodemon --watch src --ext ts,js,json --exec 'tsx ./scripts/build.ts'",
"build:docker": "./scripts/dc.sh run --rm builder",
"build:release": "tsx ./scripts/build.ts",
@@ -136,6 +136,14 @@
"zen-observable-ts": "^1.1.0",
"zod": "^3.23.8"
},
"peerDependencies": {
"unraid-api-plugin-connect": "workspace:*"
},
"peerDependenciesMeta": {
"unraid-api-plugin-connect": {
"optional": true
}
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@graphql-codegen/add": "^5.0.3",

View File

@@ -1,11 +1,17 @@
#!/usr/bin/env zx
import { mkdir, readFile, rm, writeFile } from 'fs/promises';
import { mkdir, readFile, writeFile } from 'fs/promises';
import { exit } from 'process';
import type { PackageJson } from 'type-fest';
import { $, cd } from 'zx';
import { getDeploymentVersion } from './get-deployment-version.js';
type ApiPackageJson = PackageJson & {
version: string;
peerDependencies: Record<string, string>;
};
try {
// Create release and pack directories
await mkdir('./deploy/release', { recursive: true });
@@ -19,13 +25,12 @@ try {
// Get package details
const packageJson = await readFile('./package.json', 'utf-8');
const parsedPackageJson = JSON.parse(packageJson);
const parsedPackageJson = JSON.parse(packageJson) as ApiPackageJson;
const deploymentVersion = await getDeploymentVersion(process.env, parsedPackageJson.version);
// Update the package.json version to the deployment version
parsedPackageJson.version = deploymentVersion;
// omit dev dependencies from release build
// omit dev dependencies from vendored dependencies in release build
parsedPackageJson.devDependencies = {};
// Create a temporary directory for packaging
@@ -42,7 +47,6 @@ try {
$.verbose = true;
await $`npm install --omit=dev`;
// Now write the package.json back to the pack directory
await writeFile('package.json', JSON.stringify(parsedPackageJson, null, 4));
const sudoCheck = await $`command -v sudo`.nothrow();

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env node
/**
* This AI-generated script copies workspace plugin dist folders to the dist/plugins directory
* to ensure they're available for dynamic imports in production.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Get the package.json to find workspace dependencies
const packageJsonPath = path.resolve(__dirname, '../package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Create the plugins directory if it doesn't exist
const pluginsDir = path.resolve(__dirname, '../dist/plugins');
if (!fs.existsSync(pluginsDir)) {
fs.mkdirSync(pluginsDir, { recursive: true });
}
// Find all workspace plugins
const pluginPrefix = 'unraid-api-plugin-';
const workspacePlugins = Object.keys(packageJson.peerDependencies || {}).filter((pkgName) =>
pkgName.startsWith(pluginPrefix)
);
// Copy each plugin's dist folder to the plugins directory
for (const pkgName of workspacePlugins) {
const pluginPath = path.resolve(__dirname, `../../packages/${pkgName}`);
const pluginDistPath = path.resolve(pluginPath, 'dist');
const targetPath = path.resolve(pluginsDir, pkgName);
console.log(`Building ${pkgName}...`);
try {
execSync('pnpm build', {
cwd: pluginPath,
stdio: 'inherit',
});
console.log(`Successfully built ${pkgName}`);
} catch (error) {
console.error(`Failed to build ${pkgName}:`, error.message);
process.exit(1);
}
if (!fs.existsSync(pluginDistPath)) {
console.warn(`Plugin ${pkgName} dist folder not found at ${pluginDistPath}`);
process.exit(1);
}
console.log(`Copying ${pkgName} dist folder to ${targetPath}`);
fs.mkdirSync(targetPath, { recursive: true });
fs.cpSync(pluginDistPath, targetPath, { recursive: true });
console.log(`Successfully copied ${pkgName} dist folder`);
}
console.log('Plugin dist folders copied successfully');

View File

@@ -29,7 +29,7 @@ fi
destination_directory="/usr/local/unraid-api"
# Replace the value inside the rsync command with the user's input
rsync_command="rsync -avz --progress --stats -e ssh \"$source_directory\" \"root@${server_name}:$destination_directory\""
rsync_command="rsync -avz --delete --progress --stats -e ssh \"$source_directory\" \"root@${server_name}:$destination_directory\""
echo "Executing the following command:"
echo "$rsync_command"

View File

@@ -37,7 +37,7 @@ export class PluginCliModule {
.map((plugin) => plugin.CliModule!);
const cliList = cliModules.map((plugin) => plugin.name).join(', ');
PluginCliModule.logger.log(`Found ${cliModules.length} CLI plugins: ${cliList}`);
PluginCliModule.logger.debug(`Found ${cliModules.length} CLI plugins: ${cliList}`);
return {
module: PluginCliModule,

View File

@@ -48,7 +48,21 @@ export class PluginService {
const pluginPackages = await PluginService.listPlugins();
const plugins = await batchProcess(pluginPackages, async ([pkgName]) => {
try {
const plugin = await import(/* @vite-ignore */ pkgName);
const possibleImportSources = [
pkgName,
/**----------------------------------------------
* Importing private workspace plugins
*
* Private workspace packages are not available in production,
* so we bundle and copy them to a plugins folder instead.
*
* See scripts/copy-plugins.js for more details.
*---------------------------------------------**/
`../plugins/${pkgName}/index.js`,
];
const plugin = await Promise.any(
possibleImportSources.map((source) => import(/* @vite-ignore */ source))
);
return apiNestPluginSchema.parse(plugin);
} catch (error) {
PluginService.logger.error(`Plugin from ${pkgName} is invalid`, error);
@@ -59,6 +73,7 @@ export class PluginService {
if (plugins.errorOccured) {
PluginService.logger.warn(`Failed to load ${plugins.errors.length} plugins. Ignoring them.`);
}
PluginService.logger.log(`Loaded ${plugins.data.length} plugins.`);
return plugins.data;
}
@@ -66,12 +81,12 @@ export class PluginService {
/** All api plugins must be npm packages whose name starts with this prefix */
const pluginPrefix = 'unraid-api-plugin-';
// All api plugins must be installed as dependencies of the unraid-api package
const { dependencies } = getPackageJson();
if (!dependencies) {
PluginService.logger.warn('Unraid-API dependencies not found; skipping plugins.');
const { peerDependencies } = getPackageJson();
if (!peerDependencies) {
PluginService.logger.warn('Unraid-API peer dependencies not found; skipping plugins.');
return [];
}
const plugins = Object.entries(dependencies).filter((entry): entry is [string, string] => {
const plugins = Object.entries(peerDependencies).filter((entry): entry is [string, string] => {
const [pkgName, version] = entry;
return pkgName.startsWith(pluginPrefix) && typeof version === 'string';
});

80
pnpm-lock.yaml generated
View File

@@ -269,6 +269,9 @@ importers:
systeminformation:
specifier: ^5.25.11
version: 5.25.11
unraid-api-plugin-connect:
specifier: workspace:*
version: link:../packages/unraid-api-plugin-connect
uuid:
specifier: ^11.0.2
version: 11.1.0
@@ -12060,7 +12063,7 @@ snapshots:
'@babel/traverse': 7.26.10
'@babel/types': 7.26.10
convert-source-map: 2.0.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -12431,7 +12434,7 @@ snapshots:
'@babel/parser': 7.27.0
'@babel/template': 7.26.9
'@babel/types': 7.27.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -12443,7 +12446,7 @@ snapshots:
'@babel/parser': 7.26.8
'@babel/template': 7.26.8
'@babel/types': 7.26.8
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -12761,7 +12764,7 @@ snapshots:
'@eslint/config-array@0.19.2':
dependencies:
'@eslint/object-schema': 2.1.6
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -12798,7 +12801,7 @@ snapshots:
'@eslint/eslintrc@3.3.1':
dependencies:
ajv: 6.12.6
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
espree: 10.3.0
globals: 14.0.0
ignore: 5.3.2
@@ -13397,12 +13400,12 @@ snapshots:
'@types/js-yaml': 4.0.9
'@whatwg-node/fetch': 0.10.3
chalk: 4.1.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
dotenv: 16.4.7
graphql: 16.10.0
graphql-request: 6.1.0(graphql@16.10.0)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@9.4.0)
jose: 5.10.0
js-yaml: 4.1.0
lodash: 4.17.21
@@ -13712,7 +13715,7 @@ snapshots:
dependencies:
consola: 3.4.2
detect-libc: 2.0.3
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@9.4.0)
node-fetch: 2.7.0
nopt: 8.1.0
semver: 7.7.1
@@ -14502,7 +14505,7 @@ snapshots:
'@pm2/pm2-version-check@1.0.4':
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
@@ -15403,7 +15406,7 @@ snapshots:
'@typescript-eslint/types': 8.28.0
'@typescript-eslint/typescript-estree': 8.28.0(typescript@5.8.2)
'@typescript-eslint/visitor-keys': 8.28.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
eslint: 9.23.0(jiti@2.4.2)
typescript: 5.8.2
transitivePeerDependencies:
@@ -15423,7 +15426,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 8.28.0(typescript@5.8.2)
'@typescript-eslint/utils': 8.28.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2)
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
eslint: 9.23.0(jiti@2.4.2)
ts-api-utils: 2.1.0(typescript@5.8.2)
typescript: 5.8.2
@@ -15451,7 +15454,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 8.28.0
'@typescript-eslint/visitor-keys': 8.28.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@@ -15623,7 +15626,7 @@ snapshots:
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
@@ -15759,7 +15762,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.12
tinyrainbow: 2.0.0
vitest: 3.0.9(@types/node@22.13.13)(@vitest/ui@3.0.9)(happy-dom@17.4.4)(jiti@2.4.2)(jsdom@26.0.0)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
vitest: 3.0.9(@types/node@22.14.0)(@vitest/ui@3.0.9)(happy-dom@17.4.4)(jiti@2.4.2)(jsdom@26.0.0)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
'@vitest/utils@2.0.5':
dependencies:
@@ -17722,7 +17725,7 @@ snapshots:
docker-modem@5.0.6:
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
readable-stream: 3.6.2
split-ca: 1.0.1
ssh2: 1.16.0
@@ -18378,7 +18381,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
escape-string-regexp: 4.0.0
eslint-scope: 8.3.0
eslint-visitor-keys: 4.2.0
@@ -18922,7 +18925,7 @@ snapshots:
dependencies:
basic-ftp: 5.0.5
data-uri-to-buffer: 6.0.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
@@ -19389,7 +19392,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.3
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
@@ -19433,13 +19436,6 @@ snapshots:
quick-lru: 5.1.1
resolve-alpn: 1.2.1
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.3
debug: 4.4.0(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6(supports-color@9.4.0):
dependencies:
agent-base: 7.1.3
@@ -19871,7 +19867,7 @@ snapshots:
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.25
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
@@ -19958,7 +19954,7 @@ snapshots:
form-data: 4.0.2
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@9.4.0)
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.16
parse5: 7.2.1
@@ -21212,10 +21208,10 @@ snapshots:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.3
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
get-uri: 6.0.4
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@9.4.0)
pac-resolver: 7.0.1
socks-proxy-agent: 8.0.5
transitivePeerDependencies:
@@ -21479,7 +21475,7 @@ snapshots:
pm2-axon-rpc@0.7.1:
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
transitivePeerDependencies:
- supports-color
@@ -21487,7 +21483,7 @@ snapshots:
dependencies:
amp: 0.3.1
amp-message: 0.1.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
escape-string-regexp: 4.0.0
transitivePeerDependencies:
- supports-color
@@ -21504,7 +21500,7 @@ snapshots:
pm2-sysmonit@1.2.8:
dependencies:
async: 3.2.6
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
pidusage: 2.0.21
systeminformation: 5.25.11
tx2: 1.0.5
@@ -21526,7 +21522,7 @@ snapshots:
commander: 2.15.1
croner: 4.1.97
dayjs: 1.11.13
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
enquirer: 2.3.6
eventemitter2: 5.0.1
fclone: 1.0.11
@@ -21880,9 +21876,9 @@ snapshots:
proxy-agent@6.4.0:
dependencies:
agent-base: 7.1.3
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
https-proxy-agent: 7.0.6(supports-color@9.4.0)
lru-cache: 7.18.3
pac-proxy-agent: 7.1.0
proxy-from-env: 1.1.0
@@ -22269,7 +22265,7 @@ snapshots:
require-in-the-middle@5.2.0:
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
module-details-from-path: 1.0.3
resolve: 1.22.10
transitivePeerDependencies:
@@ -22736,7 +22732,7 @@ snapshots:
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.3
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
socks: 2.8.4
transitivePeerDependencies:
- supports-color
@@ -22992,7 +22988,7 @@ snapshots:
stylus@0.57.0:
dependencies:
css: 3.0.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
glob: 7.2.3
safer-buffer: 2.1.2
sax: 1.2.4
@@ -23725,7 +23721,7 @@ snapshots:
vite-node@3.0.9(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0):
dependencies:
cac: 6.7.14
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
es-module-lexer: 1.6.0
pathe: 2.0.3
vite: 6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
@@ -23858,7 +23854,7 @@ snapshots:
dependencies:
'@rollup/pluginutils': 4.2.1
chalk: 4.1.2
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
vite: 6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)
optionalDependencies:
'@swc/core': 1.11.13(@swc/helpers@0.5.15)
@@ -23921,7 +23917,7 @@ snapshots:
vite-tsconfig-paths@5.1.4(typescript@5.8.2)(vite@6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)):
dependencies:
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
globrex: 0.1.2
tsconfck: 3.1.5(typescript@5.8.2)
optionalDependencies:
@@ -23996,7 +23992,7 @@ snapshots:
'@vitest/spy': 3.0.9
'@vitest/utils': 3.0.9
chai: 5.2.0
debug: 4.4.0(supports-color@5.5.0)
debug: 4.4.0(supports-color@9.4.0)
expect-type: 1.2.0
magic-string: 0.30.17
pathe: 2.0.3