How the Worker-JavaScript Backend Handles Durable Relative Imports and Structured I/O
The Worker-JavaScript backend in cloudflare/computer resolves relative imports against a Workspace's virtual file system and captures stdout, stderr, and exit codes through a framed I/O stream.
The worker-javascript backend (@cloudflare/computer/backends/worker-javascript) provides a full ECMAScript module execution environment inside a Cloudflare Dynamic Worker. It powers durable execution of Workspace scripts with two critical capabilities: resolving relative imports without filesystem access and streaming structured I/O events back to the host.
Durable Relative Imports in Worker-JavaScript
When a script executed by the worker-javascript backend calls import "./foo.js", the backend cannot rely on standard filesystem resolution. Instead, it uses the WorkspaceRuntimeCapability to resolve imports against the Workspace's virtual file system.
How buildModuleGraph Resolves Imports
The core resolution logic lives in module-graph.ts at lines 70-98. The function walks the AST of the entry module and handles each import specifier:
-
Relative paths (
specifier.startsWith(".")) trigger a three-step process:resolveRelativecomputes the absolute path while preserving directory hierarchycapability.readFileloads the source, with size checked againstmaxSourceBytes- Recursive AST walking builds a complete module graph for the Dynamic Worker loader
-
Trusted modules (
ws:*andnode:*) are injected automatically and excluded from relative-import resolution
This pre-validation ensures import errors surface as deterministic failures before the Worker starts, not mid-execution.
Example: Relative Import Resolution
// workspace entry (exec source)
import "./utils.js";
export default async function (input: any) {
const { greet } = await import("./utils.js");
console.log(greet("World"));
return { message: "done" };
}
When executed via workspace.runtime.exec(..., {backend:"worker-javascript"}), buildModuleGraph resolves ./utils.js relative to the script's working directory, reads its contents through the Workspace capability, and adds it to the loader's module map.
Trusted Module Injection
The backend automatically injects stubs for Workspace capabilities. These bypass the relative-import walker:
import { git } from "ws:git";
export default async function () {
const branches = await git.listBranches();
console.log("branches:", branches);
}
The stub for ws:git is injected at module-graph.ts lines 37-44, enabling host-side Git functionality without additional configuration.
Structured I/O: Capturing stdout, stderr, and Exit Codes
The worker-javascript backend replaces standard stream APIs with instrumented wrappers that frame every I/O event for reliable host-side consumption.
Runtime I/O Instrumentation
Inside the Worker, runtimeWorkerModule (lines 73-85 in worker-javascript.ts) performs three key steps:
- Creates a fake
processobject withstdout.writeandstderr.writestubs - Overrides
console.logandconsole.errorto route through the same recording path - All writes are base-64 encoded, limited by
maxStdioBytes, and enqueued to anIdentityTransformStream
Framed Output Stream and Host Decoding
The framed stream connects to the host through this pipeline:
- Worker side:
host.attachOutput(output.readable)streams framed JSON lines - Host side:
#pumpFrames(lines 89-110) decodes each frame viadecodeRuntimeFrames - Event translation: Frames become
WorkspaceRuntimeEventobjects (stdout,stderr,exit) - Persistence: Events are stored via
#persistEventand published to subscribers via#publishEvent
When user code finishes, the runtime posts an exit frame containing the exit code and optional JSON-serializable result. The host finalizes the execution record through #finalizeOnce.
Structured I/O Example
export default async function () {
console.log("hello stdout"); // → stdout event
console.error("oops stderr"); // → stderr event
return { success: true, value: 42 }; // → exit event with result
}
The host receives these events:
| Event | Payload | Source Property |
|---|---|---|
stdout |
Base-64 encoded bytes | event.name === "stdout" |
stderr |
Base-64 encoded bytes | event.name === "stderr" |
exit |
{code: 0, result: {...}} |
event.name === "exit" |
Access events via workspace.runtime.getExec({id, after: 0}) or consume the real-time ReadableStream from exec().
Configuration and Limits
The backend enforces resource boundaries through constructor options:
new WorkerJavaScriptBackend({
loader,
maxStdioBytes: 64 * 1024, // 64 KiB combined stdout+stderr
maxSourceBytes: 2 * 1024 * 1024, // 2 MiB total source
});
Exceeding maxStdioBytes triggers truncation with ...[stdio truncated] appended (see runtimeWorkerModule lines 106-127).
Execution Lifecycle
The complete flow from connection to finalization:
- Connection:
WorkerJavaScriptBackend.connectreturns aJavaScriptBackendHandleowning SQLite tables for durable logs - Execution request:
exec(input)creates an execution ID, validates limits, and builds the module graph - Module graph construction: Relative imports resolved, trusted modules injected
- Dynamic Worker launch:
startJavaScriptExecutionpasses the module map andworkspace-runtime-runner.jsentry to a new Worker - Runtime setup:
runtimeWorkerModuleinstruments I/O and creates the framed stream - Host streaming:
#pumpFramesdecodes, persists, and publishes events - Finalization:
exitframe triggers#finalizeOnceto persist exit code and result
Summary
- Durable relative imports are resolved via
WorkspaceRuntimeCapabilityinbuildModuleGraph, with trustedws:*modules automatically injected - Structured I/O replaces native stream APIs with framed, base-64 encoded events streamed through
IdentityTransformStream - Pre-validation of the complete module graph prevents mid-execution import failures
- Configurable limits on source size and stdio protect host resources
- Full execution lifecycle from
connect()throughexec()to final event persistence is implemented inworker-javascript.ts
Frequently Asked Questions
How does worker-javascript resolve imports without filesystem access?
The backend uses WorkspaceRuntimeCapability.readFile and resolveConfined to load modules from the Workspace's virtual file system. The buildModuleGraph function in module-graph.ts (lines 70-98) walks import specifiers and resolves relative paths against the Workspace directory structure, building a complete module map before the Dynamic Worker starts.
What happens if a script exceeds the stdio size limit?
When output exceeds maxStdioBytes, the runtime truncates further writes and appends ...[stdio truncated] to the stream. This occurs in runtimeWorkerModule at lines 106-127. The limit applies to the combined total of stdout and stderr to prevent unbounded memory growth in the Worker.
Can I stream execution output in real time?
Yes. The exec() method returns a ReadableStream of WorkspaceRuntimeEvent objects that emits stdout, stderr, and finally exit events as they occur. Alternatively, poll workspace.runtime.getExec({id, after: sequenceNumber}) to retrieve events durably from SQLite storage.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →