/** * webcontainer.ts — Full Node.js runtime in the browser via WebContainer API. * * Bugs fixed vs original: * #1 mount() overwriting tree on every write → now: first call mounts, subsequent calls use fs.writeFile/mkdir * #2 exec read-loop could hang past the deadline → now: race each read against timeout + kill signal * #3 server-ready listener leaked on timeout → now: unsubscribes in both resolve and reject paths * #4 boot failure poisoned the cached promise forever → now: clears _wcBootPromise on error so retry works * #5 no cwd on exec → now: optional cwd param forwarded to wc.spawn * #6 no way to know if COOP/COEP missing → now: webcontainer_isolation_status tool * #7 no listdir / no delete → now: wcLsTool, wcRmTool * #8 output was tail-only with no head option → now: keep_head option + configurable cap */ import { tool } from '@strands-agents/sdk' import { z } from 'zod' let _wc: any = null let _wcBootPromise: Promise | null = null let _mounted = false // tracks whether initial mount has happened // Persistent cache of every server-ready event we've seen. // The event fires ONCE per server.listen() call, so we must remember it. type ServerInfo = { port: number; url: string; seen_at: number } const _servers = new Map() // port -> info const _pendingServerResolvers: Array<(info: ServerInfo) => void> = [] let _serverListenerInstalled = false function installServerListener(wc: any) { if (_serverListenerInstalled) return _serverListenerInstalled = true try { wc.on('server-ready', (port: number, url: string) => { const info: ServerInfo = { port, url, seen_at: Date.now() } _servers.set(port, info) // Wake any pending webcontainer_server_ready callers. while (_pendingServerResolvers.length > 0) { const resolve = _pendingServerResolvers.shift() try { resolve?.(info) } catch {} } }) } catch (err) { console.warn('[webcontainer] failed to install server-ready listener:', err) _serverListenerInstalled = false } } async function getWC() { if (_wc) return _wc if (_wcBootPromise) return _wcBootPromise _wcBootPromise = (async () => { // Pre-flight: surface actionable error when cross-origin isolation is missing. if (typeof window !== 'undefined' && (window as any).crossOriginIsolated === false) { throw new Error( 'WebContainer requires cross-origin isolation (window.crossOriginIsolated === false). ' + 'Ensure the COOP/COEP service worker (coi-serviceworker.js) has registered and reload the page.' ) } const mod: any = await import('@webcontainer/api') const wc = await mod.WebContainer.boot() _wc = wc // Install the global server-ready listener IMMEDIATELY so we never miss events. installServerListener(wc) return wc })().catch((err) => { _wcBootPromise = null // CLEAR on failure so retry is possible throw err }) return _wcBootPromise } // --- Isolation check (call this FIRST when debugging) ----------------------- export const wcIsolationTool = tool({ name: 'webcontainer_isolation_status', description: 'Check if the page has cross-origin isolation needed for WebContainer. Returns crossOriginIsolated, SharedArrayBuffer availability, and a diagnosis.', inputSchema: z.object({}), callback: async () => { try { const isolated = typeof window !== 'undefined' ? (window as any).crossOriginIsolated : false const hasSAB = typeof SharedArrayBuffer !== 'undefined' const isSecure = typeof window !== 'undefined' ? window.isSecureContext : false const diagnosis = isolated && hasSAB ? 'READY: WebContainer can boot.' : !isSecure ? 'Page is not in a secure context (HTTPS / localhost required).' : !hasSAB ? 'SharedArrayBuffer unavailable — COEP header missing or blocked.' : 'Cross-origin isolation not yet active. Reload the page once after coi-serviceworker registers.' return JSON.stringify({ status: 'success', crossOriginIsolated: isolated, hasSharedArrayBuffer: hasSAB, isSecureContext: isSecure, diagnosis, fix: isolated ? null : { dev: 'vite.config.ts should set server.headers for COOP/COEP (already done in this repo)', prod: 'coi-serviceworker.js must be loaded in index.html BEFORE any module scripts', }, }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) // --- Boot ------------------------------------------------------------------- export const wcBootTool = tool({ name: 'webcontainer_boot', description: 'Boot the WebContainer (in-browser Node.js runtime). Call once before other wc_* tools. Retryable on failure.', inputSchema: z.object({}), callback: async () => { try { const wc = await getWC() return JSON.stringify({ status: 'success', booted: true, workdir: wc.workdir ?? null, already_mounted: _mounted, hint: 'Use webcontainer_write_files to mount your project, then webcontainer_exec to run commands.', }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message, hint: 'Run webcontainer_isolation_status first. If crossOriginIsolated is false, reload the page.', }) } }, }) // --- Write (merge-safe) ----------------------------------------------------- function buildTree(files: Record) { const tree: any = {} for (const [path, content] of Object.entries(files)) { const parts = path.split('/').filter(Boolean) if (parts.some((p) => p === '..' || p === '.')) { throw new Error(`Illegal path segment in "${path}"`) } let cur = tree for (let i = 0; i < parts.length - 1; i++) { const seg = parts[i] cur[seg] = cur[seg] || { directory: {} } cur = cur[seg].directory } cur[parts[parts.length - 1]] = { file: { contents: content } } } return tree } export const wcWriteTool = tool({ name: 'webcontainer_write_files', description: 'Write files into the WebContainer filesystem. Map of relative path → content, e.g. {"package.json": "...", "src/index.js": "..."}. ' + 'First call bulk-mounts; subsequent calls merge individual files (do NOT overwrite the whole tree).', inputSchema: z.object({ files: z.record(z.string(), z.string()).describe('Map of relative path to file content'), }), callback: async (input) => { try { const wc = await getWC() const entries = Object.entries(input.files) if (entries.length === 0) return JSON.stringify({ status: 'success', written: 0 }) if (!_mounted) { // Initial bulk mount — fast path const tree = buildTree(input.files) await wc.mount(tree) _mounted = true } else { // Merge mode — write files individually so we don't blow away existing state for (const [path, content] of entries) { const parts = path.split('/').filter(Boolean) if (parts.some((p) => p === '..' || p === '.')) { throw new Error(`Illegal path segment in "${path}"`) } if (parts.length > 1) { const dir = parts.slice(0, -1).join('/') try { await wc.fs.mkdir(dir, { recursive: true }) } catch {} } await wc.fs.writeFile(path, content) } } return JSON.stringify({ status: 'success', written: entries.length, mode: _mounted ? 'merge' : 'mount' }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) // --- Exec ------------------------------------------------------------------- export const wcExecTool = tool({ name: 'webcontainer_exec', description: 'Run a shell command in the WebContainer. Example: {"cmd":"npm","args":["install"]}. Captures stdout/stderr with a hard timeout. ' + 'Default timeout is 120s (npm install is slow on first boot). Set keep_head=true to capture first 4KB instead of last.', inputSchema: z.object({ cmd: z.string().describe('Executable, e.g. "npm", "node", "ls", "cat"'), args: z.array(z.string()).optional(), cwd: z.string().optional().describe('Working directory inside the container (e.g. "src", "packages/api")'), timeout_ms: z.number().optional().describe('Default 120000 (2 min)'), keep_head: z.boolean().optional().describe('Keep first 4KB of output instead of last 4KB (useful for install errors)'), max_output: z.number().optional().describe('Output cap in bytes. Default 4000'), }), callback: async (input) => { try { const wc = await getWC() const spawnOpts = input.cwd ? { cwd: input.cwd } : undefined const proc = await wc.spawn(input.cmd, input.args || [], spawnOpts) const timeout = input.timeout_ms ?? 120_000 const maxOut = input.max_output ?? 4000 let out = '' let killed = false const killProc = () => { if (!killed) { killed = true; try { proc.kill() } catch {} } } // Global deadline — if hit, kill the process const deadlineTimer = setTimeout(killProc, timeout) // Drain output. Race each read against the "killed" signal so we never hang. const reader = proc.output.getReader() const drainPromise = (async () => { try { while (true) { const result = await Promise.race([ reader.read(), new Promise<{ done: true; value: undefined }>((r) => { // If the process is killed, break the loop. const check = setInterval(() => { if (killed) { clearInterval(check); r({ done: true, value: undefined }) } }, 50) }), ]) if (result.done) break const chunk = typeof result.value === 'string' ? result.value : String(result.value ?? '') out += chunk } } catch {} try { reader.releaseLock() } catch {} })() const code: number = await Promise.race([ proc.exit, new Promise((r) => setTimeout(() => { killProc(); r(-1) }, timeout)), ]) clearTimeout(deadlineTimer) // Give the drain a brief window to flush, but don't block forever. await Promise.race([drainPromise, new Promise((r) => setTimeout(r, 150))]) const truncated = out.length > maxOut const snippet = truncated ? (input.keep_head ? out.slice(0, maxOut) : out.slice(-maxOut)) : out return JSON.stringify({ status: code === 0 ? 'success' : 'error', exitCode: code, output: snippet, truncated, full_length: out.length, timed_out: code === -1, cwd: input.cwd ?? null, }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message, hint: (err as Error).message.includes('not found') ? 'Did you run webcontainer_write_files and npm install first?' : undefined, }) } }, }) // --- Read ------------------------------------------------------------------- export const wcReadTool = tool({ name: 'webcontainer_read', description: 'Read a file from the WebContainer filesystem.', inputSchema: z.object({ path: z.string().describe('Relative path, e.g. "package.json"'), }), callback: async (input) => { try { const wc = await getWC() const content = await wc.fs.readFile(input.path, 'utf-8') return JSON.stringify({ status: 'success', path: input.path, content }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) // --- List & Delete (new) ---------------------------------------------------- export const wcLsTool = tool({ name: 'webcontainer_ls', description: 'List directory entries in the WebContainer. Returns file + directory names.', inputSchema: z.object({ path: z.string().optional().describe('Directory path (default ".")'), }), callback: async (input) => { try { const wc = await getWC() const entries = await wc.fs.readdir(input.path ?? '.', { withFileTypes: true }) return JSON.stringify({ status: 'success', path: input.path ?? '.', entries: entries.map((e: any) => ({ name: e.name, type: e.isDirectory?.() ? 'dir' : 'file', })), }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const wcRmTool = tool({ name: 'webcontainer_rm', description: 'Delete a file or directory from the WebContainer filesystem. Set recursive=true to delete directories.', inputSchema: z.object({ path: z.string().describe('Path to remove'), recursive: z.boolean().optional(), }), callback: async (input) => { try { const wc = await getWC() await wc.fs.rm(input.path, { recursive: input.recursive ?? false, force: true }) return JSON.stringify({ status: 'success', removed: input.path }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) // --- Server ready (cache-backed, never misses events) ---------------------- export const wcServerReadyTool = tool({ name: 'webcontainer_server_ready', description: 'Get the preview URL for a dev server running inside the container. ' + 'If a port was already bound, returns its URL immediately from cache. ' + 'Otherwise waits for the next server-ready event up to timeout_ms. ' + 'Pass port=N to wait for a specific port only.', inputSchema: z.object({ port: z.number().optional().describe('Specific port to wait for. If omitted, returns any running server or waits for the next one.'), timeout_ms: z.number().optional().describe('Default 60000. Set to 0 to only check cache (no wait).'), }), callback: async (input) => { try { const wc = await getWC() // Ensure listener is installed even if boot happened in a weird path. installServerListener(wc) // Cache hit: specific port requested and already running. if (input.port !== undefined && _servers.has(input.port)) { const info = _servers.get(input.port)! return JSON.stringify({ status: 'success', url: info.url, port: info.port, from_cache: true }) } // Cache hit: any port, and at least one is running → return the most recent. if (input.port === undefined && _servers.size > 0) { const all = Array.from(_servers.values()).sort((a, b) => b.seen_at - a.seen_at) const info = all[0] return JSON.stringify({ status: 'success', url: info.url, port: info.port, from_cache: true, all_servers: all.map(s => ({ port: s.port, url: s.url })), }) } const timeout = input.timeout_ms ?? 60_000 if (timeout === 0) { return JSON.stringify({ status: 'error', error: 'No servers running and timeout_ms=0 (cache-only mode).' }) } const info = await new Promise((resolve, reject) => { let timer: any const waiter = (s: ServerInfo) => { if (input.port !== undefined && s.port !== input.port) { // Wrong port — keep waiting. _pendingServerResolvers.push(waiter) return } clearTimeout(timer) resolve(s) } _pendingServerResolvers.push(waiter) timer = setTimeout(() => { // Remove our waiter from the queue so it doesn't leak. const idx = _pendingServerResolvers.indexOf(waiter) if (idx !== -1) _pendingServerResolvers.splice(idx, 1) reject(new Error( input.port !== undefined ? `Timeout waiting for port ${input.port}. Currently running: ${Array.from(_servers.keys()).join(', ') || 'none'}` : 'Timeout waiting for server-ready. Did your server actually listen()?' )) }, timeout) }) return JSON.stringify({ status: 'success', url: info.url, port: info.port, from_cache: false }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) // --- List running servers (new) --------------------------------------------- export const wcServersListTool = tool({ name: 'webcontainer_servers_list', description: 'List all servers currently running inside the WebContainer (returns port + URL for each).', inputSchema: z.object({}), callback: async () => { try { await getWC() // ensures listener is installed const all = Array.from(_servers.values()).sort((a, b) => b.seen_at - a.seen_at) return JSON.stringify({ status: 'success', count: all.length, servers: all.map(s => ({ port: s.port, url: s.url, seen_at: new Date(s.seen_at).toISOString(), })), }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) // --- Spawn background process (fire-and-forget, returns immediately) -------- export const wcSpawnTool = tool({ name: 'webcontainer_spawn', description: 'Start a long-running process in the WebContainer (like `node server.js` or `npm run dev`) without blocking. ' + 'Returns immediately after the first 2s of output or on listen. Use this for dev servers — do NOT use webcontainer_exec. ' + 'After calling this, use webcontainer_server_ready to get the preview URL.', inputSchema: z.object({ cmd: z.string(), args: z.array(z.string()).optional(), cwd: z.string().optional(), wait_ms: z.number().optional().describe('How long to wait for initial output before returning. Default 2000.'), }), callback: async (input) => { try { const wc = await getWC() installServerListener(wc) const spawnOpts = input.cwd ? { cwd: input.cwd } : undefined const proc = await wc.spawn(input.cmd, input.args || [], spawnOpts) const waitMs = input.wait_ms ?? 2000 let out = '' const reader = proc.output.getReader() // Drain output into `out` for the wait window, then return without blocking. const drain = (async () => { try { while (true) { const { value, done } = await reader.read() if (done) break out += typeof value === 'string' ? value : String(value ?? '') } } catch {} })() // NOTE: we don't await drain — it continues in the background. // We also don't release the reader; letting it keep pulling keeps the stream flowing. void drain // Give the process a window to boot / print / call listen(). await new Promise((r) => setTimeout(r, waitMs)) return JSON.stringify({ status: 'success', spawned: true, cmd: input.cmd, args: input.args || [], initial_output: out.slice(-2000), hint: 'Process is running in the background. Call webcontainer_server_ready to get the preview URL.', }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const WEBCONTAINER_TOOLS = [ wcIsolationTool, wcBootTool, wcWriteTool, wcExecTool, wcSpawnTool, // NEW: fire-and-forget for long-running processes wcServersListTool, // NEW: list all running servers wcReadTool, wcLsTool, wcRmTool, wcServerReadyTool, ]