Spaces:
Sleeping
Sleeping
| const BOOT_FILES = { | |
| "package.json": { | |
| file: { | |
| contents: JSON.stringify( | |
| { | |
| type: "module", | |
| scripts: { | |
| demo: "node hello.js", | |
| }, | |
| dependencies: {}, | |
| devDependencies: {}, | |
| }, | |
| null, | |
| 2, | |
| ), | |
| }, | |
| }, | |
| "README.md": { | |
| file: { | |
| contents: | |
| "# Pi web sandbox\n\nThis filesystem and every spawned process run inside WebContainers in this browser tab.\n", | |
| }, | |
| }, | |
| "hello.js": { | |
| file: { | |
| contents: 'console.log("hello from the browser sandbox");\nconsole.log(2 + 2);\n', | |
| }, | |
| }, | |
| }; | |
| const MAX_OUTPUT_CHARS = 16000; | |
| const DEFAULT_TIMEOUT_MS = 10000; | |
| const DEFAULT_MAX_READ_LINES = 2000; | |
| const DEFAULT_RESULT_LIMIT = 200; | |
| function splitCommandLine(value) { | |
| const parts = []; | |
| let current = ""; | |
| let quote = ""; | |
| let escaping = false; | |
| for (const char of String(value || "")) { | |
| if (escaping) { | |
| current += char; | |
| escaping = false; | |
| continue; | |
| } | |
| if (char === "\\") { | |
| escaping = true; | |
| continue; | |
| } | |
| if (quote) { | |
| if (char === quote) { | |
| quote = ""; | |
| } else { | |
| current += char; | |
| } | |
| continue; | |
| } | |
| if (char === "\"" || char === "'") { | |
| quote = char; | |
| } else if (/\s/.test(char)) { | |
| if (current) { | |
| parts.push(current); | |
| current = ""; | |
| } | |
| } else { | |
| current += char; | |
| } | |
| } | |
| if (current) parts.push(current); | |
| return parts; | |
| } | |
| function escapeRegExp(value) { | |
| return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| } | |
| function globToRegExp(glob) { | |
| const source = String(glob || "*") | |
| .split("*") | |
| .map(escapeRegExp) | |
| .join(".*"); | |
| return new RegExp(`^${source}$`); | |
| } | |
| export function createSandbox({ onLog = () => {}, onStatus = () => {} } = {}) { | |
| let instance = null; | |
| let booting = null; | |
| let root = ""; | |
| async function boot() { | |
| if (instance) return instance; | |
| if (booting) return booting; | |
| booting = (async () => { | |
| if (!globalThis.crossOriginIsolated) { | |
| throw new Error("WebContainers need cross-origin isolation. Start this app through Vite or another server that sends COOP/COEP headers."); | |
| } | |
| onStatus("Booting WebContainer"); | |
| const { WebContainer } = await import("@webcontainer/api"); | |
| instance = await WebContainer.boot({ | |
| coep: "credentialless", | |
| workdirName: "workspace", | |
| }); | |
| root = instance.workdir; | |
| await instance.mount(BOOT_FILES); | |
| onLog(`sandbox booted at ${root}`); | |
| onStatus("Sandbox ready"); | |
| return instance; | |
| })(); | |
| try { | |
| return await booting; | |
| } finally { | |
| booting = null; | |
| } | |
| } | |
| function assertRelativePath(path, { allowDot = false } = {}) { | |
| let value = String(path ?? (allowDot ? "." : "")).trim(); | |
| if (value === "" && allowDot) value = "."; | |
| value = value.replace(/^\/+/, "").replace(/^workspace\/?/, ""); | |
| if (value === "." && allowDot) return "."; | |
| if (!value || value.includes("\0")) throw new Error("Path is required."); | |
| const segments = value.split("/").filter(Boolean); | |
| if (segments.some((segment) => segment === "..")) { | |
| throw new Error("Paths must stay inside the sandbox workspace."); | |
| } | |
| const normalized = segments.filter((segment) => segment !== ".").join("/"); | |
| if (!normalized && allowDot) return "."; | |
| if (!normalized) throw new Error("Path is required."); | |
| return normalized; | |
| } | |
| function toWorkspacePath(path, options) { | |
| return assertRelativePath(path, options); | |
| } | |
| async function pathExists(path) { | |
| const wc = await boot(); | |
| try { | |
| await wc.fs.stat(toWorkspacePath(path, { allowDot: true })); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async function reset() { | |
| const wc = await boot(); | |
| const entries = await wc.fs.readdir("."); | |
| await Promise.all( | |
| entries.map((entry) => | |
| wc.fs.rm(entry, { | |
| force: true, | |
| recursive: true, | |
| }), | |
| ), | |
| ); | |
| await wc.mount(BOOT_FILES); | |
| onLog("sandbox reset"); | |
| return "Sandbox reset to the starter project."; | |
| } | |
| async function walkFiles(start = ".") { | |
| const wc = await boot(); | |
| const rootPath = toWorkspacePath(start, { allowDot: true }); | |
| const files = []; | |
| async function walk(current) { | |
| const entries = await wc.fs.readdir(current, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| if (entry.name === "node_modules" || entry.name === ".git") continue; | |
| const child = current === "." ? entry.name : `${current}/${entry.name}`; | |
| if (entry.isDirectory()) { | |
| await walk(child); | |
| } else { | |
| files.push(child); | |
| } | |
| } | |
| } | |
| const stat = await wc.fs.stat(rootPath); | |
| if (stat.isDirectory()) { | |
| await walk(rootPath); | |
| } else { | |
| files.push(rootPath); | |
| } | |
| return files; | |
| } | |
| async function ls(path = ".", limit = DEFAULT_RESULT_LIMIT) { | |
| const wc = await boot(); | |
| const target = toWorkspacePath(path || ".", { allowDot: true }); | |
| const stat = await wc.fs.stat(target); | |
| if (!stat.isDirectory()) return `file ${target}`; | |
| const entries = await wc.fs.readdir(target, { withFileTypes: true }); | |
| const capped = entries.slice(0, Number(limit || DEFAULT_RESULT_LIMIT)); | |
| const lines = capped.map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`); | |
| if (entries.length > capped.length) { | |
| lines.push(`[Showing ${capped.length} of ${entries.length} entries. Use a larger limit to continue.]`); | |
| } | |
| return lines.join("\n") || "(empty)"; | |
| } | |
| async function readTextFile(path, { offset, limit } = {}) { | |
| const wc = await boot(); | |
| const target = toWorkspacePath(path); | |
| const text = await wc.fs.readFile(target, "utf-8"); | |
| const lines = text.split("\n"); | |
| const start = Math.max(0, Number(offset || 1) - 1); | |
| if (start >= lines.length) throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`); | |
| const maxLines = Number(limit || DEFAULT_MAX_READ_LINES); | |
| const selected = lines.slice(start, start + maxLines); | |
| let output = selected.join("\n"); | |
| if (start + selected.length < lines.length) { | |
| output += `\n\n[${lines.length - start - selected.length} more lines in file. Use offset=${start + selected.length + 1} to continue.]`; | |
| } | |
| return output; | |
| } | |
| async function writeFile(path, content) { | |
| const wc = await boot(); | |
| const relative = assertRelativePath(path); | |
| const parent = relative.split("/").slice(0, -1).join("/"); | |
| if (parent) { | |
| await wc.fs.mkdir(parent, { recursive: true }); | |
| } | |
| await wc.fs.writeFile(relative, String(content ?? "")); | |
| onLog(`wrote ${relative}`); | |
| return `Successfully wrote ${String(content ?? "").length} bytes to ${relative}`; | |
| } | |
| async function editFile(path, edits = []) { | |
| const wc = await boot(); | |
| const target = toWorkspacePath(path); | |
| if (!Array.isArray(edits) || edits.length === 0) { | |
| throw new Error("Edit tool input is invalid. edits must contain at least one replacement."); | |
| } | |
| const original = await wc.fs.readFile(target, "utf-8"); | |
| const replacements = []; | |
| for (const edit of edits) { | |
| const oldText = String(edit?.oldText ?? ""); | |
| const newText = String(edit?.newText ?? ""); | |
| if (!oldText) throw new Error("Every edit.oldText must be non-empty."); | |
| const first = original.indexOf(oldText); | |
| if (first === -1) throw new Error(`Could not edit file: ${path}. oldText was not found.`); | |
| if (original.indexOf(oldText, first + oldText.length) !== -1) { | |
| throw new Error(`Could not edit file: ${path}. oldText must match exactly one location.`); | |
| } | |
| replacements.push({ start: first, end: first + oldText.length, newText }); | |
| } | |
| replacements.sort((a, b) => b.start - a.start); | |
| for (let index = 1; index < replacements.length; index += 1) { | |
| if (replacements[index].end > replacements[index - 1].start) { | |
| throw new Error("Edit tool input is invalid. edits must not overlap."); | |
| } | |
| } | |
| let next = original; | |
| for (const replacement of replacements) { | |
| next = `${next.slice(0, replacement.start)}${replacement.newText}${next.slice(replacement.end)}`; | |
| } | |
| await wc.fs.writeFile(target, next); | |
| onLog(`edited ${target}`); | |
| return `Successfully replaced ${edits.length} block(s) in ${path}.`; | |
| } | |
| async function findFiles(pattern = "*", path = ".", limit = DEFAULT_RESULT_LIMIT) { | |
| const start = toWorkspacePath(path || ".", { allowDot: true }); | |
| if (!(await pathExists(start))) throw new Error(`Path does not exist: ${path || "."}`); | |
| const matcher = globToRegExp(pattern); | |
| const files = await walkFiles(start); | |
| const matches = files.filter((file) => matcher.test(file) || matcher.test(file.split("/").pop() || "")); | |
| const capped = matches.slice(0, Number(limit || DEFAULT_RESULT_LIMIT)); | |
| if (matches.length > capped.length) capped.push(`[Showing ${capped.length} of ${matches.length} matches. Use a larger limit to continue.]`); | |
| return capped.join("\n") || "(no matches)"; | |
| } | |
| async function grepFiles({ pattern, path = ".", glob = "*", ignoreCase = false, literal = false, context = 0, limit = DEFAULT_RESULT_LIMIT }) { | |
| if (!pattern) throw new Error("pattern is required."); | |
| const flags = ignoreCase ? "i" : ""; | |
| const matcher = literal ? new RegExp(escapeRegExp(pattern), flags) : new RegExp(pattern, flags); | |
| const globMatcher = globToRegExp(glob || "*"); | |
| const files = (await walkFiles(path || ".")).filter((file) => globMatcher.test(file) || globMatcher.test(file.split("/").pop() || "")); | |
| const matches = []; | |
| for (const file of files) { | |
| let text = ""; | |
| try { | |
| text = await readTextFile(file, { limit: DEFAULT_MAX_READ_LINES }); | |
| } catch { | |
| continue; | |
| } | |
| const lines = text.split("\n"); | |
| for (let index = 0; index < lines.length; index += 1) { | |
| if (!matcher.test(lines[index])) continue; | |
| matcher.lastIndex = 0; | |
| const before = Math.max(0, index - Number(context || 0)); | |
| const after = Math.min(lines.length - 1, index + Number(context || 0)); | |
| for (let lineIndex = before; lineIndex <= after; lineIndex += 1) { | |
| matches.push(`${file}:${lineIndex + 1}:${lines[lineIndex]}`); | |
| if (matches.length >= Number(limit || DEFAULT_RESULT_LIMIT)) { | |
| matches.push(`[Showing first ${matches.length - 1} matches. Use a larger limit to continue.]`); | |
| return matches.join("\n"); | |
| } | |
| } | |
| } | |
| } | |
| return matches.join("\n") || "(no matches)"; | |
| } | |
| async function spawnProcess(command, args = [], timeoutMs = DEFAULT_TIMEOUT_MS) { | |
| const wc = await boot(); | |
| const cmd = String(command || "").trim(); | |
| if (!cmd) throw new Error("Command is required."); | |
| const cleanArgs = Array.isArray(args) ? args.map((arg) => String(arg)) : []; | |
| onLog(`$ ${[cmd, ...cleanArgs].join(" ")}`); | |
| const process = await wc.spawn(cmd, cleanArgs, { | |
| terminal: { | |
| cols: 96, | |
| rows: 28, | |
| }, | |
| }); | |
| let output = ""; | |
| const reader = process.output.getReader(); | |
| const pump = (async () => { | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| output += value; | |
| if (output.length > MAX_OUTPUT_CHARS) { | |
| output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated]`; | |
| process.kill(); | |
| break; | |
| } | |
| } | |
| })(); | |
| const timeout = new Promise((resolve) => { | |
| setTimeout(() => { | |
| process.kill(); | |
| resolve("timeout"); | |
| }, Number(timeoutMs) || DEFAULT_TIMEOUT_MS); | |
| }); | |
| const exitCode = await Promise.race([process.exit, timeout]); | |
| await pump.catch(() => {}); | |
| const normalizedExitCode = exitCode === "timeout" ? 124 : exitCode; | |
| const result = { | |
| command: [cmd, ...cleanArgs].join(" "), | |
| exitCode: normalizedExitCode, | |
| output: output.trimEnd(), | |
| }; | |
| onLog(`exit ${normalizedExitCode}`); | |
| return result; | |
| } | |
| async function bash(command, timeoutSeconds) { | |
| const timeoutMs = timeoutSeconds ? Number(timeoutSeconds) * 1000 : DEFAULT_TIMEOUT_MS; | |
| try { | |
| return await spawnProcess("jsh", ["-c", command], timeoutMs); | |
| } catch (error) { | |
| const parts = splitCommandLine(command); | |
| if (parts.length === 0) throw error; | |
| return await spawnProcess(parts[0], parts.slice(1), timeoutMs); | |
| } | |
| } | |
| return { | |
| boot, | |
| reset, | |
| bash, | |
| editFile, | |
| findFiles, | |
| grepFiles, | |
| ls, | |
| listFiles: ls, | |
| readFile: (path) => readTextFile(path), | |
| readTextFile, | |
| writeFile, | |
| runCommand: spawnProcess, | |
| get isReady() { | |
| return Boolean(instance); | |
| }, | |
| }; | |
| } | |