MoonBash's core engine is written in MoonBit and compiled to pure JavaScript via moon build --target js. The FFI (Foreign Function Interface) layer handles all communication between the MoonBit core and the JavaScript host environment.
- Minimal FFI Surface - Only side-effectful operations cross the boundary
- Zero-Copy Strings - MoonBit JS backend maps strings to native JS strings
- Async-First - All I/O operations are async (Promise-based)
- Type-Safe Bridge - Both sides have strong typing (MoonBit + TypeScript)
Status note (as of 2026-02-19): the active filesystem integration direction is the TypeScript-layer AgentFS adapter (docs/AGENTFS_ANALYSIS.md), while OverlayFs/ReadWriteFs content in this document is retained as legacy reference.
┌────────────────────────────────┐
│ TypeScript Host │
│ │
│ ┌──────────────────────────┐ │
│ │ FsBridge │ │
│ │ NetworkBridge │ │
│ │ TimeBridge │ │
│ │ TraceBridge │ │
│ └────────────┬─────────────┘ │
│ │ JS functions │
├───────────────┼────────────────┤
│ │ extern "js" │
│ ┌────────────▼─────────────┐ │
│ │ MoonBit FFI Layer │ │
│ │ (ffi/*.mbt) │ │
│ └──────────────────────────┘ │
│ │
│ MoonBit Core Engine │
└────────────────────────────────┘
The filesystem is the most critical FFI boundary. InMemoryFs runs entirely in MoonBit. The current roadmap path uses an AgentFS adapter in the TypeScript wrapper; the callback model below documents the legacy OverlayFs/ReadWriteFs approach.
/// Filesystem callback functions provided by the host
struct FsCallbacks {
read_file : (String) -> String // path -> content
write_file : (String, String) -> Unit // path, content -> ()
stat : (String) -> String // path -> JSON-encoded FsStat
readdir : (String) -> String // path -> JSON-encoded entries
exists : (String) -> Bool // path -> bool
mkdir : (String) -> Unit // path -> ()
rm : (String) -> Unit // path -> ()
}
/// Register filesystem callbacks from host
pub fn set_fs_callbacks(callbacks : FsCallbacks) -> Unit {
// Store callbacks for legacy OverlayFs path
}// bridge.ts
import * as fs from "node:fs/promises";
import { setFsCallbacks } from "./moon_bash-core.js";
export function setupFsBridge(rootDir: string) {
setFsCallbacks({
read_file: (path: string) => {
const realPath = resolveToReal(rootDir, path);
validatePathSecurity(rootDir, realPath);
return fs.readFile(realPath, "utf-8");
},
write_file: (path: string, content: string) => {
const realPath = resolveToReal(rootDir, path);
validatePathSecurity(rootDir, realPath);
return fs.writeFile(realPath, content, "utf-8");
},
stat: (path: string) => {
const realPath = resolveToReal(rootDir, path);
const stat = fs.stat(realPath);
return JSON.stringify({
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
isSymbolicLink: stat.isSymbolicLink(),
size: stat.size,
mode: stat.mode,
mtime: stat.mtimeMs,
});
},
readdir: (path: string) => {
const realPath = resolveToReal(rootDir, path);
const entries = fs.readdir(realPath, { withFileTypes: true });
return JSON.stringify(entries.map(e => ({
name: e.name,
isFile: e.isFile(),
isDirectory: e.isDirectory(),
isSymbolicLink: e.isSymbolicLink(),
})));
},
exists: (path: string) => {
const realPath = resolveToReal(rootDir, path);
try { fs.access(realPath); return true; }
catch { return false; }
},
mkdir: (path: string) => {
const realPath = resolveToReal(rootDir, path);
return fs.mkdir(realPath, { recursive: true });
},
rm: (path: string) => {
const realPath = resolveToReal(rootDir, path);
return fs.rm(realPath, { recursive: true, force: true });
},
});
}MoonBit's @moonbitlang/async library compiles to JavaScript Promises. This enables seamless async interop.
/// Execute a bash script (async entry point)
pub async fn execute(script : String, ctx : ExecContext) -> ExecResult {
let ast = parse(script)!
evaluate(ast, ctx)
}When compiled to JS (--target js), this becomes:
// Generated JS (simplified)
function execute(script, ctx) {
return new Promise((resolve, reject) => {
try {
const ast = parse(script);
resolve(evaluate(ast, ctx));
} catch (e) {
reject(e);
}
});
}When MoonBit needs to call an async JS function (e.g., reading a file from disk):
// MoonBit side: declare external async function
extern "js" fn host_read_file_async(path : String) -> String =
"async (path) => await globalThis.__moon_bash_fs.readFile(path)"// Using @moonbitlang/async/js_async for Promise interop
pub async fn read_overlay_file(path : String) -> String!FsError {
try {
host_read_file_async(path)
} catch {
e => raise FsError::IoError(e.to_string())
}
}Network access is routed through the host for security enforcement.
struct NetworkCallbacks {
fetch : (FetchRequest) -> String // Returns JSON-encoded FetchResponse
}
struct FetchRequest {
url : String
method : String
headers : Array[(String, String)]
body : String?
}
extern "js" fn host_fetch(request_json : String) -> String =
"async (req) => JSON.stringify(await globalThis.__moon_bash_network.fetch(JSON.parse(req)))"
pub async fn secure_fetch(request : FetchRequest) -> FetchResponse!NetworkError {
let req_json = request.to_json().stringify()
let resp_json = host_fetch(req_json)
FetchResponse::from_json(resp_json)
}export function setupNetworkBridge(config: NetworkConfig) {
globalThis.__moon_bash_network = {
fetch: async (req: FetchRequest): Promise<FetchResponse> => {
// 1. Validate URL against allowedUrlPrefixes
validateUrl(req.url, config.allowedUrlPrefixes);
// 2. Validate HTTP method
validateMethod(req.method, config.allowedMethods);
// 3. Execute fetch with timeout
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
config.timeoutMs
);
try {
const response = await fetch(req.url, {
method: req.method,
headers: Object.fromEntries(req.headers),
body: req.body,
signal: controller.signal,
redirect: "manual",
});
// 4. Validate response size
const body = await readWithLimit(response, config.maxResponseSize);
return {
status: response.status,
headers: Object.fromEntries(response.headers),
body,
};
} finally {
clearTimeout(timeout);
}
},
};
}MoonBit's @regexp library handles most regex needs. For advanced features (backreferences, lookahead), we fall back to JS RegExp with timeout protection.
/// Try MoonBit native regex first, fall back to JS if needed
pub fn regex_match(pattern : String, input : String) -> RegexResult!RegexError {
// Try native @regexp first
match @regexp.compile(pattern) {
Ok(re) => {
match re.find(input) {
Some(m) => RegexResult::Match(m)
None => RegexResult::NoMatch
}
}
Err(_) => {
// Pattern uses features not in @regexp (backrefs, lookahead)
// Fall back to JS RegExp with timeout
js_regex_fallback(pattern, input)
}
}
}
extern "js" fn js_regex_match(pattern : String, flags : String, input : String) -> String =
#| (pattern, flags, input) => {
#| try {
#| const re = new RegExp(pattern, flags);
#| const match = re.exec(input);
#| return match ? JSON.stringify({matched: true, groups: [...match]}) :
#| JSON.stringify({matched: false});
#| } catch (e) {
#| return JSON.stringify({error: e.message});
#| }
#| }/// Get current timestamp (milliseconds)
extern "js" fn host_now() -> Int64 =
"() => BigInt(Date.now())"
/// Sleep for specified milliseconds
extern "js" fn host_sleep(ms : Int) -> Unit =
"async (ms) => new Promise(r => setTimeout(r, ms))"/// Emit a trace event to the host
extern "js" fn host_trace(event_json : String) -> Unit =
"(json) => { if (globalThis.__moon_bash_trace) globalThis.__moon_bash_trace(JSON.parse(json)); }"
pub fn emit_trace(category : String, name : String, duration_ms : Double) -> Unit {
let event = TraceEvent { category, name, duration_ms }
host_trace(event.to_json().stringify())
}MoonBit's JS backend maps String directly to JavaScript's native string type. No encoding conversion or copying is needed. This is a fundamental advantage over WASM-based solutions.
MoonBit String ←→ JavaScript string
↕ ↕
Same V8 heap object (zero copy)
For complex data crossing the FFI boundary, we use JSON serialization:
// MoonBit -> JS: serialize to JSON
let stat_json = fs_stat.to_json().stringify()
host_callback(stat_json)
// JS -> MoonBit: parse from JSON
let response = @json.parse(json_string)!
let status = response["status"].as_int()!For binary file content (Bytes):
extern "js" fn host_read_binary(path : String) -> Bytes =
"async (path) => new Uint8Array(await globalThis.__moon_bash_fs.readBinary(path))"
extern "js" fn host_write_binary(path : String, data : Bytes) -> Unit =
"async (path, data) => await globalThis.__moon_bash_fs.writeBinary(path, data)"All FFI callbacks use a global namespace pattern for clean registration:
// TypeScript host setup
declare global {
var __moon_bash_fs: FsBridge;
var __moon_bash_network: NetworkBridge;
var __moon_bash_trace: TraceCallback | undefined;
var __moon_bash_sleep: (ms: number) => Promise<void>;
}
export function initMoonBash(options: BashOptions) {
// 1. Set up filesystem bridge
globalThis.__moon_bash_fs = createFsBridge(options.fs);
// 2. Set up network bridge (if enabled)
if (options.network) {
globalThis.__moon_bash_network = createNetworkBridge(options.network);
}
// 3. Set up tracing (if enabled)
if (options.trace) {
globalThis.__moon_bash_trace = options.trace;
}
// 4. Set up sleep
globalThis.__moon_bash_sleep = options.sleep ?? defaultSleep;
// 5. Initialize MoonBit core
return createBashInstance(options);
}Errors flow across the FFI boundary via structured error types:
MoonBit Error (enum)
│
├── Caught in MoonBit → return ExecResult with stderr + exitCode
│
└── Uncaught → propagates as JS Error
│
└── Caught in TypeScript wrapper
│
└── Re-thrown as typed BashError
// MoonBit error types
pub(open) enum BashError {
ParseError(String)
ExecError(String)
LimitExceeded(String)
FsError(FsError)
NetworkError(String)
InternalError(String)
}// TypeScript error wrapper
class BashError extends Error {
constructor(
public type: "parse" | "exec" | "limit" | "fs" | "network" | "internal",
message: string,
) {
super(message);
this.name = "BashError";
}
}