Creating Custom Processors
Processors in Ecopages handle asset transformation, build-time loaders, and development watch behavior. Core owns lifecycle ordering; processors declare contributions through the Processor base class.
See Plugin Lifecycle for the full ordering across config build, startup, and development invalidation.
Basic Structure
A processor extends the Processor abstract class from @ecopages/core:
import {
Processor,
type EcoBuildPlugin,
type ProcessorConfig,
} from '@ecopages/core/plugins/processor';
class CustomProcessor extends Processor {
buildPlugins: EcoBuildPlugin[] = [];
plugins: EcoBuildPlugin[] = [];
constructor(config: Omit<ProcessorConfig, 'name'>) {
super({
name: 'custom-processor',
runtimeCapability: {
tags: ['node-compatible'],
},
capabilities: [
{
kind: 'stylesheet',
extensions: ['*.custom'],
},
],
watch: {
paths: ['src/custom'],
extensions: ['.custom'],
},
...config,
});
}
override async prepareBuildContributions(): Promise<void> {
// Materialize buildPlugins and plugins before ConfigBuilder.build() seals the manifest
}
override async setup(): Promise<void> {
// Runtime-only initialization
}
override async process(input: unknown, filePath?: string): Promise<unknown> {
return input;
}
}
export const customProcessorPlugin = (config?: Omit<ProcessorConfig, 'name'>) =>
new CustomProcessor(config ?? {});Shipped processors such as PostCSS and Image expose a factory function (postcssProcessorPlugin(), imageProcessorPlugin()) rather than requiring callers to construct the class directly.
Declaring Runtime Requirements
Processors can declare runtime assumptions through runtimeCapability. Ecopages validates this during ConfigBuilder.build(), which means incompatible processors fail before startup proceeds.
class CustomProcessor extends Processor {
constructor() {
super({
name: 'custom-processor',
runtimeCapability: {
tags: ['requires-node-builtins'],
minRuntimeVersion: '20.0.0',
},
});
}
}Available tags:
node-compatiblerequires-node-builtinsbun-onlyrequires-native-bun-api
Prefer the narrowest declaration that matches the processor's actual runtime needs.
Processor Lifecycle
Core owns the ordering. Processors participate in these phases:
prepareBuildContributions()— runs duringConfigBuilder.build()before the app build manifest is sealed. MaterializepluginsandbuildPluginshere.- Manifest collection — processor
pluginsbecome runtime plugin contributions;buildPluginsbecome browser bundle contributions. setup()— runs at app startup after the manifest is sealed. Use for runtime-only work such as cache warming or watcher registration.process()— transforms assets when the asset pipeline or watch callbacks invoke the processor.- Watch callbacks — in development,
onChange/onCreate/onDeleteon thewatchconfig handle owned file paths. teardown()— optional override for explicit cleanup. Core does not call this hook today.
Asset Capabilities
Declare which asset kinds a processor owns through capabilities. Without capabilities, canProcessAsset() returns false and the processor will not participate in the asset pipeline.
super({
name: 'custom-processor',
capabilities: [
{
kind: 'stylesheet',
extensions: ['*.{css,scss,sass}'],
},
],
});Supported kind values: script, stylesheet, image. Extension patterns support *, .css, *.css, and grouped forms such as *.{css,scss}.
prepareBuildContributions
Use prepareBuildContributions() when plugins or buildPlugins depend on finalized paths or options resolved during config build:
override async prepareBuildContributions(): Promise<void> {
this.plugins = [
{
name: 'custom-runtime-loader',
setup(build) {
build.onLoad({ filter: /\.custom$/ }, async (args) => ({
contents: await transformCustomFile(args.path),
loader: 'js',
}));
},
},
];
this.buildPlugins = [
{
name: 'custom-browser-plugin',
setup(build) {
// Browser-only bundling behavior
},
},
];
}Runtime-only side effects belong in setup(), not here.
Working with Cache
Processors have built-in caching capabilities:
class CustomProcessor extends Processor {
override async process(input: string): Promise<string> {
const cacheKey = 'my-cache-key';
const cached = await this.readCache<string>(cacheKey);
if (cached) return cached;
const result = await someExpensiveOperation(input);
await this.writeCache(cacheKey, result);
return result;
}
}Adding Build Plugins
Processors contribute runtime-agnostic build plugins through EcoBuildPlugin:
class CustomProcessor extends Processor {
buildPlugins = [
{
name: 'custom-build-plugin',
setup(build) {
build.onResolve({ filter: /\.custom$/ }, (args) => ({
path: args.path,
}));
},
},
];
plugins = [
{
name: 'custom-runtime-plugin',
setup(build) {
build.onLoad({ filter: /\.custom$/ }, async (args) => ({
contents: 'export default ""',
loader: 'js',
}));
},
},
];
}buildPlugins apply to browser-oriented bundles. plugins are collected into runtime plugin contributions and exposed during startup.
File Watching
Configure file watching for development mode:
class CustomProcessor extends Processor {
constructor() {
super({
name: 'custom-processor',
watch: {
paths: ['src/custom'],
extensions: ['.custom'],
onChange: async ({ path, bridge }) => {
// bridge.reload() or bridge.cssUpdate(path)
},
onDelete: async ({ path, bridge }) => {
// Handle file deletions
},
},
});
}
}Using the Processor
Register your processor in eco.config.ts:
import { ConfigBuilder } from '@ecopages/core/config-builder';
import { ecopagesJsxPlugin } from '@ecopages/ecopages-jsx';
import { customProcessorPlugin } from './custom-processor';
const config = await new ConfigBuilder()
.setProcessors([customProcessorPlugin()])
.setIntegrations([ecopagesJsxPlugin()])
.build();addProcessor(instance) is also valid when you already hold a configured processor instance.
Communicating with the Browser (IClientBridge)
Processors can broadcast events to connected browser clients for live updates using the bridge provided in watch callbacks:
import type { IClientBridge } from '@ecopages/core';
import path from 'node:path';
import { fileSystem } from '@ecopages/file-system';
class StyleProcessor extends Processor {
constructor() {
super({
name: 'style-processor',
watch: {
paths: ['src/styles'],
extensions: ['.css'],
onChange: async ({ path, bridge }) => {
await this.processFile(path, bridge);
},
},
});
}
private async processFile(filePath: string, bridge: IClientBridge) {
if (!this.context) return;
const source = await fileSystem.readFile(filePath);
const processed = String(await this.process(source, filePath));
const outputPath = path.join(this.context.distDir, path.basename(filePath));
await fileSystem.write(outputPath, processed);
bridge.cssUpdate(filePath);
}
}The IClientBridge contract provides:
reload(): Triggers a full page reload.cssUpdate(path): Triggers a CSS hot update for the given path.update(path): Triggers a JS module update.error(message): Sends an error notification to the client.broadcast(event): Sends a rawClientBridgeEvent.
Best Practices
- Use meaningful names for your processors
- Declare the smallest valid
runtimeCapability - Register
capabilitieswhen the processor owns asset kinds - Put manifest-facing work in
prepareBuildContributions(); runtime work insetup() - Cache expensive operations
- Use
bridgefor live updates instead of full reloads when possible - Export a factory function for
eco.config.tsregistration - See PostCSS Processor for a production reference implementation