Skip to content

Commit 6bef2f1

Browse files
pujitmmdatelle
authored andcommitted
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.
1 parent 3391375 commit 6bef2f1

File tree

8 files changed

+168
-55
lines changed

8 files changed

+168
-55
lines changed

api/docs/developer/api-plugins.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Working with API plugins
2+
3+
Under the hood, API plugins (i.e. plugins to the `@unraid/api` project) are represented
4+
as npm `peerDependencies`. This is npm's intended package plugin mechanism, and given that
5+
peer dependencies are installed by default as of npm v7, it supports bi-directional plugin functionality,
6+
where the API provides dependencies for the plugin while the plugin provides functionality to the API.
7+
8+
## Private Workspace plugins
9+
10+
### Adding a local workspace package as an API plugin
11+
12+
The challenge with local workspace plugins is that they aren't available via npm during production.
13+
To solve this, we vendor them inside `dist/plugins`. To prevent the build from breaking, however,
14+
you should mark the workspace dependency as optional. For example:
15+
16+
```json
17+
{
18+
"peerDependencies": {
19+
"unraid-api-plugin-connect": "workspace:*"
20+
},
21+
"peerDependenciesMeta": {
22+
"unraid-api-plugin-connect": {
23+
"optional": true
24+
}
25+
},
26+
}
27+
```
28+
29+
By marking the workspace dependency "optional", npm will not attempt to install it.
30+
Thus, even though the "workspace:*" identifier will be invalid during build-time and run-time,
31+
it will not cause problems.

api/package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"command:raw": "./dist/cli.js",
2121
"// Build and Deploy": "",
2222
"build": "vite build --mode=production",
23-
"postbuild": "chmod +x dist/main.js && chmod +x dist/cli.js",
23+
"postbuild": "chmod +x dist/main.js && chmod +x dist/cli.js && node scripts/copy-plugins.js",
2424
"build:watch": "nodemon --watch src --ext ts,js,json --exec 'tsx ./scripts/build.ts'",
2525
"build:docker": "./scripts/dc.sh run --rm builder",
2626
"build:release": "tsx ./scripts/build.ts",
@@ -136,6 +136,14 @@
136136
"zen-observable-ts": "^1.1.0",
137137
"zod": "^3.23.8"
138138
},
139+
"peerDependencies": {
140+
"unraid-api-plugin-connect": "workspace:*"
141+
},
142+
"peerDependenciesMeta": {
143+
"unraid-api-plugin-connect": {
144+
"optional": true
145+
}
146+
},
139147
"devDependencies": {
140148
"@eslint/js": "^9.21.0",
141149
"@graphql-codegen/add": "^5.0.3",

api/scripts/build.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
#!/usr/bin/env zx
2-
import { mkdir, readFile, rm, writeFile } from 'fs/promises';
2+
import { mkdir, readFile, writeFile } from 'fs/promises';
33
import { exit } from 'process';
44

5+
import type { PackageJson } from 'type-fest';
56
import { $, cd } from 'zx';
67

78
import { getDeploymentVersion } from './get-deployment-version.js';
89

10+
type ApiPackageJson = PackageJson & {
11+
version: string;
12+
peerDependencies: Record<string, string>;
13+
};
14+
915
try {
1016
// Create release and pack directories
1117
await mkdir('./deploy/release', { recursive: true });
@@ -19,13 +25,12 @@ try {
1925

2026
// Get package details
2127
const packageJson = await readFile('./package.json', 'utf-8');
22-
const parsedPackageJson = JSON.parse(packageJson);
23-
28+
const parsedPackageJson = JSON.parse(packageJson) as ApiPackageJson;
2429
const deploymentVersion = await getDeploymentVersion(process.env, parsedPackageJson.version);
2530

2631
// Update the package.json version to the deployment version
2732
parsedPackageJson.version = deploymentVersion;
28-
// omit dev dependencies from release build
33+
// omit dev dependencies from vendored dependencies in release build
2934
parsedPackageJson.devDependencies = {};
3035

3136
// Create a temporary directory for packaging
@@ -42,7 +47,6 @@ try {
4247
$.verbose = true;
4348
await $`npm install --omit=dev`;
4449

45-
// Now write the package.json back to the pack directory
4650
await writeFile('package.json', JSON.stringify(parsedPackageJson, null, 4));
4751

4852
const sudoCheck = await $`command -v sudo`.nothrow();

api/scripts/copy-plugins.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* This AI-generated script copies workspace plugin dist folders to the dist/plugins directory
5+
* to ensure they're available for dynamic imports in production.
6+
*/
7+
import { execSync } from 'child_process';
8+
import fs from 'fs';
9+
import path from 'path';
10+
import { fileURLToPath } from 'url';
11+
12+
const __filename = fileURLToPath(import.meta.url);
13+
const __dirname = path.dirname(__filename);
14+
15+
// Get the package.json to find workspace dependencies
16+
const packageJsonPath = path.resolve(__dirname, '../package.json');
17+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
18+
19+
// Create the plugins directory if it doesn't exist
20+
const pluginsDir = path.resolve(__dirname, '../dist/plugins');
21+
if (!fs.existsSync(pluginsDir)) {
22+
fs.mkdirSync(pluginsDir, { recursive: true });
23+
}
24+
25+
// Find all workspace plugins
26+
const pluginPrefix = 'unraid-api-plugin-';
27+
const workspacePlugins = Object.keys(packageJson.peerDependencies || {}).filter((pkgName) =>
28+
pkgName.startsWith(pluginPrefix)
29+
);
30+
31+
// Copy each plugin's dist folder to the plugins directory
32+
for (const pkgName of workspacePlugins) {
33+
const pluginPath = path.resolve(__dirname, `../../packages/${pkgName}`);
34+
const pluginDistPath = path.resolve(pluginPath, 'dist');
35+
const targetPath = path.resolve(pluginsDir, pkgName);
36+
37+
console.log(`Building ${pkgName}...`);
38+
try {
39+
execSync('pnpm build', {
40+
cwd: pluginPath,
41+
stdio: 'inherit',
42+
});
43+
console.log(`Successfully built ${pkgName}`);
44+
} catch (error) {
45+
console.error(`Failed to build ${pkgName}:`, error.message);
46+
process.exit(1);
47+
}
48+
49+
if (!fs.existsSync(pluginDistPath)) {
50+
console.warn(`Plugin ${pkgName} dist folder not found at ${pluginDistPath}`);
51+
process.exit(1);
52+
}
53+
console.log(`Copying ${pkgName} dist folder to ${targetPath}`);
54+
fs.mkdirSync(targetPath, { recursive: true });
55+
fs.cpSync(pluginDistPath, targetPath, { recursive: true });
56+
console.log(`Successfully copied ${pkgName} dist folder`);
57+
}
58+
59+
console.log('Plugin dist folders copied successfully');

api/scripts/deploy-dev.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ fi
2929
destination_directory="/usr/local/unraid-api"
3030

3131
# Replace the value inside the rsync command with the user's input
32-
rsync_command="rsync -avz --progress --stats -e ssh \"$source_directory\" \"root@${server_name}:$destination_directory\""
32+
rsync_command="rsync -avz --delete --progress --stats -e ssh \"$source_directory\" \"root@${server_name}:$destination_directory\""
3333

3434
echo "Executing the following command:"
3535
echo "$rsync_command"

api/src/unraid-api/plugin/plugin.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export class PluginCliModule {
3737
.map((plugin) => plugin.CliModule!);
3838

3939
const cliList = cliModules.map((plugin) => plugin.name).join(', ');
40-
PluginCliModule.logger.log(`Found ${cliModules.length} CLI plugins: ${cliList}`);
40+
PluginCliModule.logger.debug(`Found ${cliModules.length} CLI plugins: ${cliList}`);
4141

4242
return {
4343
module: PluginCliModule,

api/src/unraid-api/plugin/plugin.service.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,21 @@ export class PluginService {
4848
const pluginPackages = await PluginService.listPlugins();
4949
const plugins = await batchProcess(pluginPackages, async ([pkgName]) => {
5050
try {
51-
const plugin = await import(/* @vite-ignore */ pkgName);
51+
const possibleImportSources = [
52+
pkgName,
53+
/**----------------------------------------------
54+
* Importing private workspace plugins
55+
*
56+
* Private workspace packages are not available in production,
57+
* so we bundle and copy them to a plugins folder instead.
58+
*
59+
* See scripts/copy-plugins.js for more details.
60+
*---------------------------------------------**/
61+
`../plugins/${pkgName}/index.js`,
62+
];
63+
const plugin = await Promise.any(
64+
possibleImportSources.map((source) => import(/* @vite-ignore */ source))
65+
);
5266
return apiNestPluginSchema.parse(plugin);
5367
} catch (error) {
5468
PluginService.logger.error(`Plugin from ${pkgName} is invalid`, error);
@@ -59,19 +73,20 @@ export class PluginService {
5973
if (plugins.errorOccured) {
6074
PluginService.logger.warn(`Failed to load ${plugins.errors.length} plugins. Ignoring them.`);
6175
}
76+
PluginService.logger.log(`Loaded ${plugins.data.length} plugins.`);
6277
return plugins.data;
6378
}
6479

6580
private static async listPlugins(): Promise<[string, string][]> {
6681
/** All api plugins must be npm packages whose name starts with this prefix */
6782
const pluginPrefix = 'unraid-api-plugin-';
6883
// All api plugins must be installed as dependencies of the unraid-api package
69-
const { dependencies } = getPackageJson();
70-
if (!dependencies) {
71-
PluginService.logger.warn('Unraid-API dependencies not found; skipping plugins.');
84+
const { peerDependencies } = getPackageJson();
85+
if (!peerDependencies) {
86+
PluginService.logger.warn('Unraid-API peer dependencies not found; skipping plugins.');
7287
return [];
7388
}
74-
const plugins = Object.entries(dependencies).filter((entry): entry is [string, string] => {
89+
const plugins = Object.entries(peerDependencies).filter((entry): entry is [string, string] => {
7590
const [pkgName, version] = entry;
7691
return pkgName.startsWith(pluginPrefix) && typeof version === 'string';
7792
});

0 commit comments

Comments
 (0)