Compiler
Add new discoverable concepts to Heximon — new handler types, transport hooks, or any class a module config lists — by writing a compiler plugin. Each plugin runs through five phases over the classes it discovers.
(For what the compiler reads and emits in general, see How the Compiler Works — this page assumes that and covers the plugin API: declaring what you discover, analyzing matched classes, contributing DI graph edges, validating, and emitting generated code.)
Extend CompilerPlugin
A plugin is a class that extends CompilerPlugin. Register it in the heximon.config.ts config file's plugins array; the runner provides it to every recompile.
import { CompilerPlugin } from "@heximon/compiler";
export class MyFeaturePlugin extends CompilerPlugin {
readonly name = "my-feature";
}
import { defineHeximonConfig } from "@heximon/build";
import { MyFeaturePlugin } from "my-feature/compiler";
export default defineHeximonConfig({ plugins: [new MyFeaturePlugin()] });
All phases are optional. The runner calls reset() before each pass so you can clear any per-compilation state.
Declare what you discover
discoveryKeys() tells the compiler which module-config sub-keys your plugin owns. When a module lists a class under one of those sub-keys, the compiler matches it and delivers it to your analyze hook.
import { CompilerPlugin, type DiscoveryKey } from "@heximon/compiler";
export class MyFeaturePlugin extends CompilerPlugin {
readonly name = "my-feature";
override discoveryKeys(): readonly DiscoveryKey[] {
return [
{
namespace: "myFeature",
key: "handlers",
category: "handler",
extends: [{ module: "@my/package", export: "MyHandler" }],
},
];
}
}
A module declaring myFeature: { handlers: [FeedHandler] } will have FeedHandler matched and passed to analyze as category "handler". Discovery originates exclusively from the module config — there is no whole-codebase scan.
The optional extends list walks each matched class's extends chain to confirm it reaches one of the bases. A class that doesn't reach a base emits the shared inert-provider warning and is dropped before your analyze runs, so you never handle a mismatch yourself. Omit extends to match every listed class unconditionally.
Analyze matched classes
analyze runs for every plugin before any contributeGraph call. Use it to read each matched class's extends arguments, method names, and constructor signature. Publish your results so later phases and other plugins can read them.
import type { MatchedClass, CompilerContext, PluginResults } from "@heximon/compiler";
interface AnalyzedHandler {
readonly reference: ClassReference;
readonly category: string;
}
const HANDLER_RESULTS = "my-feature:handlers" as ResultChannel<AnalyzedHandler[]>;
export class MyFeaturePlugin extends CompilerPlugin {
readonly name = "my-feature";
override analyze(
matches: MatchedClass[],
_context: CompilerContext,
results: PluginResults,
): void {
const handlers: AnalyzedHandler[] = matches.map((match) => ({
reference: match.reference,
category: match.category,
// match.extendsArguments — AST nodes for the `extends MyHandler(…)` arguments
// match.scope — the import scope for resolving references in the declaring file
// match.sourceText — the file's source, for re-emitting expressions verbatim
}));
results.publish(HANDLER_RESULTS, handlers);
}
}
ClassIdentity resolves re-exported classes to their canonical declaration. NameReserver produces collision-free aliases for generated import bindings when a name would otherwise clash with another generated binding.
Contribute to the DI graph
contributeGraph runs after all plugins finish analyze. Read your published results and add providers or dependency edges so the compiler includes your concept's instances in the generated wiring.
import type { DependencyGraph, PluginResults } from "@heximon/compiler";
export class MyFeaturePlugin extends CompilerPlugin {
readonly name = "my-feature";
override contributeGraph(graph: DependencyGraph, results: PluginResults): void {
const handlers = results.read(HANDLER_RESULTS) ?? [];
for (const handler of handlers) {
graph.addCoreProvider({
provide: { module: handler.reference.sourceFile, export: handler.reference.className },
useClass: { module: handler.reference.sourceFile, export: handler.reference.className },
});
}
}
}
Validate
Return diagnostics that surface to the user in the dev-server overlay or build output. Reading your analyze results directly from results means you don't need to stash them on an instance field to carry them across phases.
import { DiagnosticSeverity, type Diagnostic, type PluginResults } from "@heximon/compiler";
export class MyFeaturePlugin extends CompilerPlugin {
readonly name = "my-feature";
override validate(results: PluginResults): Diagnostic[] {
const handlers = results.read(HANDLER_RESULTS) ?? [];
return handlers
.filter((handler) => handler.category !== "handler")
.map((handler) => ({
severity: DiagnosticSeverity.Error,
message: `${handler.reference.className} must extend MyHandler(EventClass)`,
hint: "add the event class as the type argument.",
}));
}
}
Beyond severity and message, a diagnostic can carry a code (one of the framework's own stable
DiagnosticCode values — first-party plugins add their codes to that shared const, so a diagnostic's code
is optional and worth setting only when a consumer needs to branch on the diagnostic's kind).
It can also carry a span ({ sourceFile, start, end } source offsets — the renderer grows a codeframe
pointing at exactly that source) and a hint (the remediation, shown as the ╰─ help: trailer, kept out of
the message).
For an error that points at more than one place — a binding and where it was first declared — add
labels (each { span, message?, isPrimary? }); the primary label anchors the header and span, the rest
render as secondary underlines.
Anchoring an error at the offending node turns a vague message into one the user can act on without hunting for the line. See Compiler Diagnostics for how the rendered output reads.
Emit generated code
codegen returns the files to write into .heximon/. It receives the same PluginResults so you read your analysis directly.
import type { CompilerContext, CodegenOutput, PluginResults } from "@heximon/compiler";
export class MyFeaturePlugin extends CompilerPlugin {
readonly name = "my-feature";
override codegen(context: CompilerContext, results: PluginResults): CodegenOutput {
const handlers = results.read(HANDLER_RESULTS) ?? [];
const lines: string[] = [];
for (const handler of handlers) {
lines.push(`// register handler: ${handler.reference.className}`);
}
return {
files: [{ path: `${context.outputDirectory}/my-feature.js`, content: lines.join("\n") }],
};
}
}
Use ImportBinder (from @heximon/compiler) to accumulate import specifiers as you build the file, and flush them as a block of import statements at the top.
Share results across plugins
PluginResults carries typed, keyed values between any two plugins. Type your key constant as ResultChannel<T> so publish, read, contribute, and collect are all checked against T.
// One plugin writes; a second publish to the same key overwrites the first.
results.publish(MY_KEY, value);
// Any plugin in a later phase reads.
const value = results.read(MY_KEY);
// Extension-point pattern: many plugins append to one shared key.
results.contribute(SHARED_KEY, myEntry);
// The key's owner collects all contributions in arrival order.
const all = results.collect(SHARED_KEY);
Declare inter-plugin dependencies with requires so the runner constructs and sequences them for you. A required plugin that isn't in the user's list is default-constructed automatically; instances are deduped by class.
export class MyFeaturePlugin extends CompilerPlugin {
readonly name = "my-feature";
readonly requires = [HttpPlugin]; // auto-provided, ordered before this plugin
}
Library mode
When a Heximon module is precompiled as an npm package, the compiler emits a heximon.manifest.json carrying each plugin's per-package metadata.
Implement emitManifestSection in the package build and composeBoundary in the consuming app build to carry your concept's metadata across the boundary — one hook receives every composed boundary's section for your plugin at once, folds its facts into your own analysis, and returns any concepts the app should re-discover.
Implement emitContributions and contributionRegistrar to deliver the package's live instances to the app's registries at runtime.
See Library mode for the full package setup, including how the heximonLibrary() Vite plugin orchestrates the emit.
See also
- How the Compiler Works — the build-step framing, the class-in/JS-out proof, and the editor feedback loop this page builds on.
- Library mode — precompiling a module as an npm package bearing a cross-package manifest.
- Packages overview — where
@heximon/compilersits in the layer diagram. - Modules — how a module's config drives the DI graph the compiler emits.
How the Compiler Works
Why Heximon compiles a static DI graph ahead of time instead of resolving it at boot, what the compiler reads from your source, what it emits into .heximon/, and the verdicts.gen.d.ts feedback loop that surfaces DI mistakes in your editor.
Compiler Diagnostics
How a Heximon build error reads — the severity header, the area/slug code, the source codeframe, the help trailer, the dev-server overlay, and the compact CI form.