Skip to content

Module-Format Mods

This document specifies the entry.format: "module" evaluation mode, the rules that govern ES-module mod entries across all conformant runtimes, and the authoring conventions that make a TypeScript mod compile to a module the runtime can load.

A mod entry is either a classic script (format: "script", the default) or an ES module (format: "module"). Both modes evaluate a single self-contained entry source in the same sandbox realm. Module mode adds top-level export syntax and automatic export harvesting; it does not add module loading.

When a mod’s entry block declares format: "module":

  • The entry source is evaluated as an ES module, one-shot, at load_mod / loadMod / LoadMod time — the same call site that evaluates a classic script in script mode.
  • Evaluation happens in the same sandbox realm and global as a script-mode mod. Bindings, hooks, console, the xript global, the exports surface, and the fragment API are installed on globalThis before the module is evaluated. A module sees the identical ambient environment a script sees: the host bindings as globals/namespaces, hooks, console, and xript.
  • Top-level code runs exactly once. Side-effecting registration (hooks.fragment.update(...), xript.exports.register(...)) is legal inside a module and behaves identically to script mode.
  • Top-level await is permitted where the runtime’s module evaluator supports it. A module that never settles its top-level await is a load-time error, mirroring the workflow path’s unsettled-promise handling. In both paths an execution-budget expiry or a cancellation that lands while the promise is outstanding is reported as itself, not as an unsettled promise.
  • A failed module instantiation or evaluation (syntax error, top-level throw, unresolved import) surfaces as the same load-time error the script path uses today — never a silent no-op.

Module-vs-script is a manifest fact (entry.format) read by the loader; the host calls the same load entry point in both modes. The single entry script (entry.script) is the v1 baseline: exactly one module source is evaluated. Multi-module / array entry is out of scope for module mode in v1.

  • rust (xript-runtime): rquickjs Module compile + eval; drive pending jobs to settle, then catch.
  • js (@xriptjs/runtime): only the async sandbox (createSandboxAsync) supports module evaluation. The sync sandbox (createSandboxSync) must reject a module-format entry with a ModuleUnsupportedError (“module-format mods require the async sandbox”) rather than silently evaluating it as a script.
  • node (@xriptjs/runtime-node): node:vm SourceTextModule with a deny-all link callback, then module.evaluate().
  • csharp (Xript.Runtime): Jint Engine.Modules.Add + Engine.Modules.Import, with a module loader that rejects external specifiers.

Top-Level Exports Become Host-Invokable Exports

Section titled “Top-Level Exports Become Host-Invokable Exports”

After a module evaluates, the runtime reads its top-level named function exports and registers each into the same export registry that xript.exports.register feeds. A function exported as transcribe becomes invokable via the unchanged invoke_export('transcribe', args) / invokeExport / InvokeExport path. No xript.exports.register call is required in module mode.

  • Script mode keeps using xript.exports.register. Both paths coexist and merge into one registry.
  • Collision rule: if a top-level export and an explicit register() call share a name, register() wins — it is an explicit imperative act and runs after the export binding is established.
  • Only function-valued named exports are harvested. Non-function named exports (export const VERSION = "1.0") are ignored for invocation purposes (they are data, not callables) and do not error.
  • The default export is not harvested — exports are addressed by name and a default export has no stable invocation name.
  • Capability gating is unchanged: entry.exports[name].capability gates invoke_export by name via the host-side export-capability map, regardless of whether the export came from a module binding or a register() call. Audit emission is identical for both origins.

entry.exports in the manifest documents and capability-gates these exports. It need not enumerate them for invocation to work; the runtime is authoritative for what actually registered. It SHOULD still enumerate them for typed authoring and docs.

Export Names Are Scoped To The Declaring Mod

Section titled “Export Names Are Scoped To The Declaring Mod”

The registry described above is per mod, not per runtime. Two mods loaded into one runtime may each export transcribe; neither shadows the other, and neither can reach the other’s function. This is what makes the capability gate hold: an export’s gate is read from the declaration of the mod that owns it, so a later mod’s declaration of the same name lands in its own slot and can neither weaken nor remove an earlier one.

  • Registration is mod-scoped. xript.exports.register(name, fn) and the module harvest both write into the export namespace of the mod whose entry is being evaluated. Code the host runs itself (execute) writes into the host’s own namespace.
  • The collision rule above is intra-mod. register() beating a top-level export applies within one mod’s namespace. Across mods there is no collision to resolve — the names never meet.
  • Mod-scoped invocation is the addressed form. invoke_mod_export(mod, name, args) / invokeModExport / InvokeModExport invokes inside one named mod’s namespace and gates on that mod’s own declaration. Role resolution already carries the owning mod in RoleResolution.addon; hosts SHOULD pass it through.
  • Command invocation always takes the addressed form. A resolved command carries its owning mod, so invokeCommand routes through the mod-scoped path and MUST NOT fall back to the bare, unaddressed one. Two mods may each back a command with an export named run, and neither invocation is ambiguous. See commands.md.
  • Bare-name invocation resolves only when unambiguous. invoke_export(name, args) invokes the sole owner of name. When two or more loaded mods own it, the runtime MUST raise an ambiguity error naming the export and every colliding mod, rather than picking one. Silently resolving to the first or last loaded mod is non-conformant: it is the shape that erased the capability boundary.
  • Loading a duplicate name is not itself an error. Two providers of the same role exporting the same function name is normal, and load succeeds. The ambiguity surfaces at the call site, where the host has the information to disambiguate.

Imports Are Default-Deny; Approved Libraries Lift It

Section titled “Imports Are Default-Deny; Approved Libraries Lift It”

A module-format mod is a self-contained entry module whose imports are denied by default. Every import specifier is rejected at link/instantiation time, before any top-level code runs, unless the specifier names a library the host has approved (see Approved Libraries). That covers bare specifiers ("fs", "lodash"), absolute, URL, and (in v1) relative alike. This preserves security guarantee #1 (no sandbox escape) and the eval ban on dynamic import().

  • Static import x from "..." of an unapproved specifier fails at link time.
  • Dynamic import("...") fails at call time regardless of approval (already covered by the no-eval / dynamic-import ban; module mode does not re-enable it, and the library lift applies only to static imports).
  • The rejection is a load-time error with a stable, cross-runtime identity: error name ImportDeniedError, with .specifier set, and a message of the form: import of "<specifier>" is not permitted; xript mods cannot import external modules (see security guarantee: no sandbox escape).

Relative intra-mod imports are denied in v1: the single-entry self-contained module is the baseline.

The host manifest’s top-level libraries map is a curated allow-list of importable libraries — the capability model applied to modules. Default-deny is intact: the host curates which libraries exist, and a capability gates which mods may import each.

// host manifest
{
"libraries": {
"@example/doc": {
"description": "Shared markdown + doc rendering.",
"capability": "lib.doc",
"version": "^1.0.0"
}
}
}
// host wiring — the host ships the implementation; xript provides the link path
createRuntime(manifest, { hostBindings, libraries: { "@example/doc": docModuleSource } });
// mod entry — a real static import, linked in-sandbox
import { renderMarkdown } from "@example/doc";
export function render(md) { return renderMarkdown(md); }
  • Resolution order at link time: the specifier must (1) be declared in the resolved host manifest’s libraries, (2) have source registered by the host at runtime construction, and (3) pass the capability gate — the granted set must satisfy the library’s capability under the v0.7 subsumption rules (liblib.doc). A miss at any step is a load-time error: undeclared → ImportDeniedError; declared-but-unregistered → LibraryUnavailableError (the host forgot to supply the source — a host bug, named as such); capability denied → CapabilityDeniedError naming the specifier and the missing capability. An ungated library (no capability) skips step (3).
  • In-sandbox execution: an approved library evaluates inside the sandbox, at the importing mod’s own privilege. It sees the same ambient environment the mod sees and crosses no marshalling boundary — imports are full-fidelity object/function references, not JSON. Approving a library grants the mod no new power; it is the host vouching “this is sandbox-safe shared code I choose to offer.”
  • One instance per runtime: a library module is instantiated once and shared by everything that imports it in that runtime, standard ES module semantics.
  • Import-clean rule: an approved library must be a self-contained, pre-bundled ES module with no imports of its own. Library source is run through the same default-deny loader — a library importing an unapproved specifier fails exactly as a mod would, and runtimes MUST reject a registered library whose source contains static import / export … from / dynamic import( forms at registration time, using the same conservative-detector posture as the CommonJS guard (false positives in strings/comments are accepted). This stops the import-deny from being laundered through a library’s dependency tree.
  • CommonJS guard applies: registered library source is subject to the same CommonJSDetectedError detector as mod entries.
  • Capability declaration integrity: a library’s capability scope must be declared in the manifest’s capabilities map, the same rule that governs binding and slot gates.

Libraries link at module-entry evaluation, so they ride the module path end to end. In the universal JS runtime that means the async sandbox: initXriptAsync + loadModAsync. The sync sandbox rejects module-format entries outright (ModuleUnsupportedError), and there is no other import site — a host on the sync path adopts the async runtime first, then adds libraries. The Node, Rust, and C# runtimes evaluate modules on their standard load paths, so no equivalent split exists there.

The library lift is for pure compute — markdown rendering, date math, validation, formatting: code that needs no privilege beyond the sandbox. Anything touching host state, the filesystem, or the network stays a host binding (host-side execution, JSON boundary, per-function capability). The two columns are complementary, not competing; a host typically offers both.

CommonJS is not a supported module format in any mode. Runtimes and the validator must detect CommonJS artifacts in a mod entry and fail loudly with a fix-it message, rather than producing a mod whose exports silently never register (the failure mode when a misconfigured tsconfig emits CJS: require/module are undefined in the sandbox and the top-level code throws an opaque ReferenceError, or an exports.foo = ... assignment mutates a stray global and registers nothing).

The detector is conservative — it favors over-rejection over a silently-broken mod. It flags an entry source when any of the following appear at any position:

  • require( — the CommonJS require call form.
  • module.exports — the CommonJS module-exports assignment or reference.
  • exports.<ident> = or exports[ — a top-level CommonJS named-export assignment.

The reference match is the union of these case-sensitive patterns:

  • /\brequire\s*\(/
  • /\bmodule\s*\.\s*exports\b/
  • /\bexports\s*\.\s*[A-Za-z_$][\w$]*\s*=/
  • /\bexports\s*\[/

False positives inside string literals or comments are accepted: a mod flagged as CJS that was actually fine is a one-line author fix, whereas a CJS mod that loads silently broken is the exact bug this guard kills. Runtimes MUST NOT build a full tokenizer to avoid over-rejection.

The check runs before module/script evaluation, in both script and module mode (a script-mode entry compiled to CJS is equally broken).

Error name CommonJSDetectedError, with .artifact set to the matched form (require(), module.exports, or exports.x), and a message that points at the ESM/script-mode fix and the authoring guide:

CommonJS artifacts detected in mod entry (found: <artifact>). xript mods must be authored
as ES modules (entry.format: "module", top-level export) or as classic scripts using
xript.exports.register — never CommonJS. Fix your tsconfig to emit ESM (module: "esnext",
moduleResolution: "bundler"/"nodenext") or remove the require()/module.exports usage.
See https://xript.dev/spec/modules/.

Both ship, by design:

  • Validate-time (early, author/CI-facing): when the entry source is reachable, the validator raises a hard error with keyword commonjs-detected on /entry. This is the primary fix-it surface — it catches a misconfigured tsconfig at author time.
  • Runtime load-time (late, host-facing): the runtime runs the same detector before evaluation as defense-in-depth, so a misconfigured tsconfig can never silently break — even for a mod the validator never saw.

A TypeScript mod must compile to an ES module the runtime can evaluate. The rules:

  1. Compile to ESM. Set module: "ESNext" (or "NodeNext") and moduleResolution: "Bundler" (or "NodeNext") in tsconfig.json. module: "Node16" with a default package can emit CJS-shaped output — the exact footgun the CommonJS guard exists to catch.

  2. Use top-level exports for invokable functions. export function transcribe(...) is registered automatically; no xript.exports.register call is needed. Declare each export in the mod manifest’s entry.exports for typed authoring, docs, and capability gating.

  3. Import only approved libraries. Imports are default-deny; the only specifiers that link are the ones in the host’s libraries allow-list (gated by their declared capability). For everything else, use host bindings (available as ambient globals) instead of pulling in packages.

  4. Never CommonJS. No require(...), no module.exports, no exports.x = .... These fail loudly at both validate and load time.

  5. Type against the ambient surface. Generate an ambient declaration file with xript typegen --ambient --host <host-manifest> <mod-manifest>, and reference it from tsconfig types or a triple-slash directive. This types the xript global, the host bindings, hooks, events, and the mod’s own Exports.

    Pass the host. log, hooks, events, the slot ids and the capability names are surfaces the host installs on the sandbox global; a mod manifest declares none of them. Generating from the mod alone yields an environment with none of that in it, which will not compile against any real mod. The mod manifest supplies only its own half — its fills’ binding surfaces and its entry.exports.

  6. Name the built artifact, not the source. entry.script is the module a host loads. When authoring in TypeScript it is the compiled output (dist/mod.js), never the .ts file — a runtime has no compiler in it. The scaffold’s tsconfig compiles src/ to dist/ for exactly this reason.

A minimal module-mode mod entry:

/// <reference path="./xript-env.d.ts" />
export function transcribe(audioUrl: string): string {
log("transcribing " + audioUrl);
return "transcript of " + audioUrl;
}
hooks.fragment.update("transcript-panel", (bindings, fragment) => {
log("fragment updated with: " + JSON.stringify(bindings));
});
  • Security: module syntax is allowed, but imports are denied and CommonJS is loudly rejected — guarantees #1 (no sandbox escape) and #4 (no eval) are strengthened, not weakened.
  • Bindings: defines the runtime error vocabulary, including ImportDeniedError and CommonJSDetectedError.
  • Fragments: the inert-fragment model that module-mode mods contribute to.