Files
WRNexusJS/packages/plugin/src/discovery.ts
T
2026-07-27 12:42:18 +05:30

273 lines
9.1 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,
} from "./types.ts";
import { definePlugin, flattenPlugins } from "./base.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;
}
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;
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);
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];
}