Creating Custom Integrations

Integrations in Ecopages add support for new templating engines or frameworks. A custom integration usually owns four things:

  1. which file extensions belong to the framework
  2. which renderer turns those files into HTML
  3. which build-time contributions must be registered before startup
  4. which runtime-only behavior is needed for HMR, browser bundles, or cross-integration boundaries

Core owns lifecycle ordering, route rendering, document-shell composition, and final HTML transformation. Integration plugins declare contributions; config finalization and app startup decide when those hooks run. A custom Integration can use createIntegrationRenderer() and supply only the optional behavior it needs. Its hook factory runs once for each renderer and can capture that renderer's app-owned services. Keep a renderer subclass only while a needed capability has no hook.

Minimal Integrations

For string-markup integrations that only need declarative config, use defineIntegration() with the renderer class produced by createIntegrationRenderer():

import { defineIntegration } from '@ecopages/core/integration';
import { createIntegrationRenderer } from '@ecopages/core/integration';
 
const CustomRenderer = createIntegrationRenderer('custom-integration');
 
export const customPlugin = defineIntegration({
	name: 'custom-integration',
	extensions: ['.custom'],
	renderer: CustomRenderer,
});

An extension ending in .api.ts, .api.tsx, .api.js or .api.jsx is reserved for endpoint files and cannot be registered; config finalization fails and names the Integration.

customPlugin() returns a configured integration instance. customPlugin.Plugin exposes the generated class when you need an explicit constructor.

For integrations with runtime services, HMR strategies, or build loaders, keep the plugin class below and use the same renderer factory unless custom renderer state requires a subclass.

Basic Structure

An integration extends IntegrationPlugin and provides a renderer class. Keep the constructor declarative: use it to describe ownership, not to perform runtime side effects.

import type { EcoPagesElement } from '@ecopages/core';
import { IntegrationPlugin, type IntegrationPluginConfig } from '@ecopages/core/integration';
import { CustomRenderer } from './custom-renderer.ts';
 
export class CustomIntegration extends IntegrationPlugin<EcoPagesElement> {
	renderer = CustomRenderer;
 
	constructor(options?: Omit<IntegrationPluginConfig, 'name' | 'extensions'>) {
		super({
			name: 'custom-integration',
			extensions: ['.custom'],
			...options,
		});
	}
}

Static pages are generated by executing each route's integration renderer directly during ecopages build.

Integration Lifecycle Contract

The base contract keeps configuration reads separate from runtime work:

  • plugins(app) returns build plugins and is read once during config finalization. Keep it free of I/O and state changes.
  • setup(ctx) receives { appConfig, runtime } after finalization. Call super.setup(ctx) before resolving Integration-specific runtime state.
  • createRenderer(base) receives the core renderer options and processed Integration dependencies. Override it to add framework-specific renderer options.
  • hmr(ctx) registers Integration-owned development behavior against ctx.runtime.hmr.
  • staticExport(ctx) may start export-wide work and return an async release function.
  • teardown() releases runtime resources when the app stops, restarts, or finishes a static export.

Pass browserRuntimeManifest as constructor data when the browser runtime needs specifier rewrites; it is read while the build manifest is sealed and is not a lifecycle hook.

Pages That Are Not JavaScript Modules

Core bundles an Integration's Page files and imports the result. An Integration whose files are not JavaScript, such as core's own HTML Pages, can compile them itself instead:

  • compilePageModule(filePath, appConfig) returns the module for a Page or html.* template file, or a promise of it: a default component, plus optional getMetadata or cache. Core calls it for every file of the Integration that it loads and never bundles those files itself, so throw for a file the Integration cannot compile.
    • Every Page of an Integration that implements it stays out of the production pages graph, so a static export compiles each one on its own.
    • Core caches the result by the file's content hash, so editing another file it reads does not invalidate it.
  • acceptsParams = false as a class field says its Pages cannot receive Params. Route discovery then rejects [param] and [...param] filenames the Integration owns, instead of failing at render time. It is not a constructor option, because it describes the Integration rather than the app.

TemplateRenderer and compileTemplate below are your own modules:

import type { EcoPageFile, EcoPagesAppConfig } from '@ecopages/core';
import { IntegrationPlugin } from '@ecopages/core/integration';
import { compileTemplate } from './compile-template.ts';
import { TemplateRenderer } from './template-renderer.ts';
 
export class TemplateIntegration extends IntegrationPlugin {
	renderer = TemplateRenderer;
	override readonly acceptsParams = false;
 
	constructor() {
		super({ name: 'templates', extensions: ['.tpl'] });
	}
 
	override async compilePageModule(filePath: string, appConfig: EcoPagesAppConfig): Promise<EcoPageFile> {
		return { default: await compileTemplate(filePath, appConfig) };
	}
}

Creating a Custom Renderer

createIntegrationRenderer(name, hooks) returns an IntegrationRenderer class without requiring a renderer subclass. Pass a hook object when its callbacks need no renderer-specific setup, or pass a factory that receives the renderer's app config, asset service, and dependency helpers. Core calls that factory once for each renderer instance, then supplies the route render() implementation, shared document shell, per-render session, dependency handling, and final document transform. A renderer instance can provide a second hook factory through its hooks option; that factory receives the generated hooks and its returned hooks override matching entries. Use beforeImportPageModule to prepare a runtime before a Page module loads, normalizePageModule to adapt the loaded module, and renderRoute to scope work around core's full route render. normalizeForeignChildProps transforms props before core queues a foreign child. Use resolveDependencies() when a hook contributes a custom component list to the Page Browser Graph; it includes Integration-wide assets. Use applyPendingLazyTriggers(assets, configs) to resolve this render session's lazy triggers from processed assets and component configs. resolveDependencyPath() applies core's path rules to a declared relative asset. Core resolves queued children and finalizes island host attributes after the hook returns. Use finalizeDocument only when an Integration must reconcile its document markup after core transforms the response.

import { createIntegrationRenderer } from '@ecopages/core/integration';
 
export const CustomRenderer = createIntegrationRenderer('custom-integration', {
	documentAttributes: () => ({ 'data-renderer': 'custom-integration' }),
});

Use a factory when hooks depend on app-owned renderer state:

export const CustomRenderer = createIntegrationRenderer('custom-integration', ({ appConfig }) => ({
	documentAttributes: () => ({ 'data-root': appConfig.rootDir }),
}));

The hook surface includes renderComponent, renderQueuedChildren, composeChildren, normalizePageModule, collectPageBrowserGraphContribution, documentContributions, documentAttributes, documentShellProps, buildIslandClientAssets, collectAppBrowserClientBuildEntries, beforeRender, transformDocumentHtml, finalizeDocument, and renderRemote. beforeRender receives route-resolved components from the page dependency resolver as pageDependencyComponents, so an Integration can prepare assets for components outside the page's declared component tree. documentShellProps can supply Integration-specific browser pageProps and top-level documentProps to the shared document shell. collectAppBrowserClientBuildEntries contributes raw Integration-owned client entries to the app-wide browser build during static production export. Omit hooks whose behavior core already supplies. renderComponent returns a ComponentRenderResult: the component's HTML and root attributes, without assets. Core collects a page's stylesheets and scripts once before rendering, from the Dependencies of every component the route declares and from the Page's dependencies. A component rendered without being declared contributes no assets, so a component's stylesheets and scripts belong in its Dependencies. renderQueuedChildren lets a renderer serialize or preserve structured children while core resolves the owning renderer.

The shared string renderer serializes string and markup children itself. A framework with structured child values can provide renderQueuedChildren; a framework with its own component result can provide renderComponent while keeping core's route pipeline.

Handling Dependencies

Use integrationDependencies for global framework assets that should apply everywhere the integration is active.

import { AssetFactory } from '@ecopages/core/integration';
 
class CustomIntegration extends IntegrationPlugin {
	renderer = CustomRenderer;
	private worker?: { stop(): Promise<void> };
 
	constructor() {
		super({
			name: 'custom-integration',
			extensions: ['.custom'],
			integrationDependencies: [
				AssetFactory.createFileScript({
					filepath: './runtime.js',
					position: 'head',
				}),
				AssetFactory.createFileStylesheet({
					filepath: './styles.css',
				}),
			],
		});
	}
}

Use plugins(app) for build-facing declarations, such as loaders or browser-only build plugins. Core reads it once while finalizing the config and seals the app manifest from it, so it must not do I/O. Use setup(ctx) for runtime work; core supplies { appConfig, runtime } after finalization. Watch-mode network startup attempts warmup for configured Integrations before releasing requests; setup failures are logged after all attempts settle and do not prevent startup. Request-only app.fetch() activates only the Integrations used by the requested render, including owners of foreign children.

import type { EcoPagesAppConfig } from '@ecopages/core';
import type {
	EcoBuildPlugin,
	IntegrationRuntimeContext,
} from '@ecopages/core/integration';
 
override plugins(app: EcoPagesAppConfig): EcoBuildPlugin[] {
	return [
		{
			name: 'custom-loader',
			setup(build) {
				// Server and browser builds
			},
		},
		{
			name: 'custom-browser-plugin',
			environments: ['browser'],
			setup(build) {
				// Browser bundles only
			},
		},
	];
}
 
override async setup(context: IntegrationRuntimeContext): Promise<void> {
	await super.setup(context);
	// Runtime side effects can use context.appConfig and context.runtime.
}

A plugin that declares environments: ['browser'] is excluded from server bundles and static page-module generation.

Cross-Integration Ownership

Core intercepts a Foreign Child, routes it to its owning Integration's shared renderer, and places the returned HTML. The default string path queues structured foreign children and resolves their nested tokens before the owning renderer renders the parent component. Supply renderQueuedChildren when your framework needs to preserve or render a queued child's children in its own representation:

import { createIntegrationRenderer } from '@ecopages/core/integration';
 
export const CustomRenderer = createIntegrationRenderer('custom-integration', {
	renderQueuedChildren: async (children) => (children === undefined ? {} : { children }),
});

Return { html } when the hook serialized the children, { children } to pass structured children to the owning renderer, or {} when there are no children. The Foreign Child's assets, like every component's, come from its declared Dependencies before the page renders.

When returning serialized { html }, use the supplied resolveToken callback for any nested Foreign Child tokens in that markup. Core replaces the queued subtree's outer token with the returned HTML, but it does not scan that HTML to resolve nested tokens automatically.

Hot Module Replacement (HMR)

To support HMR in a custom integration, implement an HmrStrategy and register it in the plugin.

Creating an HMR Strategy

The strategy determines how file changes are handled. It should identify matches and process the updates:

import {
	HmrStrategy,
	HmrStrategyType,
	type HmrAction,
} from '@ecopages/core/integration';
import type { DefaultHmrContext } from '@ecopages/core/integration';
 
class CustomHmrStrategy extends HmrStrategy {
	readonly type = HmrStrategyType.INTEGRATION;
 
	constructor(private context: DefaultHmrContext) {
		super();
	}
 
	matches(filePath: string): boolean {
		return filePath.endsWith('.custom');
	}
 
	async process(filePath: string): Promise<HmrAction> {
		const watchedFiles = this.context.getWatchedFiles();
		const outputUrl = watchedFiles.get(filePath) || filePath;
 
		return {
			type: 'broadcast',
			events: [
				{
					type: 'update',
					path: outputUrl,
					timestamp: Date.now(),
				},
			],
		};
	}
}

Registering the Strategy

Register your strategy from hmr(ctx) after core puts the HMR manager on the app runtime:

import type { IntegrationRuntimeContext } from '@ecopages/core/integration';
 
class CustomIntegration extends IntegrationPlugin {
	renderer = CustomRenderer;
 
	override hmr(context: IntegrationRuntimeContext): void {
		super.hmr(context);
		const manager = context.runtime.hmr;
		if (!manager) return;
		manager.registerStrategy(new CustomHmrStrategy(manager.getDefaultContext()));
	}
}

Declare plugins with environments: ['browser'] in plugins(app) when the integration needs browser-only transforms in client bundles (for example alias or rewrite plugins from @ecopages/core/build).

Setup and Teardown

Use setup() and teardown() to initialize and clean up integration resources:

import type { IntegrationRuntimeContext } from '@ecopages/core/integration';
 
class CustomIntegration extends IntegrationPlugin {
	renderer = CustomRenderer;
	private worker?: { stop(): Promise<void> };
 
	override async setup(context: IntegrationRuntimeContext): Promise<void> {
		await super.setup(context);
		this.worker = await startCompilerWorker(context.appConfig.rootDir);
	}
 
	override async teardown(): Promise<void> {
		await this.worker?.stop();
	}
}

Core calls teardown() for activated Integrations when the app stops or the development server restarts, after any in-progress setup() call and route prewarm render finish. During ecopages build, core calls it after static export releases resources, including when route discovery or generation fails. Keep setup() safe to run more than once. Override teardown() only when the integration owns watchers, compiler handles, workers, or other resources that outlive individual requests.

plugins(app) runs during config finalization, before setup(). Use that split consistently:

  1. plugins(app) for loaders, manifest-facing plugins, and other build declarations
  2. setup() for runtime side effects
  3. teardown() for explicit cleanup when the app stops, restarts, or finishes a static export

Static Export

Override staticExport(context) when ecopages build needs work that spans every page, such as starting a render worker or preloading modules. It runs before the first page renders and may return a function that undoes what it started:

class CustomIntegration extends IntegrationPlugin {
	renderer = CustomRenderer;
 
	override async staticExport(context: StaticExportContext): Promise<StaticExportRelease> {
		const worker = await startRenderWorker(context.baseUrl);
		return async () => {
			await worker.stop();
		};
	}
}

Core awaits every returned release, last started first, once the export ends, whether it succeeded or failed. It then calls teardown() for each activated Integration. A release that throws is logged and does not fail the export; a teardown() failure propagates and can fail ecopages build after generation succeeds.

Using the Integration

Register the integration in the Ecopages configuration:

import { defineConfig } from '@ecopages/core/config';
import { CustomIntegration } from './custom-integration';
 
const customIntegration = new CustomIntegration();
 
export default defineConfig({
	baseUrl: 'https://example.com',
	rootDir: import.meta.dirname,
	integrations: [customIntegration],
});

Config finalization, when createApp() loads eco.config.ts, is the app-owned boundary. This is where Ecopages derives paths, reads each plugins(app), and prepares the shared runtime state used later by startup and rendering.

Best Practices

  1. Keep the constructor declarative. Build declarations go in plugins(app), which must not do I/O. Runtime side effects go in setup().
  2. Keep the renderer focused on framework semantics. Core already owns route orchestration, dependency assembly, and marker-graph resolution.
  3. Declare plugins with environments: ['browser'] for browser-only transforms and runtime specifier aliasing in browser bundles.
  4. Fall back to full reload when a hot update is not safe. Correctness matters more than partial HMR.
  5. Document any required foreign-child ownership rules. Cross-integration rendering is the first place future readers will get lost.

Example: Complete Integration

Here's a complete example including the plugin, renderer, and HMR strategy:

import { defineConfig } from '@ecopages/core/config';
import { IntegrationPlugin } from '@ecopages/core/integration';
import { createIntegrationRenderer } from '@ecopages/core/integration';
import { HmrStrategy, HmrStrategyType, type HmrAction } from '@ecopages/core/integration';
import type { IntegrationRuntimeContext } from '@ecopages/core/integration';
import type { DefaultHmrContext } from '@ecopages/core/integration';
 
const CustomRenderer = createIntegrationRenderer('custom-integration');
 
class CustomHmrStrategy extends HmrStrategy {
	readonly type = HmrStrategyType.INTEGRATION;
 
	constructor(private context: DefaultHmrContext) {
		super();
	}
 
	matches(filePath: string) {
		return filePath.endsWith('.custom');
	}
 
	async process(filePath: string): Promise<HmrAction> {
		const outputUrl = this.context.getWatchedFiles().get(filePath) || filePath;
		return {
			type: 'broadcast',
			events: [{ type: 'update', path: outputUrl, timestamp: Date.now() }],
		};
	}
}
 
export class CustomIntegration extends IntegrationPlugin {
	renderer = CustomRenderer;
 
	constructor() {
		super({
			name: 'custom-integration',
			extensions: ['.custom'],
		});
	}
 
	override hmr(context: IntegrationRuntimeContext): void {
		super.hmr(context);
		const manager = context.runtime.hmr;
		if (!manager) return;
		manager.registerStrategy(new CustomHmrStrategy(manager.getDefaultContext()));
	}
}
 
export default defineConfig({
	baseUrl: 'https://example.com',
	rootDir: import.meta.dirname,
	integrations: [new CustomIntegration()],
});