File size: 5,012 Bytes
059f0de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
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;

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) {
    const value = String(path || "").trim().replace(/^\/+/, "");
    if (!value || value.includes("\0")) {
      throw new Error("Path is required.");
    }
    const segments = value.split("/").filter(Boolean);
    if (segments.some((segment) => segment === "." || segment === "..")) {
      throw new Error("Paths must stay inside the sandbox workspace.");
    }
    return segments.join("/");
  }

  function toWorkspacePath(path) {
    return assertRelativePath(path);
  }

  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 listFiles(path = ".") {
    const wc = await boot();
    const target = path === "." || path === "" ? "." : toWorkspacePath(path);
    const entries = await wc.fs.readdir(target, { withFileTypes: true });
    return entries.map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`).join("\n") || "(empty)";
  }

  async function readFile(path) {
    const wc = await boot();
    return await wc.fs.readFile(toWorkspacePath(path), "utf-8");
  }

  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 `Wrote ${relative}`;
  }

  async function runCommand(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;
  }

  return {
    boot,
    reset,
    listFiles,
    readFile,
    writeFile,
    runCommand,
    get isReady() {
      return Boolean(instance);
    },
  };
}