Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions api/docs/developer/api-plugins.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 9 additions & 5 deletions api/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -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 });
Expand All @@ -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
Expand All @@ -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();
Expand Down
59 changes: 59 additions & 0 deletions api/scripts/copy-plugins.js
Original file line number Diff line number Diff line change
@@ -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');
2 changes: 1 addition & 1 deletion api/scripts/deploy-dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion api/src/unraid-api/plugin/plugin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 20 additions & 5 deletions api/src/unraid-api/plugin/plugin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -59,19 +73,20 @@ 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;
}

private static async listPlugins(): Promise<[string, string][]> {
/** 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';
});
Expand Down
Loading
Loading