@ 261107.24raw
current

Script-only components

jco componentize normally bakes one script into one JavaScript engine, producing a component that is ready to run. --script-only splits that in two:

A host instantiates the engine once, instantiates a script-only component whenever it needs a script, reads the script out of it and loads it into the engine. The engine’s world exports then forward to that script. Loading another script replaces the previous one, so a single engine instance runs scripts one after the other.

warning

Scripts loaded into the same engine instance share one JavaScript realm. Anything a script leaves on globalThis, or in the imports it holds on to, is visible to the next script. A script engine is a way to reuse an engine build across scripts, not a sandbox between tenants. Instantiate a fresh engine per trust boundary.

Building

Pass --script-only, one or more --wit paths, the scripts to package, and an output directory:

jco componentize --script-only \
  --wit ./wit-greeter --wit ./wit-calc \
  greet.js calc.ts \
  -o ./dist

This writes dist/engine.wasm, dist/engine.wit (the merged world the engine was built against), dist/greet.wasm and dist/calc.wasm.

Other jco componentize options apply to the engine build. Node.js built-in compatibility (--with-nodejs-*) is not available in this mode, because it rewrites the WIT world per script.

Running

Transpile the engine and the script components like any other component, then load scripts through the engine’s scriptLoader export:

jco transpile dist/engine.wasm -o dist/host/engine --map 'local:greeter/logger=./logger.js'
jco transpile dist/greet.wasm -o dist/host/greet
jco transpile dist/calc.wasm -o dist/host/calc
import { scriptLoader, greeter, calc, run } from "./dist/host/engine/engine.js";
import { script as greet } from "./dist/host/greet/greet.js";
import { script as calcScript } from "./dist/host/calc/calc.js";

scriptLoader.load(greet.name(), greet.source());
run(); // "greet"
greeter.greet("jco"); // "Hello, jco!"
new greeter.Counter(41).get(); // 41

scriptLoader.load(calcScript.name(), calcScript.source());
run(); // "calc"
calc.add(2, 3); // 5

scriptLoader.loaded(); // "calc.js"
scriptLoader.unload();

The engine’s imports (here local:greeter/logger) are supplied by the host of the engine, exactly as for a regular component; the script components import nothing. Any component runtime can do the same: instantiate the engine, call script.source() on a script component, and pass the result to script-loader.load.

The interfaces

Every engine exports jco:script-engine/script-loader@0.1.0:

interface script-loader {
    /// Evaluate `source` and make its exports the implementation behind the engine's
    /// world exports, replacing any previously loaded script.
    load: func(name: string, source: string) -> result<_, string>;

    /// Drop the loaded script. World exports fail until another script is loaded.
    unload: func();

    /// Name of the loaded script, if any.
    loaded: func() -> option<string>;
}

load takes an ES module. Neither engine can import a module from a string at runtime, so the loader rewrites the module’s top-level import declarations and its export list before evaluating it. It relies on the shape Rolldown produces, which jco componentize --script-only always uses: imports hoisted to the top, one export { ... } list at the end, no top-level await. load returns an error when the script fails to parse or evaluate, or imports something the engine world lacks; the previously loaded script stays loaded. Calling a world export that the loaded script does not implement throws inside the engine, which traps unless the export returns a result.

Every script-only component exports jco:script/script@0.1.0:

interface script {
    /// Name the script was packaged under, usually its entry file name.
    name: func() -> string;

    /// The script as a bundled ES module whose imports name WIT imports of the engine world.
    source: func() -> string;

    /// Fully-qualified engine world the script was bundled against.
    engine-world: func() -> string;

    /// JavaScript export names found in the script.
    exports: func() -> list<string>;
}

exports lets a host check that a script implements what it is about to call before loading it.

Writing scripts

Scripts are ordinary component sources: import WIT imports by their specifier and export the world’s exports under the usual JavaScript names, exactly as for jco componentize without --script-only. A script only has to implement the exports the host will call while it is loaded.

Exported resources work as well: the engine’s class for a resource stands in for the script’s class, and instances created by either side pass through transparently. Two limits apply: