Creating Custom Integrations
Integrations in Ecopages add support for new templating engines or frameworks. A custom integration usually owns four things:
- which file extensions belong to the framework
- which renderer turns those files into HTML
- which build-time contributions must be registered before startup
- which runtime-only behavior is needed for HMR, browser bundles, or cross-integration boundaries
Core still owns lifecycle ordering. Integration plugins declare contributions; ConfigBuilder.build() and app startup decide when those hooks run.
Minimal Integrations
For string-markup integrations that only need declarative config and a renderer class, use defineIntegration() instead of writing a plugin class by hand:
import { defineIntegration } from '@ecopages/core/plugins/define-integration';
import { StringMarkupRenderer } from '@ecopages/core/route-renderer/orchestration/string-markup-renderer';
class CustomRenderer extends StringMarkupRenderer {
name = 'custom-integration';
}
export const customPlugin = defineIntegration({
name: 'custom-integration',
extensions: ['.custom'],
renderer: CustomRenderer,
});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, continue using the class-based pattern below.
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/plugins/integration-plugin';
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.
Declaring Runtime Requirements
Declare runtime requirements in runtimeCapability so Ecopages can fail during config finalization instead of during startup.
class CustomIntegration extends IntegrationPlugin {
renderer = CustomRenderer;
constructor() {
super({
name: 'custom-integration',
extensions: ['.custom'],
runtimeCapability: {
tags: ['node-compatible'],
minRuntimeVersion: '20.0.0',
},
});
}
}Available tags:
node-compatiblerequires-node-builtinsbun-onlyrequires-native-bun-api
Use the smallest declaration that matches reality. For example, an integration that only needs standard Node-compatible APIs should not declare Bun-only requirements.
runtimeCapability is validated during ConfigBuilder.build(), before setup() runs. Integrations that depend on Bun-native APIs should declare that requirement explicitly.
Creating a Custom Renderer
The renderer handles framework-specific HTML generation. It extends IntegrationRenderer and implements render().
import { IntegrationRenderer } from '@ecopages/core/route-renderer/orchestration/integration-renderer';
import type { EcoComponent, IntegrationRendererRenderOptions, RouteRendererBody } from '@ecopages/core';
export class CustomRenderer extends IntegrationRenderer<string> {
name = 'custom-integration';
async render(options: IntegrationRendererRenderOptions): Promise<RouteRendererBody> {
const { Page, props, metadata, HtmlTemplate } = options;
const pageContent = await this.renderPage(Page, props);
return String(
await HtmlTemplate({
children: pageContent,
metadata,
pageProps: props ?? {},
}),
);
}
private async renderPage(Page: EcoComponent, props: Record<string, unknown> | undefined): Promise<string> {
return String(await Page(props ?? {}));
}
}IntegrationRenderer already owns shared orchestration such as dependency collection, page-module loading, marker-graph resolution, and document finalization. Custom renderers should focus on framework semantics rather than rebuilding core’s route pipeline.
HtmlTemplate receives children, metadata, and pageProps. Examples should include pageProps to match the current public contract.
Handling Dependencies
Use integrationDependencies for global framework assets that should apply everywhere the integration is active.
import { AssetFactory } from '@ecopages/core/services/asset-processing-service';
class CustomIntegration extends IntegrationPlugin {
renderer = CustomRenderer;
constructor() {
super({
name: 'custom-integration',
extensions: ['.custom'],
integrationDependencies: [
AssetFactory.createFileScript({
filepath: './runtime.js',
position: 'head',
}),
AssetFactory.createFileStylesheet({
filepath: './styles.css',
}),
],
});
}
}Use prepareBuildContributions() for build-facing declarations that must exist before the app manifest is sealed, such as optional loaders or browser-only build plugins. Use setup() only for runtime-only work.
override async prepareBuildContributions(): Promise<void> {
// Register build-facing plugins here.
}
override async setup(): Promise<void> {
await super.setup();
// Runtime-only side effects go here.
}Use browserBuildPlugins for browser-only bundling behavior. browserBuildPlugins are intentionally excluded from server bundles and static page-module generation.
Cross-Integration Ownership
Cross-integration ownership belongs in the renderer. When a renderer must keep control of its subtree even after another integration encounters it, implement renderer-owned foreign-child handling in the IntegrationRenderer subclass.
The runtime contract has three steps:
- Queue — Override
createForeignChildRuntime()for custom interception, or callthis.foreignSubtreeExecutionService.createQueuedRuntime(...)(the base class default). - Resolve — After local HTML is produced in
renderComponent, callthis.foreignSubtreeExecutionService.resolveQueuedHtml(...)(string-markup paths can useresolveStringQueuedHtml/renderStringComponentWithQueuedForeignSubtrees). - Own — Pass
getOwningRendererthat callsresolveOwningIntegrationRendererfrom@ecopages/core/route-renderer/orchestration/foreign-child/owning-renderer-resolution. The same module exportsgetForeignSubtreeTokenPrefixandgetForeignSubtreeResolutionContextKey.
Prefer extending StringMarkupRenderer when the integration is string-first — queue and resolve are already wired. Integrations such as Lit and React use the explicit resolve step because the owning renderer must re-enter during foreign-subtree resolution to produce the correct SSR output.
import {
getForeignSubtreeResolutionContextKey,
resolveOwningIntegrationRenderer,
} from '@ecopages/core/route-renderer/orchestration/foreign-child/owning-renderer-resolution';
const queued = await this.foreignSubtreeExecutionService.resolveQueuedHtml({
currentIntegrationName: this.name,
html,
runtimeContext: this.foreignSubtreeExecutionService.getQueuedRuntimeContext(
input,
getForeignSubtreeResolutionContextKey(this.name),
),
queueLabel: this.name,
getOwningRenderer: (integrationName, rendererCache) =>
resolveOwningIntegrationRenderer({
appConfig: this.appConfig,
runtimeOrigin: this.runtimeOrigin,
currentIntegrationName: this.name,
currentRenderer: this,
integrationName,
cache: rendererCache,
}),
applyAttributesToFirstElement: (resolvedHtml, attributes) =>
this.htmlTransformer.applyAttributesToFirstElement(resolvedHtml, attributes),
dedupeProcessedAssets: (assets) => this.htmlTransformer.dedupeProcessedAssets(assets),
renderQueuedChildren: (children, _ctx, byToken, resolveToken) =>
this.renderQueuedForeignSubtreeChildren(children, byToken, resolveToken),
});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/hmr/hmr-strategy';
import type { DefaultHmrContext } from '@ecopages/core';
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
Override getHmrStrategy() to return your custom strategy:
class CustomIntegration extends IntegrationPlugin {
renderer = CustomRenderer;
override getHmrStrategy() {
if (!this.hmrManager) {
return undefined;
}
return new CustomHmrStrategy(this.hmrManager.getDefaultContext());
}
}Use browserBuildPlugins when the integration needs browser-only transforms in client bundles (for example alias or rewrite plugins from @ecopages/core/build/browser-runtime-plugin or @ecopages/core/plugins/alias-resolver-plugin). Override setHmrManager() only when behavior beyond the shared strategy registration path is required.
Setup and Teardown
Use setup() and teardown() to initialize and clean up integration resources:
class CustomIntegration extends IntegrationPlugin {
renderer = CustomRenderer;
async setup(): Promise<void> {
await super.setup();
// Additional initialization logic
// e.g., Bun.plugin(customCompilerPlugin())
}
async teardown(): Promise<void> {
// Clean up resources when the process stops
}
}Core does not call teardown() by default. Override it only when the integration owns watchers, compiler handles, or other resources that outlive individual requests.
prepareBuildContributions() runs before setup(). Use that split consistently:
prepareBuildContributions()for loaders, manifest-facing plugins, and other build declarationssetup()for runtime-only side effectsteardown()for explicit cleanup when the process stops
Using the Integration
Register the integration in the Ecopages configuration:
import { ConfigBuilder } from '@ecopages/core/config-builder';
import { CustomIntegration } from './custom-integration';
const customIntegration = new CustomIntegration();
const config = await new ConfigBuilder()
.setBaseUrl('https://example.com')
.setRootDir(process.cwd())
.setIntegrations([customIntegration])
.build();ConfigBuilder.build() is the app-owned finalization boundary. This is where Ecopages validates runtime requirements, derives paths, collects build contributions, and prepares the shared runtime state used later by startup and rendering.
Best Practices
- Keep the constructor declarative. Build-facing work goes in
prepareBuildContributions(). Runtime side effects go insetup(). - Keep the renderer focused on framework semantics. Core already owns route orchestration, dependency assembly, and marker-graph resolution.
- Declare the smallest valid
runtimeCapability. Overstating requirements makes the integration unusable on compatible runtimes. - Use
browserBuildPluginsfor browser-only transforms and runtime specifier aliasing in browser bundles. - Fall back to full reload when a hot update is not safe. Correctness matters more than partial HMR.
- 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 { ConfigBuilder } from '@ecopages/core/config-builder';
import { IntegrationPlugin } from '@ecopages/core/plugins/integration-plugin';
import { IntegrationRenderer } from '@ecopages/core/route-renderer/orchestration/integration-renderer';
import { HmrStrategy, HmrStrategyType, type HmrAction } from '@ecopages/core/hmr/hmr-strategy';
import type {
DefaultHmrContext,
EcoComponent,
IntegrationRendererRenderOptions,
RouteRendererBody,
} from '@ecopages/core';
class CustomRenderer extends IntegrationRenderer<string> {
name = 'custom-integration';
async render(options: IntegrationRendererRenderOptions): Promise<RouteRendererBody> {
const { Page, props, metadata, HtmlTemplate } = options;
const pageContent = String(await Page(props ?? {}));
return String(await HtmlTemplate({ children: pageContent, metadata, pageProps: props ?? {} }));
}
}
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 getHmrStrategy() {
return this.hmrManager
? new CustomHmrStrategy(this.hmrManager.getDefaultContext())
: undefined;
}
}
const config = await new ConfigBuilder()
.setBaseUrl('https://example.com')
.setRootDir(process.cwd())
.setIntegrations([new CustomIntegration()])
.build();