Building a Plugin System in TypeScript: Dynamic Loading, Sandboxing, and API Contracts for Extensible Applications
How to design and build a production plugin system in TypeScript. Covers lifecycle management, stable API contracts with Zod, dynamic import patterns, sandboxing strategies, dependency injection, and versioning, with complete TypeScript examples.
Plugin systems are one of those problems that look simple until you have shipped one. The initial version is always a thin require() call and a duck-typed interface. Then a plugin crashes your process, or mutates global state you were depending on, or your “stable” API breaks everyone at version 2.0. The architecture you start with shapes every plugin author’s experience for years.
This article walks through building a plugin system that handles the hard parts: stable API contracts, dynamic loading at runtime, meaningful sandboxing without reaching for heavyweight VMs, dependency injection for host services, and versioning that does not break the ecosystem every six months.
The examples use Node.js with TypeScript. The patterns apply to CLI tools, build systems, editors, servers, and any application that needs to be extended by code it did not ship.
Defining a Stable Plugin Contract
The first thing to get right is the contract between your host application and its plugins. This is the API surface you will be maintaining for years. Get it wrong and you either break plugins on every release or paint yourself into a corner trying to stay compatible.
Start with a typed interface, then validate it at load time with Zod. The interface is for TypeScript authors; the Zod schema is your runtime safety net for plugins that ship as compiled JS.
import { z } from "zod";
// The services the host exposes to plugins
export interface HostContext {
logger: {
info(msg: string, meta?: Record<string, unknown>): void;
warn(msg: string, meta?: Record<string, unknown>): void;
error(msg: string, meta?: Record<string, unknown>): void;
};
config: {
get<T>(key: string): T | undefined;
set(key: string, value: unknown): void;
};
events: {
emit(event: string, payload: unknown): void;
on(event: string, handler: (payload: unknown) => void): () => void;
};
}
// What every plugin must export
export interface Plugin {
name: string;
version: string;
requires?: string; // semver range for host compatibility
setup(ctx: HostContext): void | Promise<void>;
teardown?(): void | Promise<void>;
}
// Runtime validation schema (matches the interface above)
export const PluginSchema = z.object({
name: z.string().min(1),
version: z.string().regex(/^\d+\.\d+\.\d+$/),
requires: z.string().optional(),
setup: z.function().args(z.any()).returns(z.union([z.void(), z.promise(z.void())])),
teardown: z.function().returns(z.union([z.void(), z.promise(z.void())])).optional(),
});
export type PluginInput = z.infer<typeof PluginSchema>;
Two things to note here. First, HostContext is intentionally narrow. You expose only what plugins need, not your entire application object. This is the main lever for preventing plugins from reaching into internals they should not touch. Second, PluginSchema validates the shape at runtime. A plugin author can use a different version of TypeScript, compile down to CJS, or not use TypeScript at all. The Zod check catches structural problems before setup() runs.
Plugin Lifecycle: Discovery, Loading, Initialization, and Teardown
A plugin system needs more than a loader. It needs a registry that tracks state through a well-defined lifecycle so you can reason about ordering and handle failures without taking down the host.
type PluginState = "discovered" | "loading" | "active" | "failed" | "stopped";
interface PluginRecord {
plugin: Plugin;
state: PluginState;
error?: Error;
loadedAt?: Date;
stoppedAt?: Date;
}
class PluginRegistry {
private plugins = new Map<string, PluginRecord>();
private context: HostContext;
constructor(ctx: HostContext) {
this.context = ctx;
}
async register(plugin: Plugin): Promise<void> {
if (this.plugins.has(plugin.name)) {
throw new Error(`Plugin "${plugin.name}" is already registered`);
}
// Validate shape before anything else
const result = PluginSchema.safeParse(plugin);
if (!result.success) {
throw new Error(
`Plugin "${plugin.name}" failed validation: ${result.error.message}`
);
}
this.plugins.set(plugin.name, { plugin, state: "discovered" });
}
async initialize(name: string): Promise<void> {
const record = this.plugins.get(name);
if (!record) throw new Error(`Plugin "${name}" not registered`);
record.state = "loading";
try {
await record.plugin.setup(this.context);
record.state = "active";
record.loadedAt = new Date();
} catch (err) {
record.state = "failed";
record.error = err instanceof Error ? err : new Error(String(err));
throw record.error;
}
}
async teardown(name: string): Promise<void> {
const record = this.plugins.get(name);
if (!record || record.state !== "active") return;
try {
await record.plugin.teardown?.();
} finally {
record.state = "stopped";
record.stoppedAt = new Date();
}
}
async teardownAll(): Promise<void> {
// Tear down in reverse registration order
const names = [...this.plugins.keys()].reverse();
for (const name of names) {
await this.teardown(name).catch((err) => {
this.context.logger.error(`Teardown failed for "${name}"`, {
error: err.message,
});
});
}
}
status(): Record<string, PluginState> {
return Object.fromEntries(
[...this.plugins.entries()].map(([name, rec]) => [name, rec.state])
);
}
}
The state machine matters. A plugin stuck in loading means its setup() hung. A plugin in failed means you can report a useful error instead of silently ignoring it. teardownAll() reverses registration order, which handles the common case where a plugin B depends on services that plugin A set up.
Dynamic Loading at Runtime
Static imports mean plugins are bundled at build time. A proper plugin system loads plugin code at runtime, from paths or URLs that are not known when you compile.
import { pathToFileURL } from "node:url";
import { createRequire } from "node:module";
import * as path from "node:path";
import * as semver from "semver";
const HOST_VERSION = "1.4.2";
async function loadPlugin(pluginPath: string): Promise<Plugin> {
// Resolve to absolute path before any dynamic import
const resolved = path.resolve(pluginPath);
let mod: unknown;
try {
// ESM: use dynamic import with a file URL
mod = await import(pathToFileURL(resolved).href);
} catch (esmErr) {
// CJS fallback: use require (for plugins that did not ship ESM)
try {
const require = createRequire(import.meta.url);
mod = require(resolved);
} catch (cjsErr) {
throw new Error(
`Failed to load plugin at "${pluginPath}": ${(esmErr as Error).message}`
);
}
}
// ESM modules export via named exports; CJS via module.exports
const raw = (mod as any).default ?? mod;
// Validate shape
const result = PluginSchema.safeParse(raw);
if (!result.success) {
throw new Error(
`Invalid plugin at "${pluginPath}": ${result.error.message}`
);
}
const plugin = raw as Plugin;
// Version compatibility check
if (plugin.requires && !semver.satisfies(HOST_VERSION, plugin.requires)) {
throw new Error(
`Plugin "${plugin.name}" requires host ${plugin.requires}, got ${HOST_VERSION}`
);
}
return plugin;
}
The ESM/CJS fallback is ugly but necessary in 2026. Many plugin authors still publish CJS. The pathToFileURL conversion is required for Windows paths inside import(). Skipping it causes subtle failures on Windows that are invisible in CI unless you test on Windows.
For discovery, you can scan a directory:
import * as fs from "node:fs/promises";
async function discoverPlugins(pluginDir: string): Promise<string[]> {
let entries: fs.Dirent[];
try {
entries = await fs.readdir(pluginDir, { withFileTypes: true });
} catch {
return []; // directory does not exist yet
}
return entries
.filter((e) => e.isFile() && (e.name.endsWith(".js") || e.name.endsWith(".mjs")))
.map((e) => path.join(pluginDir, e.name));
}
Or from a package.json-style manifest:
interface PluginManifest {
plugins: string[]; // package names or relative paths
}
async function loadFromManifest(
manifestPath: string,
baseDir: string
): Promise<Plugin[]> {
const raw = await fs.readFile(manifestPath, "utf-8");
const manifest: PluginManifest = JSON.parse(raw);
return Promise.all(
manifest.plugins.map((entry) => {
const resolved = entry.startsWith(".")
? path.resolve(baseDir, entry)
: require.resolve(entry, { paths: [baseDir] });
return loadPlugin(resolved);
})
);
}
Sandboxing: How Isolated Do You Actually Need to Be?
This is where most plugin system designs make a choice they regret. Full VM-level isolation (separate processes, worker threads, or WASM sandboxes) is expensive. In-process plugins with no isolation are cheap but dangerous. The right level depends on your threat model.
What Can Go Wrong Without Sandboxing
A plugin running in-process can:
- Throw an uncaught exception and crash the host
- Mutate global state (
process.env,global, prototype chains) - Call
process.exit() - Block the event loop with a synchronous infinite loop
- Access
fsornetarbitrarily
Level 1: Containment Without Isolation (In-Process)
For plugins you control or trust (internal tools, your own teams), wrap calls defensively:
async function safeSetup(plugin: Plugin, ctx: HostContext): Promise<void> {
const timer = setTimeout(() => {
throw new Error(`Plugin "${plugin.name}" setup timed out after 5000ms`);
}, 5000);
try {
await plugin.setup(ctx);
} catch (err) {
throw new Error(
`Plugin "${plugin.name}" threw during setup: ${(err as Error).message}`
);
} finally {
clearTimeout(timer);
}
}
This catches thrown errors and enforces a timeout. It does not prevent the other failure modes, but for a trusted-plugin environment it is often enough.
Level 2: Worker Thread Isolation
Worker threads give you a separate V8 heap with shared memory via SharedArrayBuffer. Plugins crash their own thread, not the host. The cost is serialization overhead on every call across the thread boundary.
import { Worker } from "node:worker_threads";
import { EventEmitter } from "node:events";
interface PluginMessage {
id: string;
type: "call" | "result" | "error" | "event";
method?: string;
args?: unknown[];
payload?: unknown;
error?: string;
}
class WorkerPluginHost extends EventEmitter {
private worker: Worker;
private pending = new Map<string, { resolve: Function; reject: Function }>();
constructor(pluginPath: string) {
super();
// The worker script loads and runs the plugin
this.worker = new Worker(
new URL("./plugin-worker-bootstrap.js", import.meta.url),
{ workerData: { pluginPath } }
);
this.worker.on("message", (msg: PluginMessage) => {
if (msg.type === "result" || msg.type === "error") {
const pending = this.pending.get(msg.id);
if (!pending) return;
this.pending.delete(msg.id);
msg.type === "result"
? pending.resolve(msg.payload)
: pending.reject(new Error(msg.error));
} else if (msg.type === "event") {
this.emit("plugin-event", msg.payload);
}
});
this.worker.on("exit", (code) => {
if (code !== 0) {
for (const [, pending] of this.pending) {
pending.reject(new Error(`Worker exited with code ${code}`));
}
this.pending.clear();
}
});
}
async call(method: string, ...args: unknown[]): Promise<unknown> {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
this.pending.set(id, { resolve, reject });
this.worker.postMessage({ id, type: "call", method, args });
});
}
async stop(): Promise<void> {
await this.worker.terminate();
}
}
The bootstrap script (plugin-worker-bootstrap.js) loads the plugin and handles incoming method calls:
import { workerData, parentPort } from "node:worker_threads";
const { pluginPath } = workerData;
const mod = await import(pathToFileURL(pluginPath).href);
const plugin = mod.default ?? mod;
// Build a proxied host context that posts events back to the main thread
const ctx: HostContext = {
logger: {
info: (msg, meta) => parentPort!.postMessage({ type: "event", payload: { level: "info", msg, meta } }),
warn: (msg, meta) => parentPort!.postMessage({ type: "event", payload: { level: "warn", msg, meta } }),
error: (msg, meta) => parentPort!.postMessage({ type: "event", payload: { level: "error", msg, meta } }),
},
// config and events would follow the same pattern
};
await plugin.setup(ctx);
parentPort!.on("message", async (msg) => {
try {
const result = await plugin[msg.method]?.(...msg.args);
parentPort!.postMessage({ id: msg.id, type: "result", payload: result });
} catch (err) {
parentPort!.postMessage({ id: msg.id, type: "error", error: (err as Error).message });
}
});
Worker threads eliminate cross-plugin heap corruption and crash isolation. What they do not prevent is blocking the worker’s event loop or infinite loops. For that, you need a separate process.
Level 3: Process Isolation
Spawn each plugin as a child process with a restricted environment. More overhead, but a plugin that blocks or crashes cannot affect the host at all.
import { fork } from "node:child_process";
function spawnPluginProcess(pluginPath: string): ChildProcess {
return fork(
new URL("./plugin-process-bootstrap.js", import.meta.url).pathname,
[pluginPath],
{
env: {
// Restrict environment: no AWS keys, no DB URLs, etc.
NODE_ENV: process.env.NODE_ENV,
PATH: process.env.PATH,
},
stdio: ["pipe", "pipe", "pipe", "ipc"],
}
);
}
The tradeoff is startup time (50-200ms per plugin on cold start) and IPC serialization overhead on every method call. Acceptable for plugins that run infrequently; painful for plugins on the hot path.
Dependency Injection for Plugin Services
Handing plugins a fully-formed HostContext is fine for small systems. As the number of services grows, you want a container so plugins can declare what they need and the host wires it up.
type ServiceFactory<T> = (ctx: HostContext) => T;
class ServiceContainer {
private factories = new Map<string, ServiceFactory<unknown>>();
private instances = new Map<string, unknown>();
register<T>(name: string, factory: ServiceFactory<T>): void {
this.factories.set(name, factory as ServiceFactory<unknown>);
}
resolve<T>(name: string, ctx: HostContext): T {
if (this.instances.has(name)) {
return this.instances.get(name) as T;
}
const factory = this.factories.get(name);
if (!factory) throw new Error(`Service "${name}" not registered`);
const instance = factory(ctx);
this.instances.set(name, instance);
return instance as T;
}
}
// Plugin declares its dependencies by name
interface PluginWithDeps extends Plugin {
dependencies?: string[];
}
// Host wires them up before calling setup
async function initializeWithDeps(
plugin: PluginWithDeps,
container: ServiceContainer,
baseCtx: HostContext
): Promise<void> {
const deps = plugin.dependencies ?? [];
const resolved: Record<string, unknown> = {};
for (const dep of deps) {
resolved[dep] = container.resolve(dep, baseCtx);
}
// Extend context with resolved services
const ctx = { ...baseCtx, services: resolved };
await plugin.setup(ctx as any);
}
This keeps plugin setup() signatures simple while giving the host control over what each plugin can access. A plugin requesting "database" only gets it if the host has registered that service. An untrusted plugin can be refused specific services by simply not registering them in its scoped container.
Versioning and Backward Compatibility
The hardest part of running a plugin ecosystem is changing the host API without breaking existing plugins. The patterns that work in practice are additive versioning, capability detection, and explicit compatibility ranges.
Additive versioning: Only add to HostContext. Never remove or rename properties between major versions. Deprecate by marking in JSDoc, remove at the next major.
Capability detection: Plugins check for optional features rather than relying on version numbers.
// Plugin checks capability instead of version
function setup(ctx: HostContext): void {
if ("metrics" in ctx && typeof ctx.metrics?.increment === "function") {
ctx.metrics.increment("plugin.loaded", { name: "my-plugin" });
}
// ... rest of setup
}
Compatibility ranges: Use semver ranges in plugin.requires and enforce them at load time (shown in the loadPlugin function above). When you release a breaking change, bump the host’s major version. Plugins that declare requires: "^1.0.0" will refuse to load on host v2 and give a clear error message instead of crashing mysteriously.
Versioned context objects: For large host APIs, consider scoping breaking changes into a new context accessor rather than modifying the root.
// v1 plugins get the original ctx
// v2 plugins get the same ctx plus ctx.v2
interface HostContextV2 extends HostContext {
v2: {
scheduler: {
cron(expr: string, handler: () => void): () => void;
};
};
}
This way a plugin targeting v2 features can coexist with plugins that only target v1.
Tradeoffs Table
| Dimension | In-Process | Worker Thread | Child Process |
|---|---|---|---|
| Crash isolation | None | Partial (heap isolated) | Full |
| Memory overhead | Zero | ~20MB per worker | ~30-50MB per process |
| Startup latency | ~0ms | ~10-50ms | ~50-200ms |
| IPC overhead | None | Serialization per call | Serialization + IPC per call |
| Event loop isolation | None | Isolated per thread | Fully isolated |
| Global state contamination | Full risk | Shared nothing (no globals) | Shared nothing |
| Recommended for | Trusted internal plugins | Untrusted, frequent calls | Untrusted, long-running |
Production Considerations
Plugin load order and dependencies. If plugin B registers an event listener that plugin A emits during its own setup(), load order matters. Either document that setup events are not guaranteed to have listeners, or implement a two-phase init: all plugins call setup(), then all plugins call ready() after the registry is fully loaded.
Hot reload. In development, you want to reload a plugin without restarting the host. With worker threads, terminate the worker and spawn a new one. Clearing Node.js’s module cache for dynamic require() calls requires explicit cache deletion: delete require.cache[require.resolve(pluginPath)]. ESM module caches cannot be cleared without a custom loader, which is why worker threads are a cleaner option for hot-reload scenarios.
Plugin timeouts. Always enforce a timeout on setup() and on any method call that crosses an isolation boundary. A plugin author’s bug should not hang your application indefinitely. Use Promise.race() with a rejection timer, or AbortSignal.timeout() in Node 18+.
Error reporting. A plugin in failed state should surface a clear error: which plugin, which lifecycle phase, and the original error message. Swallowing errors quietly makes debugging impossible for plugin authors who do not have access to your host’s internal logs.
Security for untrusted plugins. Worker threads and child processes narrow the attack surface but do not eliminate it. A malicious plugin in a child process can still make outbound network calls. For real security boundaries, WASM sandboxes (via @bytecodealliance/wasmtime-js or similar) or container-level isolation are the next step. These come with significant authoring friction for plugin developers and are only justified for true untrusted-code scenarios.
Testing plugin integrations. Build a TestHostContext that records all logger calls, config reads/writes, and emitted events. Plugin unit tests should use this, not a mock. It catches regressions in how your plugin interacts with the host contract.
function createTestContext(): HostContext & {
logs: { level: string; msg: string }[];
emittedEvents: { event: string; payload: unknown }[];
} {
const logs: { level: string; msg: string }[] = [];
const emittedEvents: { event: string; payload: unknown }[] = [];
return {
logger: {
info: (msg) => logs.push({ level: "info", msg }),
warn: (msg) => logs.push({ level: "warn", msg }),
error: (msg) => logs.push({ level: "error", msg }),
},
config: {
get: () => undefined,
set: () => {},
},
events: {
emit: (event, payload) => emittedEvents.push({ event, payload }),
on: () => () => {},
},
logs,
emittedEvents,
};
}
Patterns From Real Tools
Looking at how established tools handle this is instructive.
Vite defines plugins as objects with a set of named hooks (transform, resolveId, load, etc.). The host calls each hook in sequence across all registered plugins. Plugins do not call each other. The API contract is the hook signature, and the versioning story is Rollup plugin compatibility.
ESLint uses a flat config that maps rule names to rule implementations. Each rule is a factory that receives a RuleContext with a narrow API: report violations, get source code, get options. Rules cannot reach the linter’s internals. This narrowness is why ESLint plugins are generally safe to run without sandboxing.
Hono middleware is the simplest model: middleware is a function (c, next) => Promise<void>. The “context” is the request context. The “plugin” is the middleware. No registry, no lifecycle, no teardown. It works because HTTP middleware is stateless by convention.
The right level of ceremony depends on what your plugins need to do. If they are pure transformers with no side effects, Hono’s model is correct. If they register long-lived state, subscribe to events, or acquire resources, you need lifecycle management.
Plugin systems accumulate technical debt fast. The contracts you define in v1 will constrain your architecture for years. Narrow the HostContext to the minimum, validate at load time, enforce timeouts, and pick the isolation level that matches your actual threat model. The rest is implementation detail.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.