Ecopages0.2.0-rc.4

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:

  1. node-compatible
  2. requires-node-builtins
  3. bun-only
  4. requires-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:

  1. prepareBuildContributions() — runs during ConfigBuilder.build() before the app build manifest is sealed. Materialize plugins and buildPlugins here.
  2. Manifest collection — processor plugins become runtime plugin contributions; buildPlugins become browser bundle contributions.
  3. setup() — runs at app startup after the manifest is sealed. Use for runtime-only work such as cache warming or watcher registration.
  4. process() — transforms assets when the asset pipeline or watch callbacks invoke the processor.
  5. Watch callbacks — in development, onChange / onCreate / onDelete on the watch config handle owned file paths.
  6. 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 raw ClientBridgeEvent.

Best Practices

  1. Use meaningful names for your processors
  2. Declare the smallest valid runtimeCapability
  3. Register capabilities when the processor owns asset kinds
  4. Put manifest-facing work in prepareBuildContributions(); runtime work in setup()
  5. Cache expensive operations
  6. Use bridge for live updates instead of full reloads when possible
  7. Export a factory function for eco.config.ts registration
  8. See PostCSS Processor for a production reference implementation