@enclosurejs/plugin-loader
v1.1.0
Published
Runtime plugin discovery, validation, and loading for Enclosure apps
Readme
@enclosurejs/plugin-loader — Runtime plugin discovery and validation
[!IMPORTANT] This is a universal module that provides stateless utilities for loading runtime-delivered plugins into Enclosure apps. It validates manifest metadata, checks
@enclosurejs/coreversion compatibility, verifies capability availability, enforces application targeting, and creates isolated DI contexts for capability gating — all before a plugin touches the running application.
The Problem
Compile-time plugins are bundled into the application and share full DI access. Runtime-delivered plugins (mnemoscheme panels, device screens, third-party extensions) arrive as separate bundles with a manifest.json descriptor. Without a loader layer, the host application would need to manually:
- Parse and validate manifest schemas with meaningful error messages
- Check semver compatibility between the plugin and the running core
- Verify that all required platform capabilities are actually provided
- Enforce that a plugin only runs in the application it targets
- Restrict DI access to declared capabilities only (capability gating)
@enclosurejs/plugin-loader solves this with pure functions that compose into a single validate() → loadPluginEntry() → createScopedContext() pipeline. No side effects, no global state, no platform dependencies.
Architecture
manifest.json ──▶ parseManifest() ──▶ PluginManifest
│
validate(manifest, ctx, ver, appId)
│
┌──────┼──────────────────┐
│ │ │
checkCompat checkCaps checkAppTarget
│ │ │
└──────┼──────────────────┘
│
loadPluginEntry(url) ──▶ Plugin
│
createScopedContext(ctx, caps) ──▶ scoped Context
│
app.installPlugin(plugin)The loader exports stateless functions — it does not read files or manage plugin lifecycle. The caller (or a future scan()/watch() integration) owns I/O via FileSystem and FileWatcherService capabilities.
Quick Start
import {
parseManifest,
validate,
loadPluginEntry,
createScopedContext,
} from '@enclosurejs/plugin-loader';
import { isErr } from '@enclosurejs/core';
// 1. Parse manifest from raw JSON text (caller owns file I/O)
const manifestResult = parseManifest(jsonText);
if (isErr(manifestResult)) throw manifestResult.error;
const manifest = manifestResult.value;
// 2. Pre-install validation: version + capabilities + app target
const check = validate(manifest, app.context, undefined, app.appId);
if (isErr(check)) {
console.warn(`Skipping plugin ${manifest.id}: ${check.error.message}`);
return;
}
// 3. Dynamic import of the plugin entry
const pluginResult = await loadPluginEntry(new URL(manifest.entry, baseUrl).href);
if (isErr(pluginResult)) throw pluginResult.error;
// 4. Create a scoped context (only declared capabilities visible)
const scopedCtx = createScopedContext(app.context, manifest.capabilities);
// 5. Install into the running application
await app.installPlugin(pluginResult.value);API
| Export | Kind | Purpose |
| --------------------- | --------- | ---------------------------------------------------------------- |
| parseManifest | function | Parse JSON text → Result<PluginManifest> |
| validateManifest | function | Validate parsed JSON object → Result<PluginManifest> |
| validate | function | Combined version + capabilities + app-target check |
| checkCompatibility | function | Semver range check: manifest.enclosure vs running core version |
| checkCapabilities | function | Verify all declared capability tokens exist in DI context |
| checkAppTarget | function | Verify plugin targets the current application (or is universal) |
| satisfies | function | Semver range matcher (>=, ^, ~, exact, *) |
| loadPluginEntry | function | Dynamic import + validate named plugin export |
| createScopedContext | function | Isolated DI context with only well-known + declared capabilities |
| PluginManifest | interface | Typed manifest schema |
PluginManifest
| Field | Type | Required | Description |
| -------------- | ----------------------- | -------- | ---------------------------------------------------------- |
| id | string | yes | Unique plugin identifier — must match Plugin.id in entry |
| name | string \| undefined | no | Human-readable display name |
| version | string | yes | Semver of the plugin, for update detection |
| entry | string | yes | Relative path to bundled JS entry (e.g. "./index.js") |
| enclosure | string | yes | Semver range of compatible @enclosurejs/core versions |
| capabilities | string[] \| undefined | no | Capability short names the plugin requires |
| app | string[] \| undefined | no | Application IDs this plugin targets (omit for universal) |
Error Codes
All errors are CoreError instances with domain 'manifest' or 'plugin-loader':
| Code | Domain | When |
| ---------------------- | ------------- | -------------------------------------------------------- |
| INVALID_FORMAT | manifest | Raw value is not a JSON object |
| MISSING_FIELD | manifest | Required field missing or wrong type |
| INVALID_FIELD | manifest | Optional field has wrong type |
| INCOMPATIBLE_VERSION | manifest | enclosure range not satisfied by running core version |
| UNKNOWN_CAPABILITY | manifest | Capability short name not in capabilityTokenMap |
| MISSING_CAPABILITY | manifest | Capability token not provided in DI context |
| APP_NOT_SET | manifest | Plugin targets specific apps but no appId configured |
| APP_MISMATCH | manifest | Plugin does not target current appId |
| INVALID_JSON | plugin-loader | parseManifest received unparseable text |
| IMPORT_FAILED | plugin-loader | Dynamic import threw (network error, syntax error, etc.) |
| MISSING_EXPORT | plugin-loader | Entry module has no named plugin export |
| INVALID_PLUGIN | plugin-loader | plugin export missing id string |
Configuration
Zero configuration. All functions are pure and stateless — behavior is controlled entirely through arguments.
Types Exported
| Type | Used by |
| ---------------- | ------------------------------------------------------- |
| PluginManifest | Any code that reads, validates, or displays plugin info |
Safety
Error Model
All validation failures return Result<void> with structured CoreError — never throw. Import failures and schema violations are always surfaced as err(CoreError(...)). The caller decides how to handle: skip the plugin, log a warning, or propagate.
Capability Gating
createScopedContext builds a standalone Context (no parent chain) with only well-known tokens (BackendToken, LifecycleToken, EventBusToken, AppToken, WidgetRegistryToken) and tokens matching declared capabilities. The plugin cannot resolve undeclared capability tokens via parent traversal.
If capabilities is undefined or empty, a plain createChild() is returned (no restriction — same as compile-time plugins).
Semver Engine
Minimal implementation covering common range patterns (>=, ^, ~, exact, *). Pre-release labels and complex boolean ranges (||, spaces) are not supported — returns false for unrecognized patterns.
Benchmarks
Not applicable. All functions are thin in-memory validation (JSON parsing, string comparisons, Map lookups). Performance is dominated by dynamic import() of the plugin entry, which is I/O-bound and outside this package's control.
Bundle Size
| Output | File | Size |
| ------------ | ------------ | -------- |
| Runtime (JS) | index.js | 8.49 KB |
| Types (DTS) | index.d.ts | 2.55 KB |
| Total | | 11.04 KB |
Single entrypoint. Single external dependency (@enclosurejs/core) marked as external in the build.
Quality
| Metric | Value |
| --------------------- | ------------------------------------------------------------------ |
| Unit tests | 64 (all pass) |
| Test files | 3 (manifest, loader, scoped-context) |
| Source files | 4 (manifest, scoped-context, loader, index) |
| Dependencies | 1 (@enclosurejs/core — workspace peer) |
| External dependencies | 0 (devDependencies only: tsup) |
| Coverage thresholds | statements >= 90%, branches >= 85%, functions >= 95%, lines >= 90% |
Quality Layers
Layer 1: STATIC ANALYSIS (every commit)
tsc --noEmit strict mode, zero errors
eslint ESLint 9 flat config, zero warnings
prettier --check formatting
Layer 2: UNIT TESTS (every commit)
64 tests manifest (44), loader (11), scoped-context (9)
covers schema validation (all fields, edge cases),
semver ranges (>=, ^, ~, exact, *), compatibility,
capability lookup (unknown/missing), app targeting,
dynamic import (mock), scoped context isolation,
combined validate(), parseManifest JSON errors
v8 coverage statements >= 90%, branches >= 85%, functions >= 95%, lines >= 90%
Layer 3: BENCHMARKS
N/A in-memory validation — no meaningful benchmark target
Layer 4: PACKAGE HEALTH
1 workspace dep @enclosurejs/core (types + CoreError + DI, externalized in build)
tsup build ESM + DTS output, single entrypointFile Structure
packages/plugin-loader/
├── src/
│ ├── index.ts Barrel: manifest types, validation, loader, scoped context
│ ├── manifest.ts PluginManifest, validateManifest, semver, compatibility checks
│ ├── scoped-context.ts createScopedContext — isolated DI for capability gating
│ ├── loader.ts validate, parseManifest, loadPluginEntry
│ └── __tests__/
│ ├── manifest.test.ts 44 tests — schema, semver, compat, capabilities, app target
│ ├── loader.test.ts 11 tests — validate, parseManifest, loadPluginEntry
│ └── scoped-context.test.ts 9 tests — well-known tokens, capability filtering, empty caps
├── package.json
├── tsconfig.json
├── tsup.config.ts
└── README.mdLicense
MIT
