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.
Evaluation Model
Section titled “Evaluation Model”When a mod’s entry block declares format: "module":
- The entry source is evaluated as an ES module, one-shot, at
load_mod/loadMod/LoadModtime — 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, thexriptglobal, the exports surface, and the fragment API are installed onglobalThisbefore the module is evaluated. A module sees the identical ambient environment a script sees: the host bindings as globals/namespaces,hooks,console, andxript. - 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
awaitis permitted where the runtime’s module evaluator supports it. A module that never settles its top-levelawaitis 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.
Runtime evaluators
Section titled “Runtime evaluators”- rust (
xript-runtime): rquickjsModulecompile + 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 aModuleUnsupportedError(“module-format mods require the async sandbox”) rather than silently evaluating it as a script. - node (
@xriptjs/runtime-node):node:vmSourceTextModulewith a deny-all link callback, thenmodule.evaluate(). - csharp (
Xript.Runtime): JintEngine.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].capabilitygatesinvoke_exportby name via the host-side export-capability map, regardless of whether the export came from a module binding or aregister()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/InvokeModExportinvokes inside one named mod’s namespace and gates on that mod’s own declaration. Role resolution already carries the owning mod inRoleResolution.addon; hosts SHOULD pass it through. - Command invocation always takes the addressed form. A resolved command carries its owning mod, so
invokeCommandroutes 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 namedrun, and neither invocation is ambiguous. See commands.md. - Bare-name invocation resolves only when unambiguous.
invoke_export(name, args)invokes the sole owner ofname. 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.specifierset, 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.
Approved Libraries
Section titled “Approved Libraries”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 pathcreateRuntime(manifest, { hostBindings, libraries: { "@example/doc": docModuleSource } });// mod entry — a real static import, linked in-sandboximport { renderMarkdown } from "@example/doc";export function render(md) { return renderMarkdown(md); }Semantics
Section titled “Semantics”- 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’scapabilityunder the v0.7 subsumption rules (lib⊇lib.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 →CapabilityDeniedErrornaming the specifier and the missing capability. An ungated library (nocapability) 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/ dynamicimport(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
CommonJSDetectedErrordetector as mod entries. - Capability declaration integrity: a library’s
capabilityscope must be declared in the manifest’scapabilitiesmap, the same rule that governs binding and slot gates.
Runtime requirements
Section titled “Runtime requirements”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.
What stays host-side
Section titled “What stays host-side”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 Never Supported
Section titled “CommonJS Is Never Supported”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).
Canonical detector
Section titled “Canonical detector”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> =orexports[— 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 identity
Section titled “Error identity”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 authoredas ES modules (entry.format: "module", top-level export) or as classic scripts usingxript.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/.Two enforcement homes
Section titled “Two enforcement homes”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-detectedon/entry. This is the primary fix-it surface — it catches a misconfiguredtsconfigat author time. - Runtime load-time (late, host-facing): the runtime runs the same detector before evaluation as defense-in-depth, so a misconfigured
tsconfigcan never silently break — even for a mod the validator never saw.
Authoring Mods in TypeScript
Section titled “Authoring Mods in TypeScript”A TypeScript mod must compile to an ES module the runtime can evaluate. The rules:
-
Compile to ESM. Set
module: "ESNext"(or"NodeNext") andmoduleResolution: "Bundler"(or"NodeNext") intsconfig.json.module: "Node16"with a default package can emit CJS-shaped output — the exact footgun the CommonJS guard exists to catch. -
Use top-level
exports for invokable functions.export function transcribe(...)is registered automatically; noxript.exports.registercall is needed. Declare each export in the mod manifest’sentry.exportsfor typed authoring, docs, and capability gating. -
Import only approved libraries. Imports are default-deny; the only specifiers that link are the ones in the host’s
librariesallow-list (gated by their declared capability). For everything else, use host bindings (available as ambient globals) instead of pulling in packages. -
Never CommonJS. No
require(...), nomodule.exports, noexports.x = .... These fail loudly at both validate and load time. -
Type against the ambient surface. Generate an ambient declaration file with
xript typegen --ambient --host <host-manifest> <mod-manifest>, and reference it fromtsconfigtypesor a triple-slash directive. This types thexriptglobal, the host bindings,hooks,events, and the mod’s ownExports.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 itsentry.exports. -
Name the built artifact, not the source.
entry.scriptis the module a host loads. When authoring in TypeScript it is the compiled output (dist/mod.js), never the.tsfile — a runtime has no compiler in it. The scaffold’stsconfigcompilessrc/todist/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));});Related Documents
Section titled “Related Documents”- 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
ImportDeniedErrorandCommonJSDetectedError. - Fragments: the inert-fragment model that module-mode mods contribute to.