Creating Custom Processors
Processors in Ecopages handle asset transformation, build-time loaders, and development watch behavior. Import the Processor base class and its contracts from @ecopages/core/processor. Core owns lifecycle ordering.
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 type { EcoPagesAppConfig } from '@ecopages/core';
import {
Processor,
type EcoBuildPlugin,
type ProcessorConfig,
type StylesheetTransformResult,
} from '@ecopages/core/processor';
class CustomProcessor extends Processor {
constructor(config: Omit<ProcessorConfig, 'name'>) {
super({
name: 'custom-processor',
capabilities: [
{
kind: 'stylesheet',
extensions: ['*.css'],
filter: /theme\.css$/u,
},
],
watch: {
paths: ['src/custom'],
extensions: ['.custom'],
},
...config,
});
}
override plugins(app: EcoPagesAppConfig): EcoBuildPlugin[] {
// Declare build plugins; core reads this once during config finalization
return [];
}
override async setup(): Promise<void> {
// Generation, cache warming and other runtime work
}
override async transformStylesheet(css: string, file: string): Promise<StylesheetTransformResult> {
return { css, dependencies: [] };
}
}
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.
Processor Lifecycle
Core owns the ordering. Processors participate in these phases:
plugins(app)— read once during config finalization to seal the app build manifest. It is the only hook that runs then, so it must not do I/O.setup()— runs after the manifest is sealed when preparing a development, build, preview, or type-generation runtime. Generate files, load configuration and warm caches here. Productionecopages startrestores emitted Processor state instead of callingsetup().transformStylesheet(css, file)— optional; transforms each stylesheet the processor'sstylesheetcapability matches: a.cssfile, or a file with an extension the capability declares. It returns{ css, dependencies, exports }, wheredependencieslists the absolute paths of the other files the transform read, such as the partials an@importinlined; core keeps the stylesheets among them. Take them from the transform itself (PostCSSdependencymessages, Lightning CSSdependencies): core rebuilds the stylesheet, and every module and Page that uses it, when one changes. An app can register only one processor with astylesheetcapability.exportsis for CSS Modules: a processor whosestylesheetcapability setscssModules: truereturns, for every*.module.cssfile, the scopedcssand a class map from each local class name to its scoped name followed by the names it composes, separated by spaces. Derive the scoped names from the path relative torootDir, so a build gives the same names in any folder. For a CSS Module that an installed package ships, hash the pathtoPackageScopeFile(file, rootDir)from@ecopages/core/processorreturns instead (it returnsundefinedfor an app file), which names the package and the path inside it, and run no app processing on it. WithoutcssModules: true, importing a CSS Module fails the build. A processor without it takes no part in stylesheet processing. In development core prewarms, caches, writes and refreshes the stylesheets; the processor only transforms.invalidateStylesheets(files)— optional; in development, receives the changed paths of a batch that are not stylesheets and returns the stylesheets they make stale, or'all'. Use it when the output depends on content, as Tailwind's does on the class names it scans, and reset any cached state there.- Watch batches — in development,
watch.onChanges({ created, changed, deleted, bridge })receives matching absolute paths grouped once per debounce window.extensionsselect notifications;capabilitiesdefine asset ownership. - Prewarm — a processor that owns pages to warm appends their pathnames to
this.context.config.devPrewarmPathsfromsetup(). Core renders that list in the background once the development server handles requests.
Production Runtime
ecopages build emits the server modules a Processor lists with listProductionServerModuleEntries(). Production ecopages start imports those emitted modules and calls restoreProductionRuntime() to restore in-memory state. It does not call setup() or scan the source tree. A Processor that needs generated data at runtime must emit the required modules during the build and restore its state from them.
listProductionServerModuleEntries() should return the generated source paths after setup() has created them. Core emits those entries and records their root-relative source paths in dist/server/server-modules.json. Inside restoreProductionRuntime(), map the source path to its emitted module and load that output; the source tree may not be deployed.
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileSystem } from '@ecopages/file-system';
import { loadProductionServerModules, missingProductionServerModuleError } from '@ecopages/core/build';
import { Processor } from '@ecopages/core/processor';
class GeneratedDataProcessor extends Processor {
private entries: string[] = [];
getEntries(): readonly string[] {
return this.entries;
}
private generatedModulePath(): string {
const cache = this.context?.cache;
if (!cache) throw new Error('Processor context is not set');
return path.join(cache, 'entries.mjs');
}
override async setup(): Promise<void> {
const entries = ['generated during setup'];
await fileSystem.writeAsync(
this.generatedModulePath(),
`export const entries = ${JSON.stringify(entries)};\n`,
);
this.entries = entries;
}
override listProductionServerModuleEntries(): string[] {
return [this.generatedModulePath()];
}
override async restoreProductionRuntime(): Promise<void> {
const context = this.context;
if (!context) throw new Error('Processor context is not set');
const sourceKey = path.relative(context.rootDir, this.generatedModulePath()).split(path.sep).join('/');
const manifest = loadProductionServerModules(context.config);
const emittedPath = manifest?.modules[sourceKey];
if (!manifest || !emittedPath) throw missingProductionServerModuleError(sourceKey);
const emittedFile = path.join(manifest.outdir, emittedPath);
const generatedModule = (await import(pathToFileURL(emittedFile).href)) as { entries: string[] };
this.entries = generatedModule.entries;
}
}
export const generatedDataProcessor = new GeneratedDataProcessor({ name: 'generated-data' });Keep source generation in setup() or in a build-plugin callback. restoreProductionRuntime() must restore from the emitted server modules without reading project sources or writing to the work directory. ecopages types runs Processor setup() to generate virtual-module declarations; it does not run the app entry file.
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. Stylesheet processing needs both a matching stylesheet capability and a transformStylesheet() method. Core treats .css and the extensions the stylesheet capability declares as stylesheets: import './card.scss' is discovered, kept out of server builds and served in development only when the capability declares *.scss. Config finalization fails when two processors declare a stylesheet capability.
super({
name: 'custom-processor',
capabilities: [
{
kind: 'stylesheet',
extensions: ['*.{css,scss,sass}'],
filter: /(?:^|\/)app\.(?:css|scss|sass)$/u,
},
],
});Supported kind values: script, stylesheet, image. Extension patterns support *, .css, *.css, and grouped forms such as *.{css,scss}.
filter is an optional regular expression tested against the full file path after the extension patterns match. Core clones it for each capability and removes the stateful g and y flags, so repeated asset checks do not depend on a previous match.
Declare the module types of your extensions
Core types import styles from './card.css' and ./card.module.css with the declarations it ships. A stylesheet extension you declare, such as *.scss, is typed by your processor: write the declaration from setup(), into the generated @types package TypeScript loads on its own. serializeStylesheetModuleDeclarations(capabilities) builds the text from the extensions your stylesheet capability declares: declare module '*.scss' for the CSS string and, when the capability sets cssModules: true, declare module '*.module.scss' for the class map. It is empty when you declare only .css, so nothing is written then. Core treats *.module.<ext> as a CSS Module under the same rule: only when the capability that declares <ext> sets cssModules: true. Otherwise card.module.scss is a plain stylesheet, typed as a string.
import { fileSystem } from '@ecopages/file-system';
import { path } from '@ecopages/file-system/path';
import {
Processor,
resolveGeneratedPath,
serializeStylesheetModuleDeclarations,
writeGeneratedTypesPackage,
} from '@ecopages/core/processor';
class SassProcessor extends Processor {
override async setup(): Promise<void> {
const declarations = serializeStylesheetModuleDeclarations(this.getAssetCapabilities());
if (!declarations || !this.context) return;
const write = (filePath: string, content: string) => {
fileSystem.ensureDir(path.dirname(filePath));
fileSystem.write(filePath, content);
};
writeGeneratedTypesPackage({
root: this.context.rootDir,
module: this.name,
packageName: `@types/ecopages-${this.name}`,
writeFile: write,
});
write(
resolveGeneratedPath('types', { root: this.context.rootDir, module: this.name, subPath: 'index.d.ts' }),
declarations,
);
}
}ecopages types runs setup(), so the declaration exists before the type checker runs, and the app writes no declare module '*.scss' of its own.
plugins(app)
plugins(app) returns the processor's build plugins. Each plugin picks its environments with environments: ['server'] or ['browser']; omitting them applies to both. Core calls it once while finalizing the config, so it takes the finalized app config as its argument and does nothing else:
override plugins(app: EcoPagesAppConfig): EcoBuildPlugin[] {
return [
{
name: 'custom-runtime-loader',
setup(build) {
build.onLoad({ filter: /\.custom$/ }, async (args) => ({
contents: await transformCustomFile(args.path),
loader: 'js',
}));
},
},
{
name: 'custom-browser-plugin',
environments: ['browser'],
setup(build) {
// Browser-only bundling behavior
},
},
];
}Work that needs the file system, such as generating modules or loading a config file, belongs in setup(). When a build can run before setup(), start that work lazily from the plugin's own callback and share one promise between callers, as the content processor does for its collection modules and PostCSS does for its plugin list.
Working with Cache
Processors have built-in caching capabilities. Each entry is a JSON file in the Processor's cache folder under the work directory, named after a digest of its key, so a key may contain any character:
class CustomProcessor extends Processor {
override async transformStylesheet(css: string, file: string): Promise<StylesheetTransformResult> {
const cacheKey = 'my-cache-key';
const cached = await this.readCache<string>(cacheKey);
if (cached) return { css: cached };
const result = await someExpensiveOperation(css);
await this.writeCache(cacheKey, result);
return { css: result };
}
}Adding Build Plugins
Processors contribute runtime-agnostic build plugins through EcoBuildPlugin, returned from plugins(app):
class CustomProcessor extends Processor {
override plugins(): EcoBuildPlugin[] {
return [
{
name: 'custom-build-plugin',
environments: ['browser'],
setup(build) {
build.onResolve({ filter: /\.custom$/ }, (args) => ({
path: args.path,
}));
},
},
{
name: 'custom-runtime-plugin',
setup(build) {
build.onLoad({ filter: /\.custom$/ }, async () => ({
contents: 'export default ""',
loader: 'js',
}));
},
},
];
}
}Plugins that declare environments: ['browser'] apply to browser bundles only. Plugins that omit environments apply to server and browser builds.
Virtual modules, loaders and watch files
build.module(specifier, callback)declares a virtual module. An import of exactlyspecifierresolves to it, andcallbackreturns theonLoadresult that provides its contents. The image Processor uses it forecopages:images. A virtual module path you return fromonResolveyourself starts with\0, so no other plugin reads it as a file.loaderin anonLoadresult (js,jsx,tsortsx) sets the languagecontentscompiles as. The PostCSS Processor returns{ contents: `export default ${JSON.stringify(css)};`, loader: 'js' }to hand a stylesheet's compiled CSS to the importer.onResolve,onLoadandtransformtake an optionalexcludepattern next tofilter. Rolldown tests both before your callback runs, against ids with/separators.build.addWatchFile(path), called inside anonLoadortransformcallback, declares a file the callback read, so an edit to it rebuilds the module in development and invalidates the caches that hold it.
Like onResolve, onLoad, and transform, call module() before setup returns. A later call throws.
File Watching
Configure file watching for development mode. Core calls onChanges once per debounce batch. Its created, changed, and deleted arrays contain the matching absolute paths for each event kind.
class CustomProcessor extends Processor {
constructor() {
super({
name: 'custom-processor',
watch: {
paths: ['src/custom'],
extensions: ['.custom'],
onChanges: async ({ bridge }) => {
bridge.reload();
},
},
});
}
}Using the Processor
Register your processor in eco.config.ts:
import { defineConfig } from '@ecopages/core/config';
import { ecopagesJsxPlugin } from '@ecopages/ecopages-jsx';
import { customProcessorPlugin } from './custom-processor';
export default defineConfig({
processors: [customProcessorPlugin()],
integrations: [ecopagesJsxPlugin()],
});Communicating with the Browser (IClientBridge)
Processors can broadcast events to connected browser clients for live updates using the bridge provided in watch callbacks. A stylesheet processor needs none of this: core rebuilds its stylesheets and refreshes their links.
import { Processor } from '@ecopages/core/processor';
class DataProcessor extends Processor {
constructor() {
super({
name: 'data-processor',
watch: {
paths: ['src/data'],
extensions: ['.json'],
onChanges: async ({ bridge }) => {
await this.regenerate();
bridge.reload();
},
},
});
}
override async setup(): Promise<void> {
await this.regenerate();
}
private async regenerate(): Promise<void> {
// Rebuild the modules generated from src/data
}
}The IClientBridge contract provides:
reload(): Triggers a full page reload.cssUpdate(sourceFile): Refreshes the stylesheet link delivered fromsourceFile, the source whose processed output changed.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
- Register
capabilitieswhen the processor owns asset kinds - Keep
plugins(app)free of I/O; do generation and config loading insetup()or lazily in the plugin's callbacks - 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