The build system
This page is the deep-dive companion to section 10 of the Architecture overview.
It walks through both layers of the build system: the generic action/artifact engine in
src/solution-services/build-system-core/, which names zero TODL types, and its
TODL-coupled realization in src/solution-services/todl-build-system/, which ships two
build systems — npm-package (publishable package layout) and html-bundle (a runnable
single-page app). Every type and file path below is taken directly from the real code in
the TODL package (@pragmatic-tech-ai/todl) — treat this as a map, not a paraphrase.
The generic engine: build-system-core
A build is a pipeline of small, typed steps run against a project. build-system-core
defines that shape once, generically, so it can be reused for outputs that have nothing
to do with TODL’s own concepts — it never imports a compiler type, a manifest type, or
anything else that names a .todl construct. A host binds the generics to its own
types; todl-build-system is that binding for TODL.
The unit of work: IBuildAction
Everything a build system does is a list of actions implementing one interface,
src/solution-services/build-system-core/build-action.ts:
export interface IBuildAction<C extends CoreBuildContext = CoreBuildContext>
{
readonly Name: string;
readonly Consumes: readonly ArtifactKey<unknown>[];
readonly Produces: readonly ArtifactKey<unknown>[];
Execute(ctx: C): Promise<void>;
}
An action declares, up front and statically, what typed artifacts it reads
(Consumes) and what it writes (Produces); Execute does the actual work against a
context C. File I/O inside Execute stays imperative — the interface does not try to
model reads and writes of arbitrary project files, only the small set of hot values
actions pass to each other.
The hot-value bag: ArtifactKey and BuildArtifacts
ArtifactKey<T> (artifact-key.ts) is a typed, identity-based key, modeled on the
service-key pattern used elsewhere in the codebase: identity is the object itself, not
its description string, so two keys that happen to share a description are still
distinct slots. BuildArtifacts (build-artifacts.ts) is the map keyed by those
objects:
export class BuildArtifacts
{
private readonly values = new Map<ArtifactKey<unknown>, unknown>();
public Set<T>(key: ArtifactKey<T>, value: T): void { this.values.set(key, value); }
public Get<T>(key: ArtifactKey<T>): T | undefined { return this.values.get(key) as T | undefined; }
}
The design intent is explicit in the source comment: an action must be correct reading
only files from Project/Sandbox; the artifact bag is an optimization that lets a
downstream action skip re-reading and re-parsing a file a prior action already produced
in memory — never the sole channel of truth. CompiledModel (a CompiledPackage) is
the clearest example: the compiler already holds it in memory after CompileModelAction
runs, so later actions read it straight off the bag instead of re-parsing model.json
off disk.
The context: CoreBuildContext, and the Project-versus-Sandbox convention
CoreBuildContext is what every action’s Execute receives:
export interface CoreBuildContext
{
readonly Project: IStorage;
readonly Sandbox: IStorage;
readonly Artifacts: BuildArtifacts;
readonly Options: BuildOptions;
readonly Diagnostics: DiagnosticSink;
}
Two storages, not one, is the load-bearing design decision here. Project is the
project’s own directory — persistent, under source control, exactly what the developer
sees in their editor. Sandbox is a scratch directory provisioned fresh for this one
build and discarded afterward (or promoted, see below). The convention the whole build
system follows is: an action that writes into Project is a persistent content
generator — its output is meant to sit alongside the hand-authored .todl, be
diffed, committed, and optionally hand-edited; an action that writes into Sandbox
is staging output for this build only, promoted to the final output directory
exclusively when every action in the pipeline succeeds. The html-bundle target below
splits cleanly along this line: the four files a developer owns or imports
(src/app.mu, src/main.ts, generated/model.ts, generated/data.ts) are written into
Project by the project content generators, never by a build;
everything the build itself produces — the fixed entry.ts glue, the compiled .mu.js,
the bundled script, the final index.html — is written into Sandbox.
The registry: consume-before-produce at registration time
BuildSystemRegistry<C, TProject> (build-system-registry.ts) holds the set of build
systems a host has assembled and enforces one guarantee at Register time, before any
build ever runs: every action’s Consumes keys must already have been Producesd by
an earlier action in the same flavor’s list.
private static Validate<C extends CoreBuildContext, TProject>(system: IBuildSystem<C, TProject>): void
{
for (const flavor of system.Flavors())
{
const produced = new Set<ArtifactKey<unknown>>();
for (const action of flavor.Actions())
{
for (const key of action.Consumes)
{
if (!produced.has(key))
{
throw new Error(
`invalid build system "${system.Id}": action "${action.Name}" consumes ` +
`${key.Description} before any earlier action produces it`,
);
}
}
for (const key of action.Produces) produced.add(key);
}
}
}
This is deliberately a static, structural check over the declared Consumes/Produces
arrays — it never executes an action to find out. The payoff is that a pipeline-ordering
bug (someone reorders two actions, or forgets to append a new action to the end of the
list) fails the moment the build system is registered — typically at process startup, or
in a unit test that constructs the registry — rather than three actions deep into a real
build, with a half-populated sandbox and a confusing “undefined” artifact to debug. It
is the build system’s equivalent of a compile-time type error for pipeline shape.
Build flavors: the selectable output variant
A build flavor is the unit the consume-before-produce check above iterates over, and it is worth understanding on its own because it is what a build request actually selects.
The design starts from a question: a single build system applies to a project type (via
AppliesTo), but should it be able to produce that project in more than one shape? A
library might build a normal publishable package, or a debug variant, or a
documentation-only layout. Rather than force each variant to be its own top-level build
system, the system exposes a list of flavors, and the caller picks one. A flavor owns
exactly the two things that used to live directly on the system: the name of the output
directory it writes to, and the ordered action pipeline that fills it.
BuildFlavor<C> (build-flavor.ts) is that abstraction:
export interface BuildFlavor<C extends CoreBuildContext>
{
readonly Id: string;
readonly DisplayName: string;
readonly OutputName: string;
Actions(): readonly IBuildAction<C>[];
}
Idis the stable selector a build request names to choose this flavor.DisplayNameis the human label (for a build-target picker in a UI).OutputNameis the output directory the manager opens and promotes the sandbox into — so two flavors of one system land in two different output directories and never clobber each other.Actions()is the ordered pipeline itself, the same list the registry validates and the manager runs.
IBuildSystem<C, TProject> exposes them through a single method:
Flavors(): readonly BuildFlavor<C>[];
StaticBuildFlavor: the fixed-pipeline common case
Most systems know their pipeline at construction time — they do not need to compute the
action list dynamically. For them, StaticBuildFlavor<C> is a ready-made implementation
that simply holds the four values and returns the action list it was given:
export class StaticBuildFlavor<C extends CoreBuildContext> implements BuildFlavor<C>
{
constructor(
public readonly Id: string,
public readonly DisplayName: string,
public readonly OutputName: string,
private readonly actions: readonly IBuildAction<C>[],
) {}
public Actions(): readonly IBuildAction<C>[]
{
return this.actions;
}
}
A system with a single output wraps its one pipeline in a single StaticBuildFlavor. The
BuildFlavor interface is nonetheless the seam that keeps the door open for a flavor that
builds its action list on the fly, without changing anything that consumes flavors.
How a flavor is selected, validated, and run
Three consumers close the loop around a flavor, and they map one-to-one onto the three core types already described:
- Selection. A
ProjectBuildRequestcarries an optionalBuildFlavorId.BuildSystemRegistry.SelectFlavor(system, flavorId?)resolves it: the flavor whoseIdmatches, or — when no id is given — the system’s first flavor. That default is a deliberate backward-compatibility affordance for callers that name only a build system and expect its single flavor. It returnsundefinedwhen the id matches nothing (or the system exposes no flavors), andProjectBuildManager.Buildturns that into anunknown build flavor:error rather than silently building the wrong thing. - Validation. The registry’s consume-before-produce check runs per flavor, not
per system — each flavor’s action list is validated independently at
Registertime, because each is an independently runnable pipeline. - Execution.
ProjectBuildManager.Run(flavor, request)reads exactly two things off the flavor: it callsstorage.OpenOutput(flavor.OutputName, options)to open the right output directory, andflavor.Actions()to get the pipeline it sequences, checkpoints, and promotes. The manager never looks at the system again once the flavor is chosen — the flavor is the complete description of what to build and where to put it.
Where flavors stand today
The multi-flavor capability is no longer just a seam: NpmPackageBuildSystem exposes
two flavors — npm-package (the seven-action pipeline above, a complete package) and
npm-publish (the same seven actions plus a terminal PublishPackageAction) — while
HtmlBundleBuildSystem exposes a single html-bundle flavor. Each flavor carries its
own selector Id — npm-package, npm-publish, and html-bundle respectively — while
the two npm flavors deliberately share one OutputName (npm-package), since publishing
stages the same layout and only adds a push step on top of it. The
build-flavor.test.ts suite pins the StaticBuildFlavor value contract, that
NpmPackageBuildSystem exposes two non-empty-pipeline flavors, and that html-bundle
exposes one. The type and StaticBuildFlavor were introduced in commit a623b50
(“feat(build): add BuildFlavor + StaticBuildFlavor, expose Flavors() on systems”) as part
of the build-system spec, which generalized the earlier design where a system carried its
output name and pipeline directly.
The manager: sequencing, diagnostics, and promotion
ProjectBuildManager<C, T> (project-build-manager.ts) is where a build actually runs.
Build(request) looks up the requested system and flavor by id, checks AppliesTo, and
calls Run. Run does the following, in order:
- Provisions a fresh sandbox (
storage.CreateSandbox()) and opens the output location (storage.OpenOutput(...)), both through the injectedIBuildStorageProvider. - Builds the host context via the injected
BuildContextFactory<C, T>— this is howCoreBuildContext’s five fields becomeTodlBuildContext’s seven withoutbuild-system-coreever importing a TODL type. - Runs the flavor’s actions sequentially. Before each action it checkpoints
diagnostics.Count; after it returns (or throws — a thrown error is caught and reported as an error diagnostic under the action’s name, so a misbehaving action can never escape as an uncaught exception) it checksdiagnostics.HasErrorsSince(checkpoint). Any error reported during that action’s window marks itFailedand stops the pipeline — every remaining action is recorded asSkippedrather than run. - Only if every action succeeded does it call
StorageTree.CopyAll(sandbox, output.Storage)— the sandbox-to-output promotion. A partially-succeeded build promotes nothing; the caller gets aFailedresult and the sandbox is deleted either way. - Writes
report.jsoninto the output directory unconditionally — on success or failure — with the per-action outcomes, the full diagnostic list, the artifact file list, and the duration.
The upshot: a build either lands a complete, consistent output directory, or it lands
nothing at build-output level (just the report), never a half-written mix of old and
new files. DiagnosticSink (diagnostic-sink.ts) is the small accumulator behind all
of this — Report, a running Count, and HasErrorsSince(checkpoint) — reusing the
compiler’s Severity enum but defining its own lightweight BuildDiagnostic shape
(severity, message, an optional source) rather than the compiler’s span-carrying
Diagnostic, since build-level problems (“action failed”, “unresolved base”) have no
source span to attach.
The TODL-coupled realization: todl-build-system
src/solution-services/todl-build-system/ is where the engine gets its TODL teeth.
TodlBuildContext (todl-build-context.ts) is the host’s binding of C:
export interface TodlBuildContext extends CoreBuildContext
{
readonly Manifest: ProjectManifest;
readonly Source: IPackageSource;
}
Manifest is the project’s project.plexus (base bindings, id, type); Source is the
composite package-resolution chain (§8 of the overview) that turns a base binding into an
actual compiled document. TodlBuildSystemRegistry (todl-build-system-registry.ts)
is a BuildSystemRegistry<TodlBuildContext, ProjectManifest> that registers both build
systems in its constructor, so a host resolves one populated registry rather than
hand-assembling it:
export class TodlBuildSystemRegistry extends BuildSystemRegistry<TodlBuildContext, ProjectManifest>
{
constructor()
{
super();
this.Register(new NpmPackageBuildSystem());
this.Register(new HtmlBundleBuildSystem(new EsbuildBundler()));
}
}
The new EsbuildBundler() handed to HtmlBundleBuildSystem is the node-only default — the
right choice for this headless, in-process registry. But the dependency is an interface,
IBundler, not a concrete class, and that one seam is what lets the same build system run in a
browser renderer with the actual bundling pushed to another process. That story is told in full in
the html-bundle section below and in Plexus’s
process-agnostic build deep-dive.
Two artifact-key classes carry the hot values each system’s actions pass around:
NpmArtifacts (ResolvedBases: TodlDocument[], CompiledModel: CompiledPackage,
CompiledMural: readonly string[]) is shared by both systems, since both start the
same way; HtmlArtifacts (CompiledUi, AppEntry, AppBundle — ArtifactKey<string>
or ArtifactKey<readonly string[]>) is html-bundle’s own.
npm-package: the publishable package layout
NpmPackageBuildSystem (npm/npm-package-build-system.ts) applies to any publishable
project — MetaModel, Library, or Architecture (each carries a graph fragment worth
packaging, even though only meta-models and libraries are meant to be depended on).
Its constructor takes a required IPresentationBaker — no longer optional. The
composer always supplies one (TODL’s own DefaultPresentationBaker, wrapped in a
ProviderPresentationBaker so a host can still override it at bake time — see below),
so both the headless and hosted pipelines bake the same way. The pipeline is seven
actions:
constructor(baker: IPresentationBaker)
{
this.actions = [
new ResolveBasesAction(),
new CompileModelAction(),
new CompileMuralAction(),
new StampResourceKeysAction(),
new BakeResourcesAction(baker),
new EmitBundleAction(),
new EmitPackageLayoutAction(),
];
}
1. ResolveBasesAction reads the manifest’s metaModels/libraries/architectures
bindings and runs RecursiveProjectReferencesResolver.Resolve(ctx.Source, bindings)
(the same base-closure resolver behind Projects and solutions),
reporting each unresolvable binding as an error diagnostic rather than throwing — which
is what stops the pipeline on a missing dependency.
2. CompileModelAction collects the project’s .todl sources
(TodlProjectSourceFiles.Collect(ctx.Project)), builds a PackageIdentity from the
manifest, and calls the pure compilePackage(bases, sources, identity, dependencyRefs)
from src/publish/; a failing compile reports the compiler’s own diagnostics and
produces no CompiledModel artifact, which — by the consume-before-produce contract —
means every later action’s Consumes check is unsatisfied and the pipeline has already
stopped.
3. CompileMuralAction reuses the same shared MuralCompiler html-bundle’s own
action wraps, compiling every .mu file under the project — hand-authored or
generator-produced, this action doesn’t distinguish — to compiled/*.mu.js in the
sandbox. A project with no .mu files at all yields an empty CompiledMural
artifact and no diagnostic: nothing to compile is not an error.
4. StampResourceKeysAction writes a resource key onto every own annotation
application that inherits (transitively) from the prelude’s MuralResource
annotation, mutating the shared CompiledPackage.document in place so the stamped
keys reach model.json when it is later serialized. It is gated on
PresentationResourceEmitter.DeclaresResources(document, fullDocument): a project with
no MuralResource-derived annotation applications has nothing to stamp, and the action
is a no-op.
5. BakeResourcesAction bakes presentation/presentation.compiled.json and
presentation/icon-index.json through the constructor-injected IPresentationBaker.
A baker is always present, so the gate is just two conditions: the project
DeclaresResources, and the project is a MetaModel or Library (OptionsFor has no
bake options for any other project type, so an Architecture project never bakes even if
it declares resources). Either failing is a clean skip, not a failure. The default
baker (DefaultPresentationBaker, now living in TODL alongside PresentationBake)
reads icons out of the project, resolves them through mural’s include resolver, and
writes the two files — so the bake runs identically in the headless todl pipeline and in
a host. The baker reaches the action through ProviderPresentationBaker, which resolves
PresentationBakerKey at bake time, so a host that registers its own baker under that
key after composition still overrides the default. A referenced icon with no readable
project file is reported as an error and stops the pipeline before promotion.
6. EmitBundleAction emits bundle.json into the sandbox — the load-bearing
index a host’s meta-model browser reads to discover and mount a published package. It
runs only for a producer project (MetaModel or Library); an Architecture project
carries a graph fragment, not an instantiable palette, so DiscriminatorFor returns
undefined and the action is a clean no-op. The .type discriminator it writes stays
exactly 'meta-model' or 'library' — the strings the browser keys on. The action
scans the project for each palette class’s template/thumbnail/doc plus the
asset/doc/sample listings (ProducerResources.Scan), folds them onto the classes, and
writes the assembled PackageBundle; an orphan resource file naming no known class is a
non-blocking warning, not a failure. This ports the bundle-building block out of the
retired ProducerProjectFactory.publish() path, so the npm-package flavor’s output is
now complete without any factory publish step.
7. EmitPackageLayoutAction is the terminal action, writing everything into the
sandbox: package.json (via toPackageJson(manifest), which pins every base as an
exact scoped npm dependency), model.json (the compiled package’s own-nodes-only
document — now carrying any resource keys action 4 stamped onto it — plus its recorded
dependencies), the raw .todl text under src/, a browser-safe handle module
(index.js/index.d.ts — the compiled model.json inlined as an ES module export, so
importing the published package yields its document with zero I/O), and every
non-.todl, non-.mu, non-manifest project file packed verbatim under resources/
(excluding dist/, node_modules/, and .git/). Raw .mu is excluded from
resources/ alongside raw .todl: action 3 already compiled it into
compiled/*.mu.js, so shipping the source too would double-ship the same view.
Presentation resources, resource keys, bundle.json, and compiled .mu are therefore
npm-package build artifacts: the build produces them when a project declares resources
or ships .mu, it does not require them to exist beforehand, and the former user-driven
regeneratePresentation project-factory capability that used to refresh them by hand is
gone. See Project content generators for how this line sits
next to the two files that are generator-owned.
npm-publish: publishing as a terminal build action
Publishing is no longer a factory.publish() call standing outside the build — it is the
npm-publish flavor, the same seven actions plus one terminal PublishPackageAction
(npm/publish-package-action.ts). That action tars the staged sandbox layout through
StoragePackagePacker.Pack(ctx.Sandbox) and pushes it to the registry threaded onto the
context as ctx.PublishRegistry. The pack path is IStorage-based (web streams), so it
is browser-safe: a publish build runs headlessly, in a CLI, or in a renderer alike.
The registry is not a ServiceKey seam. It rides the build request as
PublishRegistry, which TodlProjectBuildManager (and SolutionBuildManager) copy onto
TodlBuildContext.PublishRegistry only when present. A host sets it from
SolutionManagerService.PublishRegistry — the solution’s configured registry connection.
A publish build for a solution with no registry associated fails fast: PublishPackageAction
reports “no registry associated with this solution” as an error diagnostic and writes
nothing, so a mis-wired publish never half-lands. And because only the npm-publish
flavor appends this action, the plain npm-package flavor never touches
ctx.PublishRegistry at all — building a package and publishing it are now the same
pipeline stopped one action apart.
html-bundle: the per-project application compiler
HtmlBundleBuildSystem (html-bundle/html-bundle-build-system.ts) applies only to
Architecture projects and produces something categorically different from
npm-package’s output: not a package another project depends on, but a runnable,
self-contained index.html a browser can open directly.
An architecture project is an editable src/ application, not a frozen view. Its UI
(src/app.mu), its paired view-model (src/main.ts), the typed DTO (generated/model.ts),
and the data bridge (generated/data.ts) are all written into the project by the
project content generators — src/ once, at creation, then yours
to edit; generated/ regenerated on every model-shape change — independent of any build.
This pipeline creates none of them; it requires all four. HtmlBundleBuildSystem declares
that requirement declaratively, on its flavor:
private static readonly RequiredContent: readonly RequiredContent[] = [
{ Path: "generated/model.ts", GeneratorId: "model-dto" },
{ Path: "generated/data.ts", GeneratorId: "model-data" },
{ Path: "src/main.ts", GeneratorId: "app-view-model" },
{ Path: "src/app.mu", GeneratorId: "app-ui" },
];
ProjectBuildManager.CheckRequirements walks that list before provisioning a sandbox or
running a single action — before anything else happens — and fails the build with an
error naming each missing path plus the generator that owns it, rather than fabricating
a placeholder or silently proceeding. This is the “require, never create” boundary: a
project that has never had its generators run (or whose files were deleted) fails a build
with a clear, actionable message instead of a confusing failure several actions deep, or a
build action quietly recreating content the generators are supposed to own.
The build system’s single dependency on anything node-specific is injected, not hard-wired:
its constructor takes an IBundler.
constructor(bundler: IBundler)
{
this.actions = [
new ResolveBasesAction(),
new CompileModelAction(),
new EmitEntryAction(),
new CompileMuralAction(),
new BundleAppAction(bundler),
new EmitBundledHostAction(),
];
}
IBundler (build-system-core/bundler.ts) is a one-method interface —
BundleApp(request): Promise<BundleAppResult> — whose request and result are plain,
serializable { Entry, Files: { Path, Text }[] } / { Text?, Diagnostics[] } shapes. That
is the whole reason the request/result carry no handles: they are designed to survive a trip
across a process boundary. HtmlBundleBuildSystem.Register(container) wires the class to
resolve BundlerKey from DI, so a host chooses the bundler. The headless registry passes the
node-only EsbuildBundler; Plexus’s renderer passes an IpcBundler that forwards the request
to the main process — same six actions, same build system, the one node-only step pushed
elsewhere. See the Plexus
process-agnostic build deep-dive.
1. ResolveBasesAction and 2. CompileModelAction are exactly the two shared
actions described above — the same classes, imported from npm/. What matters for
everything downstream is which of CompiledPackage’s two documents html-bundle reads:
not .document (own nodes only, base references left dangling — what npm-package
writes as model.json), but .fullDocument, the full transitive closure. A runnable
app has no base packages to resolve at load time, so it needs the whole graph, not a
package fragment.
3. EmitEntryAction (html-bundle/emit-entry-action.ts) writes entry.ts — into the
sandbox root, not the project — by filling a small template ({App} substituted):
import { app } from "./src/app.mu.js";
import { {App} } from "./src/main.js";
import { model } from "./generated/data.js";
import { TodlAppBootstrap } from "@pragmatic-tech-ai/todl";
new {App}();
TodlAppBootstrap.Mount(app, model);
{App} is AppNaming.AppClass(manifest.id ?? manifest.name) — the same view-model class
AppViewModelGenerator already wrote into src/main.ts. entry.ts is fixed build glue: it
depends only on the manifest’s id or name, is never hand-edited, and so lives in ctx.Sandbox,
regenerated fresh every build, rather than in ctx.Project beside the generator-owned files.
The line order here is load-bearing, and is the single subtlest thing in the whole target.
import { app } is first because evaluating the compiled app.mu.js is what constructs the
mural Application and sets Application.current. Only after that does new {App}() run the
view-model’s constructor — the one that registers the instance into Application.current.Services
so the markup’s $service({App}) binding can resolve it. Reverse those two, and the view-model
would self-register against an Application that does not exist yet, leaving $service empty and
the page blank. (The view-model class is only defined in src/main.ts, never instantiated
there, precisely so this entry controls when the one instance is created.) The full boot sequence
and the two blank-page failure modes it guards against are traced in
The runnable app.
4. CompileMuralAction (html-bundle/compile-mural-action.ts) compiles the project’s
.mu into JavaScript, writing into the sandbox — compiled JS is build output, never
something a developer edits. It delegates to the shared MuralCompiler
(todl-build-system/mural/mural-compiler.ts), the same class npm-package uses, but hands
it MuralOutputLayout.Sibling:
const written = await new MuralCompiler(MuralOutputLayout.Sibling).Compile(ctx);
MuralOutputLayout has two members. The default, CompiledBasename, flattens every source
to compiled/<basename>.mu.js (what npm-package wants for its package layout). Sibling
instead keeps each source’s own path and just appends .js: src/app.mu compiles to
src/app.mu.js, src/widgets/card.mu to src/widgets/card.mu.js. The html-bundle target
needs Sibling because its entry point imports the app root by its real project path
(import { app } from "./src/app.mu.js"), and because a developer’s hand-added .mu files
can sit in nested folders whose structure must survive into the bundle. The compiler walks
every .mu under the project (hand-authored and the required src/app.mu alike — downstream
stages are provenance-blind), excluding build output (dist/) and presentation sources
(presentation.generated.mu, anything under a top-level presentation/). It pre-scans for
output-path collisions and reports any as an error that stops the pipeline before a byte is
written; any mural ParseError/EmitError is likewise reported by source file name and
stops the pipeline. On any failure it records no CompiledUi artifact at all, so the
consume-before-produce contract halts the bundle.
5. BundleAppAction (html-bundle/bundle-app-action.ts) turns the staged modules into
one self-executing script — but it no longer does the bundling itself. Its job is now to
assemble the input and delegate to the injected IBundler. It gathers the staged file
set — the project’s own src/ and generated/ trees (read from ctx.Project, so a
developer’s hand-added .ts/.mu files come along), plus the compiled .mu.js modules and
the entry.ts glue from ctx.Sandbox — with the sandbox winning any path collision, so a
compiled src/app.mu.js is never shadowed by the src/app.mu source beside it. Before
delegating it hard-requires src/app.mu.js among HtmlArtifacts.CompiledUi as the app
root (the known path the entry imports), erroring out with a clear message if it is absent.
Then:
const result = await this.bundler.BundleApp({ Entry: entry, Files: files });
Diagnostics from the bundler are reported through ctx.Diagnostics; on success the finished
script lands in HtmlArtifacts.AppBundle. Any thrown error is caught and reported as an
error diagnostic (honoring the no-throw action contract) rather than escaping the pipeline.
Everything esbuild-specific now lives behind the seam, in the default IBundler,
EsbuildBundler (html-bundle/node/esbuild-bundler.ts). It is the node-only half: it
materializes the staged Files into a real temp directory created inside the nearest
node_modules-bearing root (so esbuild’s normal upward resolution finds the real
node_modules and @pragmatic-tech-ai/todl resolves by self-reference), then runs esbuild
with format: "iife", platform: "browser", target: "es2020", and keepNames: true
(not cosmetic — mural’s internal lookups are name-keyed, so renaming bindings would break
resolution at runtime). It carries two further subtleties worth knowing:
- A
todl-single-muraldedup plugin: anonResolvehook that anchors every@pragmatic-tech-ai/muralspecifier to the consumer’s one hoisted copy. Without it a nested second mural copy in the module graph yields twoApplication.currentcells, and the theme manager throws — a failure that once shipped as a blank page. - A
development-vs-defaultcondition probe: it checks whether todl’ssrcis present and honors thedevelopmentexport condition (raw TypeScript) when it is, falling back to the builtdistotherwise — so the same bundler works against an in-repo checkout and an installed package alike.
Failures become Severity.Error diagnostics, never exceptions. Because this entire class is
isolated behind IBundler, a browser host substitutes an IpcBundler that ships the plain
BundleAppRequest to another process and runs EsbuildBundler there — see the Plexus
process-agnostic build deep-dive.
6. EmitBundledHostAction (html-bundle/emit-bundled-host-action.ts) is the final
step, writing into the sandbox. It reads NpmArtifacts.CompiledModel.fullDocument
and HtmlArtifacts.AppBundle and calls HtmlShell.Render(JSON.stringify(payload),
appBundle). HtmlShell (html-bundle/html-shell.ts) is a small, string-constant-only
class that emits a page with a todl-app-root mount div, a script tag assigning the
JSON-stringified full document to window.__TODL_APP__, and a second script tag holding
the bundled IIFE verbatim — no resource inlining beyond that (a noted, separate deferred
follow-up), and no shard/root payload assembly, since ModelDataSource.fromJSON already
accepts exactly the TodlDocument shape being inlined.
What HtmlArtifacts carries
The three keys in HtmlArtifacts (html-bundle/html-artifacts.ts) are the thread that
ties the html-bundle-specific actions together: AppEntry (the sandbox path entry.ts,
produced by action 3), CompiledUi (the list of compiled .mu.js sandbox paths, produced
by action 4), and AppBundle (the finished bundle string, produced by action 5 and consumed
by action 6). Action 5 (BundleAppAction) is the pipeline’s busiest consumer, declaring
Consumes: [AppEntry, CompiledUi] — it needs the entry point to bundle and the compiled UI
modules to confirm the app root among. There is no DTO key: generated/model.ts and
generated/data.ts are produced before this pipeline ever runs, by the project content
generators, so nothing inside html-bundle passes their paths around as hot values — the build
only reads them transitively, through the entry point’s imports.
History note: from a frozen bundle, to a compiled app, to an editable source tree
The current design has gone through three shapes. It started by inlining a single frozen,
committed 3.5 MB runtime bundle into every build and injecting only the model’s data into it
— one shared, static piece of view logic for every project. That gave way to a per-project
compiler: view logic moved into the project’s own generated app.mu, and the mural runtime
that renders it is compiled fresh on every build rather than reused verbatim. The third and
current shape moved generation out of the build entirely, into the
project content generators, and reshaped the output from one machine
generated/app.mu into a real editable src/ application — src/app.mu plus a paired
src/main.ts view-model, with the DTO and data confined to generated/. The build now
requires those files rather than creating any, and the old build-time clobber guard is simply
the WriteOnce policy on the two src/ generators, enforced once, outside any build. In the
same arc the one node-only step, esbuild, moved behind the IBundler seam, making the whole
target host-agnostic. The trade is a heavier, more moving-parts build (a real mural compile plus
a real bundle per project) in exchange for an application a developer can read, diff, hand-edit,
extend with their own files — and run in a browser renderer as readily as a headless CLI.
Summary: what to remember
The generic engine’s whole job is to make a pipeline’s shape checkable before it runs
(consume-before-produce at registration, plus a declarative Requires precondition for
project content a generator must have already produced) and its output atomic once it
runs (sandbox-then-promote, only on full success). TODL’s two build systems both start
the same way — resolve bases, compile the closure — and then diverge based on what they
are building: npm-package stages a package fragment (.document) plus source for
publication; html-bundle requires the project’s editable src/ application plus its
generated/ DTO and data (written by generators, not by this pipeline), emits the fixed
entry-point glue into its sandbox, compiles and bundles all of it through a swappable
IBundler, and emits one file a browser can open with nothing else installed.
← Back to the Architecture overview
See also: Project content generators · The runnable app · Consuming a model · Publish and packages