release: WRNexusJS 0.8.0
This commit is contained in:
@@ -1,7 +1,60 @@
|
||||
# @wrnexus/plugin
|
||||
|
||||
## Least-privilege package permissions
|
||||
|
||||
Package manifests declare every framework capability they register:
|
||||
|
||||
```json
|
||||
{
|
||||
"wrnexus": {
|
||||
"permissions": ["routes", "migrations"],
|
||||
"routes": [{ "kind": "api", "path": "/api/example", "entry": "./route.ts" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Applications can enable fail-closed grants:
|
||||
|
||||
```ts
|
||||
export default {
|
||||
pluginPermissions: {
|
||||
enforce: true,
|
||||
grants: { "example-plugin": ["routes"] },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Discovery rejects used-but-undeclared capabilities with
|
||||
`WRN-PLUGIN-PERMISSION-UNDECLARED` and ungranted capabilities with
|
||||
`WRN-PLUGIN-PERMISSION-DENIED`. Permissions cover components, browser runtime,
|
||||
assets, styles, routes, middleware, migrations, config, transforms,
|
||||
diagnostics/tooling, and server/build hooks.
|
||||
|
||||
## Compatibility matrices
|
||||
|
||||
Manifests can add `compatibility: { bunMin: "1.3.0", os: ["linux",
|
||||
"darwin"] }` alongside `runtimes` and `requires`. Use
|
||||
`testPluginCompatibility(manifest, targets)` in a package test to exercise the
|
||||
complete support matrix. Runtime discovery enforces the same Bun minimum, OS,
|
||||
runtime, and capability declarations used by the test kit.
|
||||
|
||||
Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms,
|
||||
diagnostics, development servers, production builds, and DevToolbar extensions.
|
||||
|
||||
Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters.
|
||||
Duplicate names and dependency cycles are rejected.
|
||||
|
||||
## Complete lifecycle and contributions
|
||||
|
||||
Plugins may implement `setup`, `configure`, `configResolved`, `transformAst`,
|
||||
`transformCode`, `diagnostics`, `routes`, `configureServer`, `buildStart`,
|
||||
`buildEnd`, `render`, `deploy`, `shutdown`, and `hmrUpdate`. The runner preserves
|
||||
resolved plugin order for every hook and executes `setup` exactly once.
|
||||
|
||||
In addition to components, routes, middleware, assets, styles, runtimes, and
|
||||
migrations, plugins can contribute `directives`, `cliCommands`,
|
||||
`virtualModules`, `deploymentAdapters`, `configSchemas`, `documentation`, and
|
||||
`typeDefinitions`. Names are collision checked. Configuration schemas run after
|
||||
configuration resolution, CLI commands are callable as normal `wrnexus`
|
||||
commands, directives participate in AST transformation, and production builds
|
||||
materialize virtual modules and invoke matching contributed adapters.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/plugin",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { WrnexusPackageManifest } from "./types.ts";
|
||||
|
||||
export interface PluginCompatibilityTarget {
|
||||
runtime: "bun" | "node" | "edge" | "worker" | "service-worker" | "browser";
|
||||
version?: string;
|
||||
os?: "win32" | "linux" | "darwin" | string;
|
||||
capabilities?: readonly string[];
|
||||
}
|
||||
|
||||
export interface PluginCompatibilityResult {
|
||||
target: PluginCompatibilityTarget;
|
||||
ok: boolean;
|
||||
issues: Array<{
|
||||
code:
|
||||
| "WRN-PLUGIN-MATRIX-RUNTIME"
|
||||
| "WRN-PLUGIN-MATRIX-VERSION"
|
||||
| "WRN-PLUGIN-MATRIX-OS"
|
||||
| "WRN-PLUGIN-MATRIX-CAPABILITY";
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parts(version: string): number[] {
|
||||
return version
|
||||
.replace(/^[^\d]*/, "")
|
||||
.split(/[.-]/)
|
||||
.slice(0, 3)
|
||||
.map((value) => Number(value) || 0);
|
||||
}
|
||||
|
||||
function atLeast(version: string, minimum: string): boolean {
|
||||
const left = parts(version);
|
||||
const right = parts(minimum);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (left[index] !== right[index]) return (left[index] ?? 0) > (right[index] ?? 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function testPluginCompatibility(
|
||||
manifest: WrnexusPackageManifest,
|
||||
targets: readonly PluginCompatibilityTarget[],
|
||||
): PluginCompatibilityResult[] {
|
||||
return targets.map((target) => {
|
||||
const issues: PluginCompatibilityResult["issues"] = [];
|
||||
if (manifest.runtimes?.length && !manifest.runtimes.includes(target.runtime)) {
|
||||
issues.push({
|
||||
code: "WRN-PLUGIN-MATRIX-RUNTIME",
|
||||
message: `${target.runtime} is not declared.`,
|
||||
});
|
||||
}
|
||||
const compatibility = manifest.compatibility;
|
||||
if (
|
||||
target.runtime === "bun" &&
|
||||
target.version &&
|
||||
compatibility?.bunMin &&
|
||||
!atLeast(target.version, compatibility.bunMin)
|
||||
) {
|
||||
issues.push({
|
||||
code: "WRN-PLUGIN-MATRIX-VERSION",
|
||||
message: `Bun ${target.version} is below ${compatibility.bunMin}.`,
|
||||
});
|
||||
}
|
||||
if (target.os && compatibility?.os?.length && !compatibility.os.includes(target.os)) {
|
||||
issues.push({ code: "WRN-PLUGIN-MATRIX-OS", message: `${target.os} is not declared.` });
|
||||
}
|
||||
const missing = (manifest.requires ?? []).filter(
|
||||
(capability) => !target.capabilities?.includes(capability),
|
||||
);
|
||||
if (missing.length) {
|
||||
issues.push({
|
||||
code: "WRN-PLUGIN-MATRIX-CAPABILITY",
|
||||
message: `Missing: ${missing.join(", ")}.`,
|
||||
});
|
||||
}
|
||||
return { target: { ...target }, ok: issues.length === 0, issues };
|
||||
});
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import type {
|
||||
PackageStyleDefinition,
|
||||
PackageRouteDefinition,
|
||||
PackageMigrationDefinition,
|
||||
PluginPermission,
|
||||
} from "./types.ts";
|
||||
import { definePlugin, flattenPlugins } from "./base.ts";
|
||||
import { testPluginCompatibility } from "./compatibility.ts";
|
||||
|
||||
interface PackageJson {
|
||||
name?: string;
|
||||
@@ -32,6 +34,70 @@ export interface DiscoverPluginOptions {
|
||||
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 {
|
||||
@@ -244,6 +310,40 @@ export async function discoverPlugins(
|
||||
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,
|
||||
@@ -258,6 +358,7 @@ export async function discoverPlugins(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PageAst, WrnDiagnostic } from "@wrnexus/syntax";
|
||||
import type { PageAst, ViewNode, WrnDiagnostic } from "@wrnexus/syntax";
|
||||
import { normalizeClientRuntime, normalizePackageAsset, validateStyleIds } from "./manifest.ts";
|
||||
import type {
|
||||
ClientRuntimeDefinition,
|
||||
@@ -11,7 +11,13 @@ import type {
|
||||
PluginContext,
|
||||
PluginInput,
|
||||
PluginOrder,
|
||||
PluginPermission,
|
||||
PluginRunner,
|
||||
PluginDirective,
|
||||
PluginCliCommand,
|
||||
PluginVirtualModule,
|
||||
PluginDeploymentAdapter,
|
||||
PluginConfigSchema,
|
||||
TransformContext,
|
||||
WrnexusPlugin,
|
||||
} from "./types.ts";
|
||||
@@ -19,6 +25,8 @@ import type {
|
||||
export * from "./types.ts";
|
||||
export * from "./manifest.ts";
|
||||
export { discoverPlugins, type DiscoverPluginOptions } from "./discovery.ts";
|
||||
export { testPluginCompatibility } from "./compatibility.ts";
|
||||
export type { PluginCompatibilityResult, PluginCompatibilityTarget } from "./compatibility.ts";
|
||||
|
||||
export { definePlugin, flattenPlugins } from "./base.ts";
|
||||
import { flattenPlugins } from "./base.ts";
|
||||
@@ -112,9 +120,40 @@ function assertUnique<T>(kind: string, entries: readonly T[], key: (entry: T) =>
|
||||
}
|
||||
}
|
||||
|
||||
async function transformDirectives(
|
||||
nodes: ViewNode[],
|
||||
directives: readonly PluginDirective[],
|
||||
context: TransformContext,
|
||||
): Promise<void> {
|
||||
const registry = new Map(directives.map((directive) => [directive.name, directive]));
|
||||
for (const node of nodes) {
|
||||
if (node.type === "element") {
|
||||
for (const attribute of node.attrs) {
|
||||
if (!attribute.name.startsWith("use:")) continue;
|
||||
const name = attribute.name.slice(4);
|
||||
const directive = registry.get(name);
|
||||
if (!directive) continue;
|
||||
const transformed = await directive.transform?.(attribute.value, context);
|
||||
if (transformed) {
|
||||
attribute.name = transformed.name;
|
||||
attribute.value = transformed.value;
|
||||
} else attribute.name = `data-wrn-directive-${name}`;
|
||||
}
|
||||
await transformDirectives(node.children, directives, context);
|
||||
} else if (node.type === "each") {
|
||||
await transformDirectives(node.body, directives, context);
|
||||
await transformDirectives(node.empty, directives, context);
|
||||
} else if (node.type === "if") {
|
||||
for (const branch of node.branches)
|
||||
await transformDirectives(branch.body, directives, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPluginRunner(input: PluginInput, context: PluginContext): PluginRunner {
|
||||
const plugins = resolvePlugins(input);
|
||||
let contributionCache: PluginContributions | null = null;
|
||||
let setupComplete = false;
|
||||
const transformContext = (file: string): TransformContext => ({ ...context, file });
|
||||
|
||||
const contributions = async (): Promise<PluginContributions> => {
|
||||
@@ -126,6 +165,13 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
|
||||
const routes: PackageRouteDefinition[] = [];
|
||||
const middleware: string[] = [];
|
||||
const migrations: PackageMigrationDefinition[] = [];
|
||||
const directives: PluginDirective[] = [];
|
||||
const cliCommands: PluginCliCommand[] = [];
|
||||
const virtualModules: PluginVirtualModule[] = [];
|
||||
const deploymentAdapters: PluginDeploymentAdapter[] = [];
|
||||
const configSchemas: PluginConfigSchema[] = [];
|
||||
const documentation: string[] = [];
|
||||
const typeDefinitions: string[] = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
componentDirs.push(...(await resolveContribution(plugin.componentDirs, context)));
|
||||
@@ -135,6 +181,13 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
|
||||
routes.push(...(await resolveContribution(plugin.routeEntries, context)));
|
||||
middleware.push(...(await resolveContribution(plugin.middleware, context)));
|
||||
migrations.push(...(await resolveContribution(plugin.migrations, context)));
|
||||
directives.push(...(await resolveContribution(plugin.directives, context)));
|
||||
cliCommands.push(...(await resolveContribution(plugin.cliCommands, context)));
|
||||
virtualModules.push(...(await resolveContribution(plugin.virtualModules, context)));
|
||||
deploymentAdapters.push(...(await resolveContribution(plugin.deploymentAdapters, context)));
|
||||
configSchemas.push(...(await resolveContribution(plugin.configSchemas, context)));
|
||||
documentation.push(...(await resolveContribution(plugin.documentation, context)));
|
||||
typeDefinitions.push(...(await resolveContribution(plugin.typeDefinitions, context)));
|
||||
}
|
||||
|
||||
const normalizedRuntimes = clientRuntimes.map(normalizeClientRuntime);
|
||||
@@ -164,6 +217,11 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
|
||||
}
|
||||
}
|
||||
assertUnique("MIGRATION", migrations, (entry) => `${entry.database ?? "default"}:${entry.id}`);
|
||||
assertUnique("DIRECTIVE", directives, (entry) => entry.name);
|
||||
assertUnique("CLI-COMMAND", cliCommands, (entry) => entry.name);
|
||||
assertUnique("VIRTUAL-MODULE", virtualModules, (entry) => entry.id);
|
||||
assertUnique("DEPLOYMENT-ADAPTER", deploymentAdapters, (entry) => entry.name);
|
||||
assertUnique("CONFIG-SCHEMA", configSchemas, (entry) => entry.namespace);
|
||||
|
||||
contributionCache = {
|
||||
componentDirs: [...new Set(componentDirs)],
|
||||
@@ -173,6 +231,13 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
|
||||
routes,
|
||||
middleware: [...new Set(middleware)],
|
||||
migrations,
|
||||
directives,
|
||||
cliCommands,
|
||||
virtualModules,
|
||||
deploymentAdapters,
|
||||
configSchemas,
|
||||
documentation: [...new Set(documentation)],
|
||||
typeDefinitions: [...new Set(typeDefinitions)],
|
||||
};
|
||||
context.metadata.set("@wrnexus/plugin:contributions", contributionCache);
|
||||
return contributionCache;
|
||||
@@ -181,18 +246,29 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
|
||||
return {
|
||||
plugins,
|
||||
async configure(config) {
|
||||
if (!setupComplete) {
|
||||
for (const plugin of plugins) await plugin.setup?.(context);
|
||||
setupComplete = true;
|
||||
}
|
||||
for (const plugin of plugins) await plugin.configure?.(config, context);
|
||||
contributionCache = null;
|
||||
},
|
||||
async configResolved(config) {
|
||||
for (const plugin of plugins) await plugin.configResolved?.(config, context);
|
||||
contributionCache = null;
|
||||
await contributions();
|
||||
const resolved = await contributions();
|
||||
for (const schema of resolved.configSchemas)
|
||||
await schema.validate(config[schema.namespace], context);
|
||||
},
|
||||
async transformAst(ast, file) {
|
||||
let current = ast;
|
||||
for (const plugin of plugins)
|
||||
current = (await plugin.transformAst?.(current, transformContext(file))) ?? current;
|
||||
await transformDirectives(
|
||||
current.view,
|
||||
(await contributions()).directives,
|
||||
transformContext(file),
|
||||
);
|
||||
return current;
|
||||
},
|
||||
async transformCode(code, file) {
|
||||
@@ -219,11 +295,19 @@ export function createPluginRunner(input: PluginInput, context: PluginContext):
|
||||
for (const plugin of plugins) current = (await plugin.routes?.(current, context)) ?? current;
|
||||
return current;
|
||||
},
|
||||
async render(html) {
|
||||
let current = html;
|
||||
for (const plugin of plugins) current = (await plugin.render?.(current, context)) ?? current;
|
||||
return current;
|
||||
},
|
||||
async hook(name, value) {
|
||||
for (const plugin of plugins) {
|
||||
if (name === "buildStart") await plugin.buildStart?.(context);
|
||||
else if (name === "buildEnd") await plugin.buildEnd?.(value, context);
|
||||
else await plugin.configureServer?.(value, context);
|
||||
else if (name === "configureServer") await plugin.configureServer?.(value, context);
|
||||
else if (name === "deploy") await plugin.deploy?.(value, context);
|
||||
else if (name === "shutdown") await plugin.shutdown?.(context);
|
||||
else await plugin.hmrUpdate?.((value as readonly string[]) ?? [], context);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -237,6 +321,7 @@ export type {
|
||||
PluginContext,
|
||||
TransformContext,
|
||||
WrnexusPlugin,
|
||||
PluginPermission,
|
||||
PluginInput,
|
||||
PluginRunner,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import type { PageAst, WrnDiagnostic } from "@wrnexus/syntax";
|
||||
|
||||
export type PluginOrder = "pre" | "normal" | "post";
|
||||
export type PluginCommand = "dev" | "build" | "test";
|
||||
export type PluginCommand = "dev" | "build" | "test" | "cli" | "deploy";
|
||||
export type ClientRuntimeType = "module" | "script";
|
||||
export type ClientRuntimeLoad = "eager" | "defer" | "idle";
|
||||
export type ClientRuntimeInject = "head" | "body-end";
|
||||
export type PluginPermission =
|
||||
| "components"
|
||||
| "client-runtime"
|
||||
| "assets"
|
||||
| "styles"
|
||||
| "routes"
|
||||
| "middleware"
|
||||
| "migrations"
|
||||
| "config"
|
||||
| "transform"
|
||||
| "diagnostics"
|
||||
| "server"
|
||||
| "cli"
|
||||
| "directives"
|
||||
| "virtual-modules"
|
||||
| "deployment"
|
||||
| "documentation"
|
||||
| "types";
|
||||
|
||||
export interface PluginContext {
|
||||
root: string;
|
||||
@@ -100,6 +118,36 @@ export interface PluginDevToolbarPanel {
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface PluginCliCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
run(args: string[], context: PluginContext): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginVirtualModule {
|
||||
id: string;
|
||||
load(context: PluginContext): string | Promise<string>;
|
||||
}
|
||||
|
||||
export interface PluginDirective {
|
||||
name: string;
|
||||
transform?: (
|
||||
value: string,
|
||||
context: TransformContext,
|
||||
) => { name: string; value: string } | void | Promise<{ name: string; value: string } | void>;
|
||||
}
|
||||
|
||||
export interface PluginDeploymentAdapter {
|
||||
name: string;
|
||||
build(output: unknown, context: PluginContext): unknown | Promise<unknown>;
|
||||
deploy?(output: unknown, context: PluginContext): unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface PluginConfigSchema {
|
||||
namespace: string;
|
||||
validate(value: unknown, context: PluginContext): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
componentDirs: string[];
|
||||
clientRuntimes: ClientRuntimeDefinition[];
|
||||
@@ -108,6 +156,13 @@ export interface PluginContributions {
|
||||
routes: PackageRouteDefinition[];
|
||||
middleware: string[];
|
||||
migrations: PackageMigrationDefinition[];
|
||||
directives: PluginDirective[];
|
||||
cliCommands: PluginCliCommand[];
|
||||
virtualModules: PluginVirtualModule[];
|
||||
deploymentAdapters: PluginDeploymentAdapter[];
|
||||
configSchemas: PluginConfigSchema[];
|
||||
documentation: string[];
|
||||
typeDefinitions: string[];
|
||||
}
|
||||
|
||||
export interface WrnexusPlugin {
|
||||
@@ -145,7 +200,25 @@ export interface WrnexusPlugin {
|
||||
| ((
|
||||
context: PluginContext,
|
||||
) => PackageMigrationDefinition[] | Promise<PackageMigrationDefinition[]>);
|
||||
directives?:
|
||||
| PluginDirective[]
|
||||
| ((context: PluginContext) => PluginDirective[] | Promise<PluginDirective[]>);
|
||||
cliCommands?:
|
||||
| PluginCliCommand[]
|
||||
| ((context: PluginContext) => PluginCliCommand[] | Promise<PluginCliCommand[]>);
|
||||
virtualModules?:
|
||||
| PluginVirtualModule[]
|
||||
| ((context: PluginContext) => PluginVirtualModule[] | Promise<PluginVirtualModule[]>);
|
||||
deploymentAdapters?:
|
||||
| PluginDeploymentAdapter[]
|
||||
| ((context: PluginContext) => PluginDeploymentAdapter[] | Promise<PluginDeploymentAdapter[]>);
|
||||
configSchemas?:
|
||||
| PluginConfigSchema[]
|
||||
| ((context: PluginContext) => PluginConfigSchema[] | Promise<PluginConfigSchema[]>);
|
||||
documentation?: string[] | ((context: PluginContext) => string[] | Promise<string[]>);
|
||||
typeDefinitions?: string[] | ((context: PluginContext) => string[] | Promise<string[]>);
|
||||
|
||||
setup?(context: PluginContext): void | Promise<void>;
|
||||
configure?(config: Record<string, unknown>, context: PluginContext): void | Promise<void>;
|
||||
configResolved?(
|
||||
config: Readonly<Record<string, unknown>>,
|
||||
@@ -158,6 +231,10 @@ export interface WrnexusPlugin {
|
||||
configureServer?(server: unknown, context: PluginContext): void | Promise<void>;
|
||||
buildStart?(context: PluginContext): void | Promise<void>;
|
||||
buildEnd?(result: unknown, context: PluginContext): void | Promise<void>;
|
||||
render?(html: string, context: PluginContext): string | void | Promise<string | void>;
|
||||
deploy?(result: unknown, context: PluginContext): void | Promise<void>;
|
||||
shutdown?(context: PluginContext): void | Promise<void>;
|
||||
hmrUpdate?(files: readonly string[], context: PluginContext): void | Promise<void>;
|
||||
devToolbarPanels?(
|
||||
context: PluginContext,
|
||||
): PluginDevToolbarPanel[] | Promise<PluginDevToolbarPanel[]>;
|
||||
@@ -179,6 +256,16 @@ export interface PackagePluginManifest {
|
||||
export interface WrnexusPackageManifest {
|
||||
name?: string;
|
||||
version?: string;
|
||||
runtimes?: Array<"bun" | "node" | "edge" | "worker" | "service-worker" | "browser">;
|
||||
requires?: Array<
|
||||
"filesystem" | "tcp" | "process" | "websocket" | "crypto" | "streams" | "background-tasks"
|
||||
>;
|
||||
/** Framework capabilities requested by this package; applications may enforce explicit grants. */
|
||||
permissions?: PluginPermission[];
|
||||
compatibility?: {
|
||||
bunMin?: string;
|
||||
os?: string[];
|
||||
};
|
||||
plugin?: string | PackagePluginManifest;
|
||||
components?: string[];
|
||||
clientRuntimes?: ClientRuntimeDefinition[];
|
||||
@@ -199,5 +286,9 @@ export interface PluginRunner {
|
||||
contributions(): Promise<PluginContributions>;
|
||||
devToolbarPanels(): Promise<PluginDevToolbarPanel[]>;
|
||||
transformRoutes(routes: unknown[]): Promise<unknown[]>;
|
||||
hook(name: "buildStart" | "buildEnd" | "configureServer", value?: unknown): Promise<void>;
|
||||
render(html: string): Promise<string>;
|
||||
hook(
|
||||
name: "buildStart" | "buildEnd" | "configureServer" | "deploy" | "shutdown" | "hmrUpdate",
|
||||
value?: unknown,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { testPluginCompatibility } from "../src/index.ts";
|
||||
|
||||
test("plugin compatibility kit evaluates runtime, Bun, OS, and capability matrices", () => {
|
||||
const results = testPluginCompatibility(
|
||||
{
|
||||
runtimes: ["bun", "node"],
|
||||
requires: ["filesystem"],
|
||||
compatibility: { bunMin: "1.3.0", os: ["linux", "darwin"] },
|
||||
},
|
||||
[
|
||||
{ runtime: "bun", version: "1.3.2", os: "linux", capabilities: ["filesystem"] },
|
||||
{ runtime: "bun", version: "1.2.9", os: "win32", capabilities: [] },
|
||||
{ runtime: "edge", os: "linux", capabilities: ["crypto"] },
|
||||
],
|
||||
);
|
||||
expect(results[0]?.ok).toBe(true);
|
||||
expect(results[1]?.issues.map((issue) => issue.code)).toEqual([
|
||||
"WRN-PLUGIN-MATRIX-VERSION",
|
||||
"WRN-PLUGIN-MATRIX-OS",
|
||||
"WRN-PLUGIN-MATRIX-CAPABILITY",
|
||||
]);
|
||||
expect(results[2]?.issues.map((issue) => issue.code)).toEqual([
|
||||
"WRN-PLUGIN-MATRIX-RUNTIME",
|
||||
"WRN-PLUGIN-MATRIX-CAPABILITY",
|
||||
]);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createPluginRunner, discoverPlugins, resolvePlugins } from "../src/index.ts";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
|
||||
describe("plugin ordering", () => {
|
||||
test("orders pre, normal, and post plugins", () => {
|
||||
@@ -22,6 +23,85 @@ describe("plugin ordering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("runs the complete lifecycle and exposes ecosystem contribution channels", async () => {
|
||||
const calls: string[] = [];
|
||||
const runner = createPluginRunner(
|
||||
{
|
||||
name: "ecosystem",
|
||||
setup: () => {
|
||||
calls.push("setup");
|
||||
},
|
||||
configure: () => {
|
||||
calls.push("configure");
|
||||
},
|
||||
configSchemas: [
|
||||
{
|
||||
namespace: "feature",
|
||||
validate(value) {
|
||||
calls.push(`schema:${String(value)}`);
|
||||
},
|
||||
},
|
||||
],
|
||||
directives: [
|
||||
{
|
||||
name: "focus",
|
||||
transform: (value) => ({ name: "data-focus", value }),
|
||||
},
|
||||
],
|
||||
cliCommands: [
|
||||
{
|
||||
name: "hello",
|
||||
run: () => {
|
||||
calls.push("cli");
|
||||
},
|
||||
},
|
||||
],
|
||||
virtualModules: [{ id: "virtual:feature", load: () => "export default true" }],
|
||||
deploymentAdapters: [{ name: "test-cloud", build: (value) => value }],
|
||||
documentation: ["docs/feature.md"],
|
||||
typeDefinitions: ["types/feature.d.ts"],
|
||||
render: (html) => `${html}<!-- plugin -->`,
|
||||
hmrUpdate: (files) => {
|
||||
calls.push(`hmr:${files.join(",")}`);
|
||||
},
|
||||
deploy: () => {
|
||||
calls.push("deploy");
|
||||
},
|
||||
shutdown: () => {
|
||||
calls.push("shutdown");
|
||||
},
|
||||
},
|
||||
context("."),
|
||||
);
|
||||
await runner.configure({ feature: true });
|
||||
await runner.configResolved({ feature: true });
|
||||
const contributions = await runner.contributions();
|
||||
expect(contributions.directives[0]?.name).toBe("focus");
|
||||
expect(contributions.cliCommands[0]?.name).toBe("hello");
|
||||
expect(contributions.virtualModules[0]?.id).toBe("virtual:feature");
|
||||
expect(contributions.deploymentAdapters[0]?.name).toBe("test-cloud");
|
||||
expect(contributions.documentation).toEqual(["docs/feature.md"]);
|
||||
expect(contributions.typeDefinitions).toEqual(["types/feature.d.ts"]);
|
||||
const transformed = await runner.transformAst(
|
||||
parse('page Demo { view { <input use:focus="first"> } }'),
|
||||
"app/pages/demo.wrn",
|
||||
);
|
||||
const input = transformed.view.find((node) => node.type === "element");
|
||||
expect(input?.type === "element" ? input.attrs[0]?.name : undefined).toBe("data-focus");
|
||||
expect(await runner.render("<main></main>")).toContain("<!-- plugin -->");
|
||||
await runner.hook("hmrUpdate", ["app/page.wrn"]);
|
||||
await runner.hook("deploy", {});
|
||||
await runner.hook("shutdown");
|
||||
expect(calls).toEqual([
|
||||
"setup",
|
||||
"configure",
|
||||
"schema:true",
|
||||
"hmr:app/page.wrn",
|
||||
"deploy",
|
||||
"shutdown",
|
||||
]);
|
||||
});
|
||||
|
||||
function context(root: string) {
|
||||
return {
|
||||
root,
|
||||
@@ -185,6 +265,96 @@ test("strict discovery surfaces invalid package plugin exports", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("discovery enforces declared deployment runtimes and capabilities", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-runtime-discovery-"));
|
||||
const app = join(root, "apps", "web");
|
||||
const pkg = join(root, "packages", "filesystem-plugin");
|
||||
try {
|
||||
mkdirSync(app, { recursive: true });
|
||||
mkdirSync(pkg, { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ private: true, workspaces: ["apps/*", "packages/*"] }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(app, "package.json"),
|
||||
JSON.stringify({ name: "web", dependencies: { "filesystem-plugin": "workspace:*" } }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(pkg, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "filesystem-plugin",
|
||||
version: "1.0.0",
|
||||
wrnexus: { runtimes: ["bun", "node"], requires: ["filesystem"] },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
discoverPlugins(app, undefined, { runtime: "edge", capabilities: ["crypto"] }),
|
||||
).rejects.toThrow("WRN-PLUGIN-RUNTIME");
|
||||
await expect(
|
||||
discoverPlugins(app, undefined, { runtime: "bun", capabilities: ["filesystem"] }),
|
||||
).resolves.toBeDefined();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("discovery enforces declared and application-granted plugin permissions", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-permission-discovery-"));
|
||||
const app = join(root, "apps", "web");
|
||||
const pkg = join(root, "packages", "route-plugin");
|
||||
try {
|
||||
mkdirSync(app, { recursive: true });
|
||||
mkdirSync(pkg, { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ private: true, workspaces: ["apps/*", "packages/*"] }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(app, "package.json"),
|
||||
JSON.stringify({ name: "web", dependencies: { "route-plugin": "workspace:*" } }),
|
||||
);
|
||||
writeFileSync(join(pkg, "route.ts"), "export default {};");
|
||||
writeFileSync(
|
||||
join(pkg, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "route-plugin",
|
||||
version: "1.0.0",
|
||||
wrnexus: {
|
||||
permissions: ["routes"],
|
||||
routes: [{ kind: "api", path: "/api/plugin", entry: "./route.ts" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
discoverPlugins(app, undefined, { enforcePermissions: true, grantedPermissions: {} }),
|
||||
).rejects.toThrow("WRN-PLUGIN-PERMISSION-DENIED");
|
||||
await expect(
|
||||
discoverPlugins(app, undefined, {
|
||||
enforcePermissions: true,
|
||||
grantedPermissions: { "route-plugin": ["routes"] },
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
writeFileSync(
|
||||
join(pkg, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "route-plugin",
|
||||
version: "1.0.0",
|
||||
wrnexus: { routes: [{ kind: "api", path: "/api/plugin", entry: "./route.ts" }] },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
discoverPlugins(app, undefined, {
|
||||
enforcePermissions: true,
|
||||
grantedPermissions: { "route-plugin": ["routes"] },
|
||||
}),
|
||||
).rejects.toThrow("WRN-PLUGIN-PERMISSION-UNDECLARED");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("orders package style contributions by pre, normal, and post", async () => {
|
||||
const runner = createPluginRunner(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user