374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { createRequire } from "node:module";
|
|
import { pathToFileURL } from "node:url";
|
|
import type {
|
|
PackagePluginManifest,
|
|
PluginInput,
|
|
WrnexusPackageManifest,
|
|
WrnexusPlugin,
|
|
ClientRuntimeDefinition,
|
|
PackageAssetDefinition,
|
|
PackageStyleDefinition,
|
|
PackageRouteDefinition,
|
|
PackageMigrationDefinition,
|
|
PluginPermission,
|
|
} from "./types.ts";
|
|
import { definePlugin, flattenPlugins } from "./base.ts";
|
|
import { testPluginCompatibility } from "./compatibility.ts";
|
|
|
|
interface PackageJson {
|
|
name?: string;
|
|
version?: string;
|
|
dependencies?: Record<string, string>;
|
|
optionalDependencies?: Record<string, string>;
|
|
devDependencies?: Record<string, string>;
|
|
peerDependencies?: Record<string, string>;
|
|
workspaces?: string[] | { packages?: string[] };
|
|
wrnexus?: WrnexusPackageManifest;
|
|
}
|
|
|
|
export interface DiscoverPluginOptions {
|
|
includeDevDependencies?: boolean;
|
|
/** Fail package discovery immediately instead of degrading to a warning. */
|
|
strict?: boolean;
|
|
includePeerDependencies?: boolean;
|
|
warn?: (message: string) => void;
|
|
runtime?: string;
|
|
capabilities?: readonly string[];
|
|
runtimeVersion?: string;
|
|
os?: string;
|
|
/** When true, every requested capability must be declared and granted by the application. */
|
|
enforcePermissions?: boolean;
|
|
grantedPermissions?: Readonly<Record<string, readonly PluginPermission[]>>;
|
|
}
|
|
|
|
function manifestPermissions(manifest: WrnexusPackageManifest): PluginPermission[] {
|
|
const permissions: PluginPermission[] = [];
|
|
if (manifest.components?.length) permissions.push("components");
|
|
if (manifest.clientRuntimes?.length) permissions.push("client-runtime");
|
|
if (manifest.assets?.length) permissions.push("assets");
|
|
if (manifest.styles?.length) permissions.push("styles");
|
|
if (manifest.routes?.length) permissions.push("routes");
|
|
if (manifest.middleware?.length) permissions.push("middleware");
|
|
if (manifest.migrations?.length) permissions.push("migrations");
|
|
return permissions;
|
|
}
|
|
|
|
function modulePermissions(plugin: WrnexusPlugin): PluginPermission[] {
|
|
const permissions: PluginPermission[] = [];
|
|
if (plugin.componentDirs) permissions.push("components");
|
|
if (plugin.clientRuntimes) permissions.push("client-runtime");
|
|
if (plugin.assets) permissions.push("assets");
|
|
if (plugin.styleSources) permissions.push("styles");
|
|
if (plugin.routeEntries || plugin.routes) permissions.push("routes");
|
|
if (plugin.middleware) permissions.push("middleware");
|
|
if (plugin.migrations) permissions.push("migrations");
|
|
if (plugin.configure || plugin.configResolved) permissions.push("config");
|
|
if (plugin.transformAst || plugin.transformCode) permissions.push("transform");
|
|
if (plugin.diagnostics || plugin.devToolbarPanels) permissions.push("diagnostics");
|
|
if (plugin.configureServer || plugin.buildStart || plugin.buildEnd) permissions.push("server");
|
|
if (plugin.cliCommands) permissions.push("cli");
|
|
if (plugin.directives) permissions.push("directives");
|
|
if (plugin.virtualModules) permissions.push("virtual-modules");
|
|
if (plugin.deploymentAdapters || plugin.deploy) permissions.push("deployment");
|
|
if (plugin.documentation) permissions.push("documentation");
|
|
if (plugin.typeDefinitions) permissions.push("types");
|
|
return [...new Set(permissions)];
|
|
}
|
|
|
|
function enforcePermissions(
|
|
packageName: string,
|
|
manifest: WrnexusPackageManifest,
|
|
required: readonly PluginPermission[],
|
|
options: DiscoverPluginOptions,
|
|
): void {
|
|
if (!options.enforcePermissions) return;
|
|
const declared = new Set(manifest.permissions ?? []);
|
|
const undeclared = required.filter((permission) => !declared.has(permission));
|
|
if (undeclared.length) {
|
|
throw new Error(
|
|
`WRN-PLUGIN-PERMISSION-UNDECLARED: ${packageName} uses ${undeclared.join(", ")} without declaring them.`,
|
|
);
|
|
}
|
|
const grants = new Set(options.grantedPermissions?.[packageName] ?? []);
|
|
const denied = required.filter((permission) => !grants.has(permission));
|
|
if (denied.length) {
|
|
throw new Error(
|
|
`WRN-PLUGIN-PERMISSION-DENIED: ${packageName} is not granted ${denied.join(", ")}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function readJson(path: string): PackageJson | null {
|
|
try {
|
|
return JSON.parse(readFileSync(path, "utf8")) as PackageJson;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function nearestWorkspaceRoot(start: string): string | null {
|
|
let current = resolve(start);
|
|
while (true) {
|
|
const packageJson = readJson(join(current, "package.json"));
|
|
if (packageJson?.workspaces) return current;
|
|
const parent = dirname(current);
|
|
if (parent === current) return null;
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
function workspacePatterns(pkg: PackageJson): string[] {
|
|
if (Array.isArray(pkg.workspaces)) return pkg.workspaces;
|
|
return pkg.workspaces?.packages ?? [];
|
|
}
|
|
|
|
function expandSimpleWorkspacePattern(root: string, pattern: string): string[] {
|
|
const normalized = pattern.replace(/\\/g, "/").replace(/\/$/, "");
|
|
if (!normalized.includes("*")) return [join(root, normalized)];
|
|
const star = normalized.indexOf("*");
|
|
const before = normalized.slice(0, star).replace(/\/$/, "");
|
|
const after = normalized.slice(star + 1).replace(/^\//, "");
|
|
const base = join(root, before);
|
|
if (!existsSync(base)) return [];
|
|
return readdirSync(base)
|
|
.map((name) => join(base, name, after))
|
|
.filter((path) => existsSync(path) && statSync(path).isDirectory());
|
|
}
|
|
|
|
function workspacePackageRoots(root: string): Map<string, string> {
|
|
const result = new Map<string, string>();
|
|
const rootPackage = readJson(join(root, "package.json"));
|
|
if (!rootPackage) return result;
|
|
for (const pattern of workspacePatterns(rootPackage)) {
|
|
for (const candidate of expandSimpleWorkspacePattern(root, pattern)) {
|
|
const pkg = readJson(join(candidate, "package.json"));
|
|
if (pkg?.name) result.set(pkg.name, candidate);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function resolvePackageRoot(
|
|
appRoot: string,
|
|
name: string,
|
|
workspaces: Map<string, string>,
|
|
): string | null {
|
|
const workspace = workspaces.get(name);
|
|
if (workspace) return workspace;
|
|
const require = createRequire(join(appRoot, "package.json"));
|
|
try {
|
|
return dirname(require.resolve(`${name}/package.json`));
|
|
} catch {
|
|
try {
|
|
const entry = require.resolve(name);
|
|
let current = dirname(entry);
|
|
while (true) {
|
|
const pkg = readJson(join(current, "package.json"));
|
|
if (pkg?.name === name) return current;
|
|
const parent = dirname(current);
|
|
if (parent === current) return null;
|
|
current = parent;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveRuntime(
|
|
packageRoot: string,
|
|
value: ClientRuntimeDefinition,
|
|
): ClientRuntimeDefinition {
|
|
return {
|
|
...value,
|
|
entry: value.entry ? resolve(packageRoot, value.entry) : undefined,
|
|
};
|
|
}
|
|
|
|
function resolveAsset(packageRoot: string, value: PackageAssetDefinition): PackageAssetDefinition {
|
|
return {
|
|
...value,
|
|
entry: value.entry ? resolve(packageRoot, value.entry) : undefined,
|
|
};
|
|
}
|
|
|
|
function resolveStyle(packageRoot: string, value: PackageStyleDefinition): PackageStyleDefinition {
|
|
return {
|
|
...value,
|
|
entry: value.entry ? resolve(packageRoot, value.entry) : undefined,
|
|
source: value.source ? resolve(packageRoot, value.source) : undefined,
|
|
};
|
|
}
|
|
|
|
function resolveRoute(packageRoot: string, value: PackageRouteDefinition): PackageRouteDefinition {
|
|
return { ...value, entry: resolve(packageRoot, value.entry) };
|
|
}
|
|
|
|
function resolveMigration(
|
|
packageRoot: string,
|
|
value: PackageMigrationDefinition,
|
|
): PackageMigrationDefinition {
|
|
return { ...value, entry: value.entry ? resolve(packageRoot, value.entry) : undefined };
|
|
}
|
|
|
|
function manifestContributionPlugin(
|
|
packageRoot: string,
|
|
packageName: string,
|
|
packageVersion: string | undefined,
|
|
manifest: WrnexusPackageManifest,
|
|
): WrnexusPlugin | null {
|
|
const components = (manifest.components ?? []).map((value) => resolve(packageRoot, value));
|
|
const runtimes = (manifest.clientRuntimes ?? []).map((value) =>
|
|
resolveRuntime(packageRoot, value),
|
|
);
|
|
const assets = (manifest.assets ?? []).map((value) => resolveAsset(packageRoot, value));
|
|
const styles = (manifest.styles ?? []).map((value) => resolveStyle(packageRoot, value));
|
|
const routes = (manifest.routes ?? []).map((value) => resolveRoute(packageRoot, value));
|
|
const middleware = (manifest.middleware ?? []).map((value) => resolve(packageRoot, value));
|
|
const migrations = (manifest.migrations ?? []).map((value) =>
|
|
resolveMigration(packageRoot, value),
|
|
);
|
|
if (
|
|
!components.length &&
|
|
!runtimes.length &&
|
|
!assets.length &&
|
|
!styles.length &&
|
|
!routes.length &&
|
|
!middleware.length &&
|
|
!migrations.length
|
|
)
|
|
return null;
|
|
return definePlugin({
|
|
name: `${packageName}/manifest`,
|
|
version: manifest.version ?? packageVersion,
|
|
componentDirs: components,
|
|
clientRuntimes: runtimes,
|
|
assets,
|
|
styleSources: styles,
|
|
routeEntries: routes,
|
|
middleware,
|
|
migrations,
|
|
});
|
|
}
|
|
|
|
function pluginManifest(value: string | PackagePluginManifest): PackagePluginManifest {
|
|
return typeof value === "string" ? { plugin: value } : value;
|
|
}
|
|
|
|
async function loadPlugin(
|
|
packageRoot: string,
|
|
manifest: PackagePluginManifest,
|
|
): Promise<WrnexusPlugin> {
|
|
const modulePath = resolve(packageRoot, manifest.plugin);
|
|
const mod = (await import(pathToFileURL(modulePath).href)) as Record<string, unknown>;
|
|
const exportName = manifest.export;
|
|
let candidate = exportName ? mod[exportName] : (mod.default ?? mod.plugin);
|
|
if (candidate === undefined) {
|
|
throw new Error(`WRN-PLUGIN-DISCOVERY-EXPORT: ${modulePath} has no plugin export.`);
|
|
}
|
|
const shouldInvoke =
|
|
manifest.factory === true || (manifest.factory !== false && typeof candidate === "function");
|
|
if (shouldInvoke) candidate = await (candidate as () => unknown)();
|
|
return definePlugin(candidate as WrnexusPlugin);
|
|
}
|
|
|
|
function dependencyNames(pkg: PackageJson, options: DiscoverPluginOptions): string[] {
|
|
const all = {
|
|
...(pkg.dependencies ?? {}),
|
|
...(pkg.optionalDependencies ?? {}),
|
|
...(options.includeDevDependencies === false ? {} : (pkg.devDependencies ?? {})),
|
|
...(options.includePeerDependencies ? (pkg.peerDependencies ?? {}) : {}),
|
|
};
|
|
return Object.keys(all).sort();
|
|
}
|
|
|
|
/**
|
|
* Discover package plugins declared through package.json `wrnexus.plugin`.
|
|
* Explicit plugins win; automatic duplicates are skipped instead of failing.
|
|
*/
|
|
export async function discoverPlugins(
|
|
appRoot: string,
|
|
explicit: PluginInput,
|
|
options: DiscoverPluginOptions = {},
|
|
): Promise<PluginInput> {
|
|
const appPackage = readJson(join(appRoot, "package.json"));
|
|
if (!appPackage) return explicit;
|
|
|
|
const explicitPlugins = flattenPlugins(explicit);
|
|
const explicitNames = new Set(explicitPlugins.map((plugin) => plugin.name));
|
|
const workspaceRoot = nearestWorkspaceRoot(appRoot);
|
|
const workspaces = workspaceRoot
|
|
? workspacePackageRoots(workspaceRoot)
|
|
: new Map<string, string>();
|
|
const automatic: WrnexusPlugin[] = [];
|
|
|
|
for (const name of dependencyNames(appPackage, options)) {
|
|
const packageRoot = resolvePackageRoot(appRoot, name, workspaces);
|
|
if (!packageRoot) continue;
|
|
const packageJson = readJson(join(packageRoot, "package.json"));
|
|
const packageManifest = packageJson?.wrnexus;
|
|
if (!packageManifest) continue;
|
|
enforcePermissions(name, packageManifest, manifestPermissions(packageManifest), options);
|
|
if (
|
|
options.runtime &&
|
|
packageManifest.runtimes?.length &&
|
|
!packageManifest.runtimes.includes(options.runtime as never)
|
|
) {
|
|
throw new Error(`WRN-PLUGIN-RUNTIME: ${name} does not support ${options.runtime}.`);
|
|
}
|
|
const missingCapabilities = (packageManifest.requires ?? []).filter(
|
|
(capability) => !options.capabilities?.includes(capability),
|
|
);
|
|
if (options.runtime && missingCapabilities.length) {
|
|
throw new Error(
|
|
`WRN-PLUGIN-CAPABILITY: ${name} requires unavailable capabilities: ${missingCapabilities.join(", ")}.`,
|
|
);
|
|
}
|
|
if (options.runtime) {
|
|
const matrix = testPluginCompatibility(packageManifest, [
|
|
{
|
|
runtime: options.runtime as
|
|
"bun" | "node" | "edge" | "worker" | "service-worker" | "browser",
|
|
version:
|
|
options.runtimeVersion ??
|
|
(options.runtime === "bun" && typeof Bun !== "undefined" ? Bun.version : undefined),
|
|
os: options.os ?? process.platform,
|
|
capabilities: options.capabilities,
|
|
},
|
|
])[0]!;
|
|
const compatibilityIssue = matrix.issues.find((issue) =>
|
|
["WRN-PLUGIN-MATRIX-VERSION", "WRN-PLUGIN-MATRIX-OS"].includes(issue.code),
|
|
);
|
|
if (compatibilityIssue)
|
|
throw new Error(`${compatibilityIssue.code}: ${name}: ${compatibilityIssue.message}`);
|
|
}
|
|
const manifestPlugin = manifestContributionPlugin(
|
|
packageRoot,
|
|
packageJson?.name ?? name,
|
|
packageJson?.version,
|
|
packageManifest,
|
|
);
|
|
if (manifestPlugin && !explicitNames.has(manifestPlugin.name)) automatic.push(manifestPlugin);
|
|
|
|
const declared = packageManifest.plugin;
|
|
if (!declared) continue;
|
|
const manifest = pluginManifest(declared);
|
|
if (manifest.autoDiscover === false) continue;
|
|
try {
|
|
const plugin = await loadPlugin(packageRoot, manifest);
|
|
enforcePermissions(name, packageManifest, modulePermissions(plugin), options);
|
|
if (!explicitNames.has(plugin.name) && !automatic.some((item) => item.name === plugin.name)) {
|
|
automatic.push(plugin);
|
|
}
|
|
} catch (error) {
|
|
const message = `Failed to auto-load ${name}: ${error instanceof Error ? error.message : String(error)}`;
|
|
if (options.strict) throw new Error(`WRN-PLUGIN-DISCOVERY: ${message}`, { cause: error });
|
|
options.warn?.(message);
|
|
}
|
|
}
|
|
|
|
return [automatic, explicitPlugins];
|
|
}
|