Pi-CLI-Web / src /sandbox.js
Mike0021's picture
Deploy pi cli web docker server
aab0173 verified
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);
},
};
}