One runtime for many TJS libraries (proposed spike, after 1.0)

Status (2026-11-06): PROPOSED, deliberately deferred. What ships works: every emitted file carries its own runtime, and a bundle can share one copy (docs/runtime-fusion.md §8). This is an optimization, in the spirit of tosijs's blueprints, which were added after tosijs already worked and have always been nice rather than necessary. It waits until after 1.0, for a reason given below that is not only priority.

The problem

An app that uses five libraries compiled from TJS carries five copies of the inline runtime. Measured 2026-11-06: the full runtime is 20.5 KB (6.7 KB gzipped). A one-function module emits 3.9 KB standalone and 1.4 KB with prelude: 'import'. Inside one bundle, prelude: 'import' already solves this. Across PUBLISHED packages it cannot, because of a deliberate choice:

Error identity across copies is already handled: MonadicError is fused through a shape-versioned global slot (runtime-fusion.md).

The idea

A library ships WITHOUT the runtime and comes in happily on whichever compatible runtime is present, or fails loudly when the one present is incompatible.

Why not a first-module loader

The obvious version has the first TJS module to load bring the runtime and every later module accept it. The first module has to get the runtime from somewhere:

The proposal

A blueprint is the host injecting a dependency. In ESM, the equivalent is a shared PEER import, and the package manager decides which copy is the one.

  1. A small runtime package (say tjs-runtime): the inline runtime and nothing else, no compiler. The emitted import points there, not at tjs-lang/prelude.
  2. Versioned by the runtime's SHAPE, not by tjs-lang releases. Its major version moves only when the contract emitted code relies on changes. Libraries declare it as a peer dependency ("tjs-runtime": "^1"). npm installs one copy, and refuses the install (ERESOLVE) when two libraries need incompatible ones: the noisy failure, at the best time, install.
  3. A load-time handshake as the backstop. Each module states the shape it was emitted against (createPrelude({ shape: 1, features })), and an incompatible runtime throws, naming both. It covers what install cannot see: CDNs, hand-copied files, nested duplicates. This relaxes today's exact content-hash lock to a compatibility range.
  4. Libraries default to the import form; apps and scripts keep the standalone default, which needs no setup.

The shared runtime is the INLINE runtime's behaviour, not tjs-lang/runtime's: the two are deliberately not equivalent (CLAUDE.md, "The inline runtime is NOT the real runtime"), and emitted code is written against the inline one.

Why after 1.0

Point 2 is the real cost. It makes the inline runtime a PUBLIC CONTRACT with semver: every change to what emitted code calls becomes a compatibility decision, where today it is a free internal change locked by a hash. Before 1.0 the runtime changes nearly every release and should; freezing it now would slow the language for a size win that bundles already get.

The spike

  1. Extract the generated prelude into a package; emit createPrelude({ shape, features }).
  2. Decide what "shape" covers: the names and signatures emitted code calls, and the behaviour the differential tests pin. Write it down as the contract.
  3. Two libraries built against shape 1, one app: one copy installed, one copy bundled; a shape-2 library refused at install and at load, with a clear message.
  4. Measure the saving on a real multi-library app before deciding.

Stop if the contract cannot be stated without freezing internals the language still needs to change. Continue if it can, and the measured saving is worth one more published package and its compatibility discipline.