Compiler: experimental ES module output (--esm) [RFC]#2402
Draft
hhugo wants to merge 5 commits into
Draft
Conversation
hhugo
added a commit
that referenced
this pull request
Jul 12, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Experiment for #1161/#551 on top of the esm tooling: - 'js_of_ocaml compile --esm' emits ES modules: no IIFE wrapping, no globalThis polyfill, trailing top-level 'return' dropped. Separately compiled units reference runtime primitives as plain identifiers bound by a named import from the runtime module (path set by --esm-runtime-path, default './runtime.js') instead of reading globalThis.jsoo_runtime — no namespace import, so the output stays statically analyzable by bundlers and tree shakers. - 'js_of_ocaml build-runtime --esm' exports every primitive by name and shims CommonJS 'require' via node:module's createRequire when needed. - 'js_of_ocaml link --esm' emits an entry module importing every unit in link order; '--bundle' merges everything into one self-contained module using the esm bundler ('--no-tree-shake' to disable shaking). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6d26df0 to
6619e62
Compare
Compiling a .cma with --esm concatenates one module per unit into a single file. Such a file is not a valid module — units duplicate names (caml_call* helpers, runtime import bindings) — and is only meant to be consumed by the linker, like a .cma archive: - 'link --esm --bundle' splits containers at '//# unitInfo: Provides:' boundaries in memory; per-module renaming absorbs duplicate names. - 'link --esm' (unbundled) materializes one module file per unit next to the entry, rebasing relative import specifiers; files holding a single unit are valid modules and are imported in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Instead of going through the runtime's string-keyed global table, an ESM
unit now imports the units it references — every [caml_get_global("X")]
(the compiler may inline the call anywhere in an expression) becomes a
binding introduced by [import X from "./X.js"] — and exports its own
module block with [export default], replacing [caml_register_global].
Predefined exceptions keep using the runtime table through the distinct
*_predef primitives.
Cross-unit references are then ES bindings, statically visible, which
lets the linker match ocamlc's archive semantics: units coming from a
multi-unit container (.cma.js) are linked only when referenced, units
given as standalone files are always linked, and --linkall links
everything. On the hello-world experiment the tree-shaken bundle drops
from 399kB (--linkall, all units kept, previous behavior) to 210kB —
unused stdlib units are no longer emitted. The unbundled entry only
imports the root units; dependencies load through the import graph.
The linker resolves "./X.js" import specifiers by unit name, wherever
the unit actually comes from (container chunk or standalone file).
The rewrite runs after [simplify_js]: the JavaScript optimization passes
do not all track bindings introduced by import/export statements (e.g.
[Js_variable_coalescing] records no use for [export default e], which
corrupted exported module blocks — that latent limitation is left as is
and module syntax is simply kept out of those passes).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The coalescing CFG recorded no uses for export statements, so variables referenced by [export default e] appeared dead at the export point and their slot was reused, corrupting the exported value. Record their uses. Variables exported by name are live bindings — importers observe every later update — so they are excluded from coalescing entirely. Import bindings need no special handling: they are not [var]-declared, hence never candidates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generate [import X from "./X.js"] and [export default <block>] directly from the [caml_get_global]/[caml_register_global] primitives in [translate_instr], instead of pattern-matching the optimized JavaScript in a post-pass. The rewrite had to reverse-engineer the output of the minifier — inlined calls, reused variable slots, aliased declarators — which this makes irrelevant: the import binding is the [Let]-bound variable itself, unused unit references still emit the import (importing evaluates the unit, like linking it), and everything flows through the JavaScript passes, which are now module-aware. The runtime named import is still synthesized at the end of the pipeline, where the set of free variables is final. [Driver.simplify_js] is applied to bundled output again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Experiment on top of #2163: emit ECMAScript modules from js_of_ocaml, end to end, to inform the design discussion in #1161 (ESM as build artifact) and #551 (Node-consumable modules). Draft — the main goal is to decide what the result of separate compilation should be: bundled or not.
What works
All four configurations compile and run
hello.ml(stdlib + Printf) under node:compile --esm: no IIFE wrap, no globalThis polyfill; units reference runtime primitives as plain identifiers bound by a named import —import { caml_get_global, caml_call_gen, ... } from "./runtime.js"(--esm-runtime-pathto override) — instead of readingglobalThis.jsoo_runtime. Noimport *: the output stays statically analyzable by bundlers and tree shakers.build-runtime --esm: exports every primitive by name; shims CommonJSrequirewithnode:module'screateRequirewhen the runtime needs it.link --esm: emits an entry module with one side-effect import per unit — ES evaluation order guarantees units run in link order.--bundlemerges everything throughEsm_bundle.caml_get_global("X")compiles to a binding introduced byimport X from "./X.js", andcaml_register_globalcompiles toexport default <module block>— emitted directly by the code generator from the primitives (no JavaScript-level rewriting). Predefined exceptions keep using the runtime table through the distinct*_predefprimitives. Cross-unit references are then ES bindings, statically visible: the ESM linker recovers ocamlc's archive semantics (container units linked only when referenced; standalone unit files always linked;--linkalllinks everything) — which the script linker gets fromunitInfometadata, but which was impossible for the table-based ESM bundle where every unit had to be an entry point. It also means browsers, node and external bundlers see the true module graph without any jsoo-specific metadata. The unbundled entry only imports the root units; dependencies load through the import graph..cma.jsfiles are containers, not modules. A multi-unit.cma.jsconcatenates one module per unit, with duplicated names across units (caml_call*helpers, runtime import bindings) — importing it directly is a SyntaxError, by design. Like a.cmaarchive, it is only meant to be consumed by the linker:link --esm --bundlesplits it at//# unitInfo: Provides:boundaries in memory (per-module renaming absorbs the duplicates), and unbundledlink --esmmaterializes one module file per unit next to the entry (rebasing relative imports). Files holding a single unit are valid modules and are used in place. The runtime's ownunitInfoheader has an emptyProvides:— the linker treats that as file-level metadata, not a unit boundary, so the runtime links as a plain module referenced in place (an earlier revision misread it as a unit named"", materializing it as a file called.jsand evaluating the runtime twice).Fixes to the #2163 machinery found by the experiment
Four bugs surfaced while driving the tooling end to end; they are folded into the parent branch (#2163) as the four
Compiler: esm:commits and are not part of this PR's diff:node:module) failedbuild_graph;resolvereturningNonenow means "external".import * as runtimekept every runtime export alive; thens.field→ named-import optimization now runs before shaking.Numbers (hello world, compact output)
--linkall(Script concatenation and ESM bundle link the same 20 stdlib units; see the decomposition below.)
Both linkers select the same 20-unit
Requires-closure — the script linker already drops unused archive units (auto-link), and with import-based unit references the ESM linker matches that (the earlier table-based ESM bundle could not: every unit had to be an entry point, 403K). The 105K win over the script concatenation decomposes as: the runtime is tree-shaken to the primitives actually used (173K → ~80K), and thecaml_set_link_infosymbol table (~20K, which serves dynlink/toplevel) is not emitted — partially offset by unit code being ~6% larger while module top-level names are not minified. Remaining size gaps are fixable, not inherent to ESM:Js_variable_coalescingis now module-aware (export expressions record uses; by-name exports are live bindings, excluded from coalescing), so unit modules flow through the full JavaScript optimization pipeline, including bundled output.The design question: should linking bundle?
Findings that frame it:
.cma.jsis a container. A.cma.jsholding N concatenated module-units is a syntax error if imported directly (shared top-level scope, duplicate bindings). This PR keeps the one-file-per-library convention by treating it as a container the linker splits back into units (--keep-unit-namesremains available to emit per-unit files at compile time). There is no cheapcat-style link for modules — the unbundled link step stays cheap because it only writes the entry plus, for containers, one small file per unit.Measured link-step times (hello world + stdlib, hyperfine, warm cache; process startup alone is ~20 ms):
link --esm, per-unit files already in placelink --esm, splitting.cma.jscontainerslink --esm --bundleConcatenation never parses JavaScript — its cost is I/O. The ESM linker pays lex+parse+rename+print for whatever it has to materialize: everything when splitting containers, the whole program when bundling, nothing when the units are already on disk next to the entry — that last configuration beats concatenation, which still copies the full output on every link.
Tentative recommendation: make per-unit modules + generated entry the canonical result of separate compilation, and keep bundling as an optional packaging step (
link --esm --bundle) — or leave bundling to external tools, which can now see the whole graph (imports between units, named runtime imports). Whole-program compilation remains the size-optimal single-file path and now emits a proper module with--esm; the bundled separate-compilation path gets surprisingly close (210K vs 72K is the remaining gap to close via minification and per-value exports).Known gaps (deliberately out of scope for the experiment)
link --esm(both modes).--wrap-with-fun,--include-runtime(partial runtime) with--esm: untested / likely broken.--target-env browsershould avoid thenode:moduleshim, untested.//# unitInfometadata is lost when bundling.--esminto the dune jsoo mode is future work.🤖 Generated with Claude Code