@illuma-ai/code-sandbox 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -12
- package/README.md +10 -11
- package/dist/index.cjs +91 -96
- package/dist/index.js +11895 -17954
- package/dist/styles.css +1 -1
- package/package.json +9 -12
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/src/components/BootOverlay.tsx +0 -145
- package/src/components/CodeEditor.tsx +0 -298
- package/src/components/FileTree.tsx +0 -678
- package/src/components/Preview.tsx +0 -262
- package/src/components/Terminal.tsx +0 -111
- package/src/components/ViewSlider.tsx +0 -87
- package/src/components/Workbench.tsx +0 -382
- package/src/hooks/useRuntime.ts +0 -637
- package/src/index.ts +0 -51
- package/src/services/runtime.ts +0 -775
- package/src/styles.css +0 -178
- package/src/templates/fullstack-starter.ts +0 -3507
- package/src/templates/index.ts +0 -607
- package/src/types.ts +0 -375
package/src/services/runtime.ts
DELETED
|
@@ -1,775 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* NodepodRuntime — Manages the full lifecycle of a Nodepod sandbox.
|
|
3
|
-
*
|
|
4
|
-
* Flow: boot → write files → npm install → run entry command → track changes
|
|
5
|
-
*
|
|
6
|
-
* This is the core service that bridges project files and the browser runtime.
|
|
7
|
-
* It is framework-agnostic (no React) — consumed by the useRuntime hook.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { Nodepod } from "@illuma-ai/nodepod";
|
|
11
|
-
import type { NodepodProcess } from "@illuma-ai/nodepod";
|
|
12
|
-
import type {
|
|
13
|
-
BootProgress,
|
|
14
|
-
BootStage,
|
|
15
|
-
FileMap,
|
|
16
|
-
RuntimeConfig,
|
|
17
|
-
SandboxError,
|
|
18
|
-
SandboxErrorCategory,
|
|
19
|
-
} from "../types";
|
|
20
|
-
|
|
21
|
-
/** Callback type for progress updates */
|
|
22
|
-
type ProgressCallback = (progress: BootProgress) => void;
|
|
23
|
-
/** Callback type for terminal output lines */
|
|
24
|
-
type OutputCallback = (line: string) => void;
|
|
25
|
-
/** Callback type for server ready */
|
|
26
|
-
type ServerReadyCallback = (port: number, url: string) => void;
|
|
27
|
-
/** Callback type for structured errors */
|
|
28
|
-
type ErrorCallback = (error: SandboxError) => void;
|
|
29
|
-
|
|
30
|
-
/** Debug log prefix for easy filtering in DevTools console */
|
|
31
|
-
const DBG = "[CodeSandbox:Runtime]";
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Manages the Nodepod runtime lifecycle.
|
|
35
|
-
*
|
|
36
|
-
* Responsibilities:
|
|
37
|
-
* - Boot Nodepod with COOP/COEP-compatible service worker
|
|
38
|
-
* - Write project files into the virtual filesystem
|
|
39
|
-
* - Install npm dependencies from package.json
|
|
40
|
-
* - Start the entry command (e.g., "node server.js")
|
|
41
|
-
* - Track file modifications for diffing
|
|
42
|
-
* - Provide preview URL for the iframe
|
|
43
|
-
*/
|
|
44
|
-
export class NodepodRuntime {
|
|
45
|
-
private nodepod: Nodepod | null = null;
|
|
46
|
-
private serverProcess: NodepodProcess | null = null;
|
|
47
|
-
private config: RuntimeConfig;
|
|
48
|
-
private originalFiles: FileMap = {};
|
|
49
|
-
private currentFiles: FileMap = {};
|
|
50
|
-
private terminalOutput: string[] = [];
|
|
51
|
-
private status: BootStage = "initializing";
|
|
52
|
-
private error: string | null = null;
|
|
53
|
-
private previewUrl: string | null = null;
|
|
54
|
-
private errors: SandboxError[] = [];
|
|
55
|
-
private errorIdCounter = 0;
|
|
56
|
-
|
|
57
|
-
// Callbacks
|
|
58
|
-
private onProgress: ProgressCallback | null = null;
|
|
59
|
-
private onOutput: OutputCallback | null = null;
|
|
60
|
-
private onServerReady: ServerReadyCallback | null = null;
|
|
61
|
-
private onSandboxError: ErrorCallback | null = null;
|
|
62
|
-
|
|
63
|
-
constructor(config: RuntimeConfig) {
|
|
64
|
-
this.config = {
|
|
65
|
-
workdir: "/app",
|
|
66
|
-
port: 3000,
|
|
67
|
-
...config,
|
|
68
|
-
};
|
|
69
|
-
console.log(DBG, "constructor", {
|
|
70
|
-
workdir: this.config.workdir,
|
|
71
|
-
port: this.config.port,
|
|
72
|
-
entryCommand: this.config.entryCommand,
|
|
73
|
-
fileCount: Object.keys(this.config.files).length,
|
|
74
|
-
files: Object.keys(this.config.files),
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Register a progress callback */
|
|
79
|
-
setProgressCallback(cb: ProgressCallback): void {
|
|
80
|
-
this.onProgress = cb;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Register a terminal output callback */
|
|
84
|
-
setOutputCallback(cb: OutputCallback): void {
|
|
85
|
-
this.onOutput = cb;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Register a server ready callback */
|
|
89
|
-
setServerReadyCallback(cb: ServerReadyCallback): void {
|
|
90
|
-
this.onServerReady = cb;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Register a structured error callback */
|
|
94
|
-
setErrorCallback(cb: ErrorCallback): void {
|
|
95
|
-
this.onSandboxError = cb;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Get the current preview URL (null if server not ready) */
|
|
99
|
-
getPreviewUrl(): string | null {
|
|
100
|
-
return this.previewUrl;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Get current status */
|
|
104
|
-
getStatus(): BootStage {
|
|
105
|
-
return this.status;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Get all terminal output */
|
|
109
|
-
getTerminalOutput(): string[] {
|
|
110
|
-
return [...this.terminalOutput];
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Get current files (with edits applied) */
|
|
114
|
-
getCurrentFiles(): FileMap {
|
|
115
|
-
return { ...this.currentFiles };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Get the original files (as loaded, before any edits) */
|
|
119
|
-
getOriginalFiles(): FileMap {
|
|
120
|
-
return { ...this.originalFiles };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Get only the files that have been modified since loading */
|
|
124
|
-
getChangedFiles(): FileMap {
|
|
125
|
-
const changes: FileMap = {};
|
|
126
|
-
for (const [path, content] of Object.entries(this.currentFiles)) {
|
|
127
|
-
if (this.originalFiles[path] !== content) {
|
|
128
|
-
changes[path] = content;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return changes;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Get the error message if status is 'error' */
|
|
135
|
-
getError(): string | null {
|
|
136
|
-
return this.error;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Get all structured errors collected during this session */
|
|
140
|
-
getErrors(): SandboxError[] {
|
|
141
|
-
return [...this.errors];
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Report an external error (e.g., from the preview iframe).
|
|
146
|
-
*
|
|
147
|
-
* This is the public entry point for errors that originate outside
|
|
148
|
-
* the runtime — browser console errors, unhandled rejections, etc.
|
|
149
|
-
* The runtime enriches them with source context from the virtual FS
|
|
150
|
-
* and emits them via the onSandboxError callback.
|
|
151
|
-
*/
|
|
152
|
-
async reportError(error: SandboxError): Promise<void> {
|
|
153
|
-
// Enrich with source context if we have a file path and line number
|
|
154
|
-
if (error.filePath && error.line && !error.sourceContext) {
|
|
155
|
-
error.sourceContext = await this.getSourceContext(
|
|
156
|
-
error.filePath,
|
|
157
|
-
error.line,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
this.errors.push(error);
|
|
161
|
-
this.onSandboxError?.(error);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// -------------------------------------------------------------------------
|
|
165
|
-
// Lifecycle
|
|
166
|
-
// -------------------------------------------------------------------------
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Boot the runtime: initialize Nodepod → write files → install → start server.
|
|
170
|
-
*
|
|
171
|
-
* This is the main entry point. Call this once.
|
|
172
|
-
* The progress callback will fire at each stage.
|
|
173
|
-
*/
|
|
174
|
-
async boot(): Promise<void> {
|
|
175
|
-
const t0 = performance.now();
|
|
176
|
-
console.log(DBG, "boot() started");
|
|
177
|
-
|
|
178
|
-
try {
|
|
179
|
-
// Stage 1: Initialize Nodepod
|
|
180
|
-
this.emitProgress("initializing", "Booting runtime environment...", 5);
|
|
181
|
-
|
|
182
|
-
const port = this.config.port ?? 3000;
|
|
183
|
-
console.log(DBG, "boot: calling Nodepod.boot()", {
|
|
184
|
-
swUrl: "/__sw__.js",
|
|
185
|
-
workdir: this.config.workdir,
|
|
186
|
-
port,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
// Check preconditions Nodepod will check
|
|
190
|
-
console.log(
|
|
191
|
-
DBG,
|
|
192
|
-
"boot: SharedArrayBuffer available?",
|
|
193
|
-
typeof SharedArrayBuffer !== "undefined",
|
|
194
|
-
);
|
|
195
|
-
console.log(
|
|
196
|
-
DBG,
|
|
197
|
-
"boot: Worker available?",
|
|
198
|
-
typeof Worker !== "undefined",
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
const tBoot = performance.now();
|
|
202
|
-
this.nodepod = await Nodepod.boot({
|
|
203
|
-
swUrl: "/__sw__.js",
|
|
204
|
-
workdir: this.config.workdir,
|
|
205
|
-
env: this.config.env,
|
|
206
|
-
onServerReady: (readyPort: number, url: string) => {
|
|
207
|
-
console.log(DBG, "onServerReady fired!", {
|
|
208
|
-
readyPort,
|
|
209
|
-
url,
|
|
210
|
-
expectedPort: port,
|
|
211
|
-
});
|
|
212
|
-
if (readyPort === port) {
|
|
213
|
-
this.previewUrl = this.rewritePreviewUrl(url);
|
|
214
|
-
console.log(DBG, "previewUrl set to:", this.previewUrl);
|
|
215
|
-
this.emitProgress("ready", "Application is running", 100);
|
|
216
|
-
this.onServerReady?.(readyPort, this.previewUrl);
|
|
217
|
-
}
|
|
218
|
-
},
|
|
219
|
-
});
|
|
220
|
-
console.log(
|
|
221
|
-
DBG,
|
|
222
|
-
`boot: Nodepod.boot() completed in ${(performance.now() - tBoot).toFixed(0)}ms`,
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
this.emitProgress("initializing", "Runtime environment ready", 15);
|
|
226
|
-
|
|
227
|
-
// Stage 2: Write project files
|
|
228
|
-
this.emitProgress("writing-files", "Writing project files...", 20);
|
|
229
|
-
const tFiles = performance.now();
|
|
230
|
-
await this.writeFiles(this.config.files);
|
|
231
|
-
console.log(
|
|
232
|
-
DBG,
|
|
233
|
-
`boot: writeFiles completed in ${(performance.now() - tFiles).toFixed(0)}ms`,
|
|
234
|
-
);
|
|
235
|
-
this.emitProgress(
|
|
236
|
-
"writing-files",
|
|
237
|
-
`Wrote ${Object.keys(this.config.files).length} files`,
|
|
238
|
-
35,
|
|
239
|
-
);
|
|
240
|
-
|
|
241
|
-
// Verify files were actually written
|
|
242
|
-
try {
|
|
243
|
-
const workdir = this.config.workdir ?? "/app";
|
|
244
|
-
const testFile = Object.keys(this.config.files)[0];
|
|
245
|
-
if (testFile) {
|
|
246
|
-
const fullPath = testFile.startsWith("/")
|
|
247
|
-
? testFile
|
|
248
|
-
: `${workdir}/${testFile}`;
|
|
249
|
-
const exists = await this.nodepod.fs.exists(fullPath);
|
|
250
|
-
console.log(DBG, `boot: verify file "${fullPath}" exists:`, exists);
|
|
251
|
-
if (exists) {
|
|
252
|
-
const content = await this.nodepod.fs.readFile(fullPath, "utf-8");
|
|
253
|
-
console.log(
|
|
254
|
-
DBG,
|
|
255
|
-
`boot: verify file "${fullPath}" has ${content.length} chars`,
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
} catch (verifyErr) {
|
|
260
|
-
console.warn(DBG, "boot: file verification failed:", verifyErr);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Stage 3: Install dependencies (if package.json exists)
|
|
264
|
-
if (this.config.files["package.json"]) {
|
|
265
|
-
this.emitProgress("installing", "Installing dependencies...", 40);
|
|
266
|
-
const tInstall = performance.now();
|
|
267
|
-
await this.installDependencies();
|
|
268
|
-
console.log(
|
|
269
|
-
DBG,
|
|
270
|
-
`boot: installDependencies completed in ${(performance.now() - tInstall).toFixed(0)}ms`,
|
|
271
|
-
);
|
|
272
|
-
this.emitProgress("installing", "Dependencies installed", 70);
|
|
273
|
-
} else {
|
|
274
|
-
console.log(DBG, "boot: no package.json found, skipping install");
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Stage 4: Start the entry command
|
|
278
|
-
this.emitProgress("starting", "Starting application...", 75);
|
|
279
|
-
const tStart = performance.now();
|
|
280
|
-
await this.startServer();
|
|
281
|
-
console.log(
|
|
282
|
-
DBG,
|
|
283
|
-
`boot: startServer returned in ${(performance.now() - tStart).toFixed(0)}ms`,
|
|
284
|
-
);
|
|
285
|
-
console.log(
|
|
286
|
-
DBG,
|
|
287
|
-
`boot: total elapsed ${(performance.now() - t0).toFixed(0)}ms, status=${this.status}`,
|
|
288
|
-
);
|
|
289
|
-
|
|
290
|
-
// Note: 'ready' is emitted by the onServerReady callback above.
|
|
291
|
-
// If the server doesn't register a port (e.g., non-HTTP scripts),
|
|
292
|
-
// we mark ready after a short delay.
|
|
293
|
-
// Also poll more aggressively: check at 1s, 3s, 5s, 10s
|
|
294
|
-
const fallbackDelays = [1000, 3000, 5000, 10000];
|
|
295
|
-
for (const delay of fallbackDelays) {
|
|
296
|
-
setTimeout(() => {
|
|
297
|
-
if (this.status === "starting") {
|
|
298
|
-
// Check if we got a preview URL via port registration
|
|
299
|
-
const url = this.nodepod?.port(port);
|
|
300
|
-
console.log(
|
|
301
|
-
DBG,
|
|
302
|
-
`boot: fallback check @${delay}ms, port(${port}) =`,
|
|
303
|
-
url,
|
|
304
|
-
"status =",
|
|
305
|
-
this.status,
|
|
306
|
-
);
|
|
307
|
-
if (url) {
|
|
308
|
-
this.previewUrl = this.rewritePreviewUrl(url);
|
|
309
|
-
console.log(DBG, "fallback: previewUrl set to:", this.previewUrl);
|
|
310
|
-
this.emitProgress("ready", "Application is running", 100);
|
|
311
|
-
this.onServerReady?.(port, this.previewUrl);
|
|
312
|
-
} else if (delay === fallbackDelays[fallbackDelays.length - 1]) {
|
|
313
|
-
// Last check - report failure
|
|
314
|
-
console.error(
|
|
315
|
-
DBG,
|
|
316
|
-
"boot: server never became ready after 10s. Process state:",
|
|
317
|
-
{
|
|
318
|
-
processExited: this.serverProcess?.exited,
|
|
319
|
-
previewUrl: this.previewUrl,
|
|
320
|
-
status: this.status,
|
|
321
|
-
},
|
|
322
|
-
);
|
|
323
|
-
this.appendOutput(
|
|
324
|
-
"[system] Server did not start within 10 seconds. Check the logs above for errors.",
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}, delay);
|
|
329
|
-
}
|
|
330
|
-
} catch (err) {
|
|
331
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
332
|
-
const stack = err instanceof Error ? err.stack : undefined;
|
|
333
|
-
console.error(DBG, "boot: FATAL ERROR:", err);
|
|
334
|
-
this.error = message;
|
|
335
|
-
this.emitProgress("error", `Error: ${message}`, 0);
|
|
336
|
-
this.emitError("boot", message, { stack });
|
|
337
|
-
throw err;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Write a single file to the virtual filesystem.
|
|
343
|
-
* Used for editor changes after boot.
|
|
344
|
-
*/
|
|
345
|
-
async writeFile(path: string, content: string): Promise<void> {
|
|
346
|
-
if (!this.nodepod) {
|
|
347
|
-
throw new Error("Runtime not booted");
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const fullPath = this.resolvePath(path);
|
|
351
|
-
await this.nodepod.fs.writeFile(fullPath, content);
|
|
352
|
-
this.currentFiles[path] = content;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Read a file from the virtual filesystem.
|
|
357
|
-
*/
|
|
358
|
-
async readFile(path: string): Promise<string> {
|
|
359
|
-
if (!this.nodepod) {
|
|
360
|
-
throw new Error("Runtime not booted");
|
|
361
|
-
}
|
|
362
|
-
const fullPath = this.resolvePath(path);
|
|
363
|
-
return await this.nodepod.fs.readFile(fullPath, "utf-8");
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Restart the server process.
|
|
368
|
-
* Kills the current process and re-runs the entry command.
|
|
369
|
-
*/
|
|
370
|
-
async restart(): Promise<void> {
|
|
371
|
-
if (this.serverProcess && !this.serverProcess.exited) {
|
|
372
|
-
this.serverProcess.kill();
|
|
373
|
-
// Wait for the process to exit
|
|
374
|
-
await this.serverProcess.completion.catch(() => {});
|
|
375
|
-
}
|
|
376
|
-
this.previewUrl = null;
|
|
377
|
-
this.emitProgress("starting", "Restarting application...", 75);
|
|
378
|
-
await this.startServer();
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* Tear down the runtime.
|
|
383
|
-
* Kills all processes and releases resources.
|
|
384
|
-
*/
|
|
385
|
-
teardown(): void {
|
|
386
|
-
if (this.serverProcess && !this.serverProcess.exited) {
|
|
387
|
-
this.serverProcess.kill();
|
|
388
|
-
}
|
|
389
|
-
this.nodepod?.teardown();
|
|
390
|
-
this.nodepod = null;
|
|
391
|
-
this.serverProcess = null;
|
|
392
|
-
this.previewUrl = null;
|
|
393
|
-
this.status = "initializing";
|
|
394
|
-
this.terminalOutput = [];
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/** Get raw Nodepod instance for advanced usage */
|
|
398
|
-
getNodepod(): Nodepod | null {
|
|
399
|
-
return this.nodepod;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// -------------------------------------------------------------------------
|
|
403
|
-
// Private helpers
|
|
404
|
-
// -------------------------------------------------------------------------
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* Rewrite a Nodepod virtual server URL to use /__preview__/ instead of /__virtual__/.
|
|
408
|
-
*
|
|
409
|
-
* WHY: Nodepod's serverUrl() returns /__virtual__/{port} URLs, but the
|
|
410
|
-
* Service Worker only tracks iframe client IDs for /__preview__/{port}
|
|
411
|
-
* navigations. Without client tracking, subresource requests (JS, CSS,
|
|
412
|
-
* API calls) from the preview iframe are NOT intercepted by the SW —
|
|
413
|
-
* they fall through to the real dev server and 404.
|
|
414
|
-
*/
|
|
415
|
-
private rewritePreviewUrl(url: string): string {
|
|
416
|
-
return url.replace("/__virtual__/", "/__preview__/");
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/** Resolve a relative path against the workdir */
|
|
420
|
-
private resolvePath(relativePath: string): string {
|
|
421
|
-
const workdir = this.config.workdir ?? "/app";
|
|
422
|
-
// Normalize: remove leading ./ and ensure proper path joining
|
|
423
|
-
const clean = relativePath.replace(/^\.\//, "");
|
|
424
|
-
if (clean.startsWith("/")) {
|
|
425
|
-
return clean;
|
|
426
|
-
}
|
|
427
|
-
return `${workdir}/${clean}`;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/** Write all project files to the virtual filesystem */
|
|
431
|
-
private async writeFiles(files: FileMap): Promise<void> {
|
|
432
|
-
if (!this.nodepod) {
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Store originals for change tracking
|
|
437
|
-
this.originalFiles = { ...files };
|
|
438
|
-
this.currentFiles = { ...files };
|
|
439
|
-
|
|
440
|
-
// Write files in parallel batches of 10
|
|
441
|
-
const entries = Object.entries(files);
|
|
442
|
-
const batchSize = 10;
|
|
443
|
-
|
|
444
|
-
for (let i = 0; i < entries.length; i += batchSize) {
|
|
445
|
-
const batch = entries.slice(i, i + batchSize);
|
|
446
|
-
await Promise.all(
|
|
447
|
-
batch.map(([path, content]) => {
|
|
448
|
-
const fullPath = this.resolvePath(path);
|
|
449
|
-
return this.nodepod!.fs.writeFile(fullPath, content);
|
|
450
|
-
}),
|
|
451
|
-
);
|
|
452
|
-
|
|
453
|
-
// Update progress within the writing-files stage
|
|
454
|
-
const percent = 20 + Math.round((i / entries.length) * 15);
|
|
455
|
-
this.emitProgress(
|
|
456
|
-
"writing-files",
|
|
457
|
-
`Writing files... (${Math.min(i + batchSize, entries.length)}/${entries.length})`,
|
|
458
|
-
percent,
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Install npm dependencies by spawning `npm install` in Nodepod.
|
|
465
|
-
*
|
|
466
|
-
* Nodepod's DependencyInstaller handles this internally via its
|
|
467
|
-
* package registry. We spawn `node -e` with a require to trigger it,
|
|
468
|
-
* or use the built-in npm install mechanism.
|
|
469
|
-
*/
|
|
470
|
-
private async installDependencies(): Promise<void> {
|
|
471
|
-
if (!this.nodepod) {
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
const workdir = this.config.workdir ?? "/app";
|
|
476
|
-
|
|
477
|
-
try {
|
|
478
|
-
// Parse package.json to find dependencies
|
|
479
|
-
const pkgJsonContent = this.config.files["package.json"];
|
|
480
|
-
if (!pkgJsonContent) {
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const pkgJson = JSON.parse(pkgJsonContent);
|
|
485
|
-
const deps = {
|
|
486
|
-
...(pkgJson.dependencies || {}),
|
|
487
|
-
...(pkgJson.devDependencies || {}),
|
|
488
|
-
};
|
|
489
|
-
|
|
490
|
-
const depNames = Object.keys(deps);
|
|
491
|
-
if (depNames.length === 0) {
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
this.appendOutput(`Installing ${depNames.length} dependencies...`);
|
|
496
|
-
|
|
497
|
-
// Use Nodepod's built-in package installer for each dependency
|
|
498
|
-
for (let i = 0; i < depNames.length; i++) {
|
|
499
|
-
const dep = depNames[i];
|
|
500
|
-
const version = deps[dep];
|
|
501
|
-
const specifier = version
|
|
502
|
-
? `${dep}@${version.replace(/[\^~>=<]/g, "")}`
|
|
503
|
-
: dep;
|
|
504
|
-
|
|
505
|
-
try {
|
|
506
|
-
await this.nodepod.packages.install(specifier);
|
|
507
|
-
this.appendOutput(` ✓ ${dep}`);
|
|
508
|
-
} catch (err) {
|
|
509
|
-
this.appendOutput(
|
|
510
|
-
` ✗ ${dep}: ${err instanceof Error ? err.message : err}`,
|
|
511
|
-
);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Update progress
|
|
515
|
-
const percent = 40 + Math.round((i / depNames.length) * 30);
|
|
516
|
-
this.emitProgress(
|
|
517
|
-
"installing",
|
|
518
|
-
`Installing ${dep}... (${i + 1}/${depNames.length})`,
|
|
519
|
-
percent,
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
this.appendOutput("Dependencies installed.");
|
|
524
|
-
} catch (err) {
|
|
525
|
-
this.appendOutput(`Warning: Could not parse package.json: ${err}`);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/** Start the entry command (e.g., "node server.js") */
|
|
530
|
-
private async startServer(): Promise<void> {
|
|
531
|
-
if (!this.nodepod) {
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
const workdir = this.config.workdir ?? "/app";
|
|
536
|
-
const command = this.config.entryCommand;
|
|
537
|
-
|
|
538
|
-
// Parse the command: "node server.js" → cmd="node", args=["server.js"]
|
|
539
|
-
const parts = command.split(/\s+/);
|
|
540
|
-
const cmd = parts[0];
|
|
541
|
-
const args = parts.slice(1);
|
|
542
|
-
|
|
543
|
-
this.appendOutput(`\n$ ${command}`);
|
|
544
|
-
|
|
545
|
-
this.serverProcess = await this.nodepod.spawn(cmd, args, { cwd: workdir });
|
|
546
|
-
|
|
547
|
-
this.serverProcess.on("output", (chunk: string) => {
|
|
548
|
-
this.appendOutput(chunk);
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
this.serverProcess.on("error", (chunk: string) => {
|
|
552
|
-
this.appendOutput(`[stderr] ${chunk}`);
|
|
553
|
-
// Emit structured error for stderr output.
|
|
554
|
-
// Filter out noise: skip empty lines and common non-error warnings.
|
|
555
|
-
const trimmed = chunk.trim();
|
|
556
|
-
if (trimmed && !this.isStderrNoise(trimmed)) {
|
|
557
|
-
this.emitError("process-stderr", trimmed);
|
|
558
|
-
}
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
this.serverProcess.on("exit", (code: number) => {
|
|
562
|
-
this.appendOutput(`\nProcess exited with code ${code}`);
|
|
563
|
-
if (code !== 0 && this.status !== "ready") {
|
|
564
|
-
this.error = `Process exited with code ${code}`;
|
|
565
|
-
this.emitProgress("error", `Process exited with code ${code}`, 0);
|
|
566
|
-
// Emit structured error for non-zero exit
|
|
567
|
-
this.emitError("process-exit", `Process exited with code ${code}`, {
|
|
568
|
-
// Include last 20 lines of terminal output for context
|
|
569
|
-
stack: this.terminalOutput.slice(-20).join("\n"),
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/** Emit a progress update */
|
|
576
|
-
private emitProgress(
|
|
577
|
-
stage: BootStage,
|
|
578
|
-
message: string,
|
|
579
|
-
percent: number,
|
|
580
|
-
): void {
|
|
581
|
-
this.status = stage;
|
|
582
|
-
const progress: BootProgress = { stage, message, percent };
|
|
583
|
-
this.onProgress?.(progress);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/** Append a line to terminal output */
|
|
587
|
-
private appendOutput(line: string): void {
|
|
588
|
-
this.terminalOutput.push(line);
|
|
589
|
-
this.onOutput?.(line);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// -------------------------------------------------------------------------
|
|
593
|
-
// Error helpers
|
|
594
|
-
// -------------------------------------------------------------------------
|
|
595
|
-
|
|
596
|
-
/** Generate a unique error ID */
|
|
597
|
-
private nextErrorId(): string {
|
|
598
|
-
return `err_${++this.errorIdCounter}_${Date.now()}`;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
/**
|
|
602
|
-
* Create and record a structured error from a process or boot event.
|
|
603
|
-
*
|
|
604
|
-
* Parses the error message to extract file path and line number when
|
|
605
|
-
* possible, then enriches with surrounding source context.
|
|
606
|
-
*/
|
|
607
|
-
private async emitError(
|
|
608
|
-
category: SandboxErrorCategory,
|
|
609
|
-
message: string,
|
|
610
|
-
extra?: Partial<SandboxError>,
|
|
611
|
-
): Promise<void> {
|
|
612
|
-
// Parse file/line from common Node.js error formats:
|
|
613
|
-
// - "/app/server.js:42"
|
|
614
|
-
// - "at Object.<anonymous> (/app/routes/api.js:15:8)"
|
|
615
|
-
// - "SyntaxError: /app/server.js: Unexpected token (12:5)"
|
|
616
|
-
let filePath = extra?.filePath;
|
|
617
|
-
let line = extra?.line;
|
|
618
|
-
let column = extra?.column;
|
|
619
|
-
|
|
620
|
-
if (!filePath) {
|
|
621
|
-
const parsed = this.parseErrorLocation(message + (extra?.stack ?? ""));
|
|
622
|
-
filePath = parsed.filePath;
|
|
623
|
-
line = parsed.line ?? line;
|
|
624
|
-
column = parsed.column ?? column;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
let sourceContext = extra?.sourceContext;
|
|
628
|
-
if (filePath && line && !sourceContext) {
|
|
629
|
-
sourceContext = await this.getSourceContext(filePath, line);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
const error: SandboxError = {
|
|
633
|
-
id: this.nextErrorId(),
|
|
634
|
-
category,
|
|
635
|
-
message,
|
|
636
|
-
timestamp: new Date().toISOString(),
|
|
637
|
-
...extra,
|
|
638
|
-
filePath,
|
|
639
|
-
line,
|
|
640
|
-
column,
|
|
641
|
-
sourceContext,
|
|
642
|
-
};
|
|
643
|
-
|
|
644
|
-
this.errors.push(error);
|
|
645
|
-
this.onSandboxError?.(error);
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* Filter out common non-error stderr output.
|
|
650
|
-
*
|
|
651
|
-
* Many Node.js tools write informational messages to stderr
|
|
652
|
-
* (deprecation warnings, experimental feature notices, etc.).
|
|
653
|
-
* We don't want to flood the agent with these.
|
|
654
|
-
*/
|
|
655
|
-
private isStderrNoise(text: string): boolean {
|
|
656
|
-
const noisePatterns = [
|
|
657
|
-
/^DeprecationWarning:/,
|
|
658
|
-
/^ExperimentalWarning:/,
|
|
659
|
-
/^\(node:\d+\) /,
|
|
660
|
-
/^npm WARN /,
|
|
661
|
-
/^warn /i,
|
|
662
|
-
/^Debugger listening/,
|
|
663
|
-
/^Debugger attached/,
|
|
664
|
-
/^Waiting for the debugger/,
|
|
665
|
-
];
|
|
666
|
-
return noisePatterns.some((p) => p.test(text));
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
/**
|
|
670
|
-
* Parse file path and line/column from common error message formats.
|
|
671
|
-
*
|
|
672
|
-
* Handles:
|
|
673
|
-
* - Node.js stack frames: "at func (/app/file.js:12:5)"
|
|
674
|
-
* - Direct references: "/app/file.js:12:5"
|
|
675
|
-
* - SyntaxError: "/app/file.js: Unexpected token (12:5)"
|
|
676
|
-
*/
|
|
677
|
-
private parseErrorLocation(text: string): {
|
|
678
|
-
filePath?: string;
|
|
679
|
-
line?: number;
|
|
680
|
-
column?: number;
|
|
681
|
-
} {
|
|
682
|
-
const workdir = this.config.workdir ?? "/app";
|
|
683
|
-
|
|
684
|
-
// Pattern 1: "at ... (/app/path/file.js:LINE:COL)"
|
|
685
|
-
const stackMatch = text.match(/\(?(\/[^:)]+):(\d+):(\d+)\)?/);
|
|
686
|
-
if (stackMatch) {
|
|
687
|
-
const absPath = stackMatch[1];
|
|
688
|
-
const relativePath = absPath.startsWith(workdir + "/")
|
|
689
|
-
? absPath.slice(workdir.length + 1)
|
|
690
|
-
: absPath;
|
|
691
|
-
return {
|
|
692
|
-
filePath: relativePath,
|
|
693
|
-
line: parseInt(stackMatch[2], 10),
|
|
694
|
-
column: parseInt(stackMatch[3], 10),
|
|
695
|
-
};
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// Pattern 2: "file.js:LINE" (no column)
|
|
699
|
-
const simpleMatch = text.match(/\(?(\/[^:)]+):(\d+)\)?/);
|
|
700
|
-
if (simpleMatch) {
|
|
701
|
-
const absPath = simpleMatch[1];
|
|
702
|
-
const relativePath = absPath.startsWith(workdir + "/")
|
|
703
|
-
? absPath.slice(workdir.length + 1)
|
|
704
|
-
: absPath;
|
|
705
|
-
return {
|
|
706
|
-
filePath: relativePath,
|
|
707
|
-
line: parseInt(simpleMatch[2], 10),
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// Pattern 3: "Unexpected token (LINE:COL)" with a file path earlier
|
|
712
|
-
const syntaxMatch = text.match(/(\/[^\s:]+)\s*:\s*[^(]*\((\d+):(\d+)\)/);
|
|
713
|
-
if (syntaxMatch) {
|
|
714
|
-
const absPath = syntaxMatch[1];
|
|
715
|
-
const relativePath = absPath.startsWith(workdir + "/")
|
|
716
|
-
? absPath.slice(workdir.length + 1)
|
|
717
|
-
: absPath;
|
|
718
|
-
return {
|
|
719
|
-
filePath: relativePath,
|
|
720
|
-
line: parseInt(syntaxMatch[2], 10),
|
|
721
|
-
column: parseInt(syntaxMatch[3], 10),
|
|
722
|
-
};
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
return {};
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
/**
|
|
729
|
-
* Get surrounding source code lines from the virtual filesystem.
|
|
730
|
-
*
|
|
731
|
-
* Returns ~10 lines centered on the target line, formatted as
|
|
732
|
-
* "lineNum: content" for each line. This gives the AI agent enough
|
|
733
|
-
* context to construct a surgical fix.
|
|
734
|
-
*/
|
|
735
|
-
private async getSourceContext(
|
|
736
|
-
filePath: string,
|
|
737
|
-
targetLine: number,
|
|
738
|
-
windowSize = 5,
|
|
739
|
-
): Promise<string | undefined> {
|
|
740
|
-
try {
|
|
741
|
-
const content = this.currentFiles[filePath];
|
|
742
|
-
if (!content) {
|
|
743
|
-
// Try reading from the FS directly (file might have been written
|
|
744
|
-
// outside of our tracking, e.g., by npm install)
|
|
745
|
-
if (!this.nodepod) return undefined;
|
|
746
|
-
const fullPath = this.resolvePath(filePath);
|
|
747
|
-
const exists = await this.nodepod.fs.exists(fullPath);
|
|
748
|
-
if (!exists) return undefined;
|
|
749
|
-
const fsContent = await this.nodepod.fs.readFile(fullPath, "utf-8");
|
|
750
|
-
return this.formatSourceContext(fsContent, targetLine, windowSize);
|
|
751
|
-
}
|
|
752
|
-
return this.formatSourceContext(content, targetLine, windowSize);
|
|
753
|
-
} catch {
|
|
754
|
-
return undefined;
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/**
|
|
759
|
-
* Format source code lines centered on a target line.
|
|
760
|
-
* Output: "lineNum: content\n" for each line in the window.
|
|
761
|
-
*/
|
|
762
|
-
private formatSourceContext(
|
|
763
|
-
content: string,
|
|
764
|
-
targetLine: number,
|
|
765
|
-
windowSize: number,
|
|
766
|
-
): string {
|
|
767
|
-
const lines = content.split("\n");
|
|
768
|
-
const start = Math.max(0, targetLine - windowSize - 1);
|
|
769
|
-
const end = Math.min(lines.length, targetLine + windowSize);
|
|
770
|
-
return lines
|
|
771
|
-
.slice(start, end)
|
|
772
|
-
.map((l, i) => `${start + i + 1}: ${l}`)
|
|
773
|
-
.join("\n");
|
|
774
|
-
}
|
|
775
|
-
}
|