273 lines
9.1 KiB
TypeScript
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];
|
|
}
|