@illuma-ai/code-sandbox 1.0.0 → 1.2.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.
@@ -3,6 +3,15 @@
3
3
  *
4
4
  * Manages the Nodepod runtime instance and exposes reactive state
5
5
  * for all UI components (progress, files, terminal output, preview URL).
6
+ *
7
+ * The sandbox is a pure renderer — it receives files via props (initial
8
+ * load) and via the imperative handle (updateFiles/updateFile for live
9
+ * updates). It does NOT interact with any storage backend directly.
10
+ *
11
+ * Error exposure: wires the runtime's structured error system and the
12
+ * preview iframe's browser error capture into a unified `errors[]` array
13
+ * in state, and forwards each error to the `onSandboxError` callback
14
+ * so the AI agent can auto-fix.
6
15
  */
7
16
 
8
17
  import { useCallback, useEffect, useRef, useState } from "react";
@@ -11,15 +20,67 @@ import { getTemplate } from "../templates";
11
20
  import type {
12
21
  BootProgress,
13
22
  CodeSandboxProps,
23
+ FileChangeStatus,
14
24
  FileMap,
15
25
  RuntimeState,
26
+ SandboxError,
16
27
  } from "../types";
17
28
 
29
+ /** Debug log prefix for easy filtering in DevTools */
30
+ const DBG = "[CodeSandbox:useRuntime]";
31
+
32
+ /**
33
+ * Compute per-file change statuses between an original and current file set.
34
+ *
35
+ * Returns only files with a non-"unchanged" status (new, modified, deleted).
36
+ * Files that are identical are omitted — treat missing keys as "unchanged".
37
+ */
38
+ function computeFileChanges(
39
+ original: FileMap,
40
+ current: FileMap,
41
+ ): Record<string, FileChangeStatus> {
42
+ const changes: Record<string, FileChangeStatus> = {};
43
+
44
+ // Check current files against original
45
+ for (const path of Object.keys(current)) {
46
+ if (!(path in original)) {
47
+ changes[path] = "new";
48
+ } else if (original[path] !== current[path]) {
49
+ changes[path] = "modified";
50
+ }
51
+ // unchanged — omit
52
+ }
53
+
54
+ // Check for deleted files (in original but not in current)
55
+ for (const path of Object.keys(original)) {
56
+ if (!(path in current)) {
57
+ changes[path] = "deleted";
58
+ }
59
+ }
60
+
61
+ return changes;
62
+ }
63
+
18
64
  /**
19
65
  * Hook that manages the full Nodepod runtime lifecycle.
20
66
  *
67
+ * The sandbox is a pure renderer — files come in via props or the
68
+ * imperative handle. No GitHub, no polling, no storage backends.
69
+ *
21
70
  * @param props - CodeSandbox component props
22
- * @returns Reactive runtime state + control functions
71
+ * @returns Reactive runtime state + control functions + imperative methods
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * // Direct files (fetched from any source by the host app)
76
+ * const runtime = useRuntime({ files: myFiles, entryCommand: 'node server.js' });
77
+ *
78
+ * // Later, push updated files from the host:
79
+ * await runtime.updateFiles(newFiles);
80
+ *
81
+ * // Or update a single file:
82
+ * await runtime.updateFile('server.js', newContent);
83
+ * ```
23
84
  */
24
85
  export function useRuntime(props: CodeSandboxProps) {
25
86
  const {
@@ -32,10 +93,19 @@ export function useRuntime(props: CodeSandboxProps) {
32
93
  onServerReady,
33
94
  onProgress,
34
95
  onError,
96
+ onSandboxError,
97
+ onFilesUpdated,
35
98
  } = props;
36
99
 
37
100
  const runtimeRef = useRef<NodepodRuntime | null>(null);
38
101
  const bootedRef = useRef(false);
102
+ /** Ref for the latest onSandboxError callback (avoids stale closures) */
103
+ const onSandboxErrorRef = useRef(onSandboxError);
104
+ onSandboxErrorRef.current = onSandboxError;
105
+
106
+ /** Ref for latest onFilesUpdated callback */
107
+ const onFilesUpdatedRef = useRef(onFilesUpdated);
108
+ onFilesUpdatedRef.current = onFilesUpdated;
39
109
 
40
110
  const [state, setState] = useState<RuntimeState>({
41
111
  status: "initializing",
@@ -43,18 +113,78 @@ export function useRuntime(props: CodeSandboxProps) {
43
113
  previewUrl: null,
44
114
  terminalOutput: [],
45
115
  files: {},
116
+ originalFiles: {},
117
+ fileChanges: {},
46
118
  error: null,
119
+ errors: [],
47
120
  });
48
121
 
49
122
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
50
123
  const [openFiles, setOpenFiles] = useState<string[]>([]);
51
124
 
52
- // Resolve files + entry command from props or template
125
+ // -------------------------------------------------------------------------
126
+ // Error handling — unified pipeline
127
+ // -------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Handle a structured error from any source (runtime or browser).
131
+ *
132
+ * Appends to the errors array in state and forwards to onSandboxError.
133
+ * The runtime calls reportError for process errors; the Preview
134
+ * component calls handleBrowserError for iframe errors.
135
+ */
136
+ const handleSandboxError = useCallback((error: SandboxError) => {
137
+ setState((prev) => ({
138
+ ...prev,
139
+ errors: [...prev.errors, error],
140
+ }));
141
+ onSandboxErrorRef.current?.(error);
142
+ }, []);
143
+
144
+ /**
145
+ * Handle a browser error from the preview iframe.
146
+ *
147
+ * Called by the Preview component's onBrowserError callback.
148
+ * We enrich the error with source context from the runtime
149
+ * (if the file exists in our virtual FS) then record it.
150
+ */
151
+ const handleBrowserError = useCallback(
152
+ async (error: SandboxError) => {
153
+ const runtime = runtimeRef.current;
154
+ if (runtime) {
155
+ // reportError enriches with source context and records internally
156
+ await runtime.reportError(error);
157
+ }
158
+ // Also update React state
159
+ handleSandboxError(error);
160
+ },
161
+ [handleSandboxError],
162
+ );
163
+
164
+ // -------------------------------------------------------------------------
165
+ // Config resolution
166
+ // -------------------------------------------------------------------------
167
+
168
+ /**
169
+ * Resolve files + entry command from props or template.
170
+ */
53
171
  const resolvedConfig = useCallback(() => {
54
- if (propFiles) {
172
+ if (propFiles && Object.keys(propFiles).length > 0) {
173
+ // Infer entry command from package.json if not provided
174
+ let entryCommand = propEntryCommand || "node server.js";
175
+ if (!propEntryCommand && propFiles["package.json"]) {
176
+ try {
177
+ const pkg = JSON.parse(propFiles["package.json"]);
178
+ if (pkg.scripts?.start) {
179
+ entryCommand = pkg.scripts.start;
180
+ }
181
+ } catch {
182
+ /* use default */
183
+ }
184
+ }
55
185
  return {
56
186
  files: propFiles,
57
- entryCommand: propEntryCommand || "node server.js",
187
+ entryCommand,
58
188
  port: propPort || 3000,
59
189
  };
60
190
  }
@@ -70,131 +200,309 @@ export function useRuntime(props: CodeSandboxProps) {
70
200
  }
71
201
  }
72
202
 
73
- // Default: empty project
74
- return {
75
- files: {},
76
- entryCommand: propEntryCommand || "node server.js",
77
- port: propPort || 3000,
78
- };
203
+ // No files provided — return null (sandbox waits for updateFiles)
204
+ return null;
79
205
  }, [propFiles, template, propEntryCommand, propPort]);
80
206
 
81
- // Boot the runtime on mount
82
- useEffect(() => {
83
- if (bootedRef.current) {
84
- return;
85
- }
86
- bootedRef.current = true;
87
-
88
- const config = resolvedConfig();
89
- if (Object.keys(config.files).length === 0) {
90
- return;
91
- }
92
-
93
- const runtime = new NodepodRuntime({
94
- files: config.files,
95
- entryCommand: config.entryCommand,
96
- port: config.port,
97
- env,
98
- });
99
-
100
- runtimeRef.current = runtime;
207
+ // -------------------------------------------------------------------------
208
+ // Boot helpers
209
+ // -------------------------------------------------------------------------
101
210
 
102
- // Set initial files state + open the first file
103
- setState((prev) => ({
104
- ...prev,
105
- files: config.files,
106
- }));
211
+ /**
212
+ * Boot the runtime with a resolved file set.
213
+ */
214
+ const bootWithFiles = useCallback(
215
+ (files: FileMap, entryCommand: string, port: number) => {
216
+ const runtime = new NodepodRuntime({
217
+ files,
218
+ entryCommand,
219
+ port,
220
+ env,
221
+ });
107
222
 
108
- const fileNames = Object.keys(config.files);
109
- // Auto-select entry file or first file
110
- const entryFile =
111
- fileNames.find((f) => f === "server.js") ||
112
- fileNames.find((f) => f === "index.js") ||
113
- fileNames.find((f) => f.endsWith(".js")) ||
114
- fileNames[0];
115
-
116
- if (entryFile) {
117
- setSelectedFile(entryFile);
118
- setOpenFiles([entryFile]);
119
- }
223
+ runtimeRef.current = runtime;
120
224
 
121
- // Wire callbacks
122
- runtime.setProgressCallback((progress: BootProgress) => {
225
+ // Set initial files state + open the first file
123
226
  setState((prev) => ({
124
227
  ...prev,
125
- status: progress.stage,
126
- progress,
127
- previewUrl: runtime.getPreviewUrl(),
128
- error: progress.stage === "error" ? progress.message : prev.error,
228
+ files,
129
229
  }));
130
- onProgress?.(progress);
131
- if (progress.stage === "error") {
132
- onError?.(progress.message);
230
+
231
+ const fileNames = Object.keys(files);
232
+ const entryFile =
233
+ fileNames.find((f) => f === "server.js") ||
234
+ fileNames.find((f) => f === "index.js") ||
235
+ fileNames.find((f) => f.endsWith(".js")) ||
236
+ fileNames[0];
237
+
238
+ if (entryFile) {
239
+ setSelectedFile(entryFile);
240
+ setOpenFiles([entryFile]);
133
241
  }
134
- });
135
242
 
136
- runtime.setOutputCallback((line: string) => {
243
+ // Wire progress callback
244
+ runtime.setProgressCallback((progress: BootProgress) => {
245
+ setState((prev) => ({
246
+ ...prev,
247
+ status: progress.stage,
248
+ progress,
249
+ previewUrl: runtime.getPreviewUrl(),
250
+ error: progress.stage === "error" ? progress.message : prev.error,
251
+ }));
252
+ onProgress?.(progress);
253
+ if (progress.stage === "error") {
254
+ onError?.(progress.message);
255
+ }
256
+ });
257
+
258
+ // Wire terminal output callback
259
+ runtime.setOutputCallback((line: string) => {
260
+ setState((prev) => ({
261
+ ...prev,
262
+ terminalOutput: [...prev.terminalOutput, line],
263
+ }));
264
+ });
265
+
266
+ // Wire server ready callback
267
+ runtime.setServerReadyCallback((readyPort: number, url: string) => {
268
+ setState((prev) => ({
269
+ ...prev,
270
+ previewUrl: url,
271
+ status: "ready",
272
+ }));
273
+ onServerReady?.(readyPort, url);
274
+ });
275
+
276
+ // Wire structured error callback
277
+ runtime.setErrorCallback(handleSandboxError);
278
+
279
+ // Boot
280
+ runtime.boot().catch((err) => {
281
+ const msg = err instanceof Error ? err.message : String(err);
282
+ onError?.(msg);
283
+ });
284
+
285
+ return runtime;
286
+ },
287
+ [env, onProgress, onError, onServerReady, handleSandboxError],
288
+ );
289
+
290
+ // -------------------------------------------------------------------------
291
+ // Imperative file update methods
292
+ // -------------------------------------------------------------------------
293
+
294
+ /**
295
+ * Push a complete new file set into the sandbox.
296
+ *
297
+ * Diffs against current files, writes only changed files to Nodepod FS,
298
+ * and restarts the server if anything changed (unless restartServer=false).
299
+ *
300
+ * The previous file set becomes `originalFiles` for diff tracking.
301
+ *
302
+ * Called by the imperative handle's updateFiles() method.
303
+ * Also usable as the initial boot path when files arrive asynchronously
304
+ * (e.g., host fetches from GitHub/DB/S3, then calls updateFiles).
305
+ */
306
+ const updateFiles = useCallback(
307
+ async (newFiles: FileMap, options?: { restartServer?: boolean }) => {
308
+ const shouldRestart = options?.restartServer !== false;
309
+ const runtime = runtimeRef.current;
310
+
311
+ // If runtime hasn't booted yet, boot with these files
312
+ if (!runtime) {
313
+ if (bootedRef.current) {
314
+ console.warn(
315
+ DBG,
316
+ "updateFiles called but runtime is gone (already torn down)",
317
+ );
318
+ return;
319
+ }
320
+ bootedRef.current = true;
321
+
322
+ // Infer entry command from package.json
323
+ let entryCommand = propEntryCommand || "node server.js";
324
+ if (!propEntryCommand && newFiles["package.json"]) {
325
+ try {
326
+ const pkg = JSON.parse(newFiles["package.json"]);
327
+ if (pkg.scripts?.start) {
328
+ entryCommand = pkg.scripts.start;
329
+ }
330
+ } catch {
331
+ /* use default */
332
+ }
333
+ }
334
+ const port = propPort || 3000;
335
+
336
+ bootWithFiles(newFiles, entryCommand, port);
337
+ return;
338
+ }
339
+
340
+ // Runtime exists — diff and write changed files
341
+ const currentFiles = runtime.getCurrentFiles();
342
+ let changed = false;
343
+
344
+ for (const [path, content] of Object.entries(newFiles)) {
345
+ if (currentFiles[path] !== content) {
346
+ try {
347
+ await runtime.writeFile(path, content);
348
+ changed = true;
349
+ console.log(DBG, `updated file: ${path}`);
350
+ } catch (err) {
351
+ console.warn(DBG, `failed to write ${path}:`, err);
352
+ }
353
+ }
354
+ }
355
+
356
+ // Compute change status — "original" is what we had before this update
357
+ const fileChanges = computeFileChanges(currentFiles, newFiles);
358
+
359
+ // Update React state with new file set + diff info
137
360
  setState((prev) => ({
138
361
  ...prev,
139
- terminalOutput: [...prev.terminalOutput, line],
362
+ originalFiles: currentFiles,
363
+ files: newFiles,
364
+ fileChanges,
140
365
  }));
141
- });
142
366
 
143
- runtime.setServerReadyCallback((port: number, url: string) => {
144
- setState((prev) => ({
367
+ // Restart server if any files changed
368
+ if (changed && shouldRestart) {
369
+ console.log(
370
+ DBG,
371
+ `files changed — restarting server (${Object.keys(fileChanges).length} files differ)`,
372
+ );
373
+ try {
374
+ await runtime.restart();
375
+ } catch (err) {
376
+ console.error(DBG, "restart failed:", err);
377
+ }
378
+ }
379
+
380
+ // Notify host that files have been processed
381
+ onFilesUpdatedRef.current?.(fileChanges);
382
+ },
383
+ [propEntryCommand, propPort, bootWithFiles],
384
+ );
385
+
386
+ /**
387
+ * Push a single file update into the sandbox.
388
+ *
389
+ * Writes to Nodepod FS and updates React state. Does NOT restart
390
+ * the server — use restart() if needed, or updateFiles() for bulk
391
+ * updates with auto-restart.
392
+ *
393
+ * This is the same path as editor edits, but callable imperatively
394
+ * (e.g., when the agent modifies a single file).
395
+ */
396
+ const updateFile = useCallback(async (path: string, content: string) => {
397
+ const runtime = runtimeRef.current;
398
+ if (!runtime) {
399
+ console.warn(
400
+ DBG,
401
+ "updateFile called but runtime not ready. Use updateFiles() for initial load.",
402
+ );
403
+ return;
404
+ }
405
+
406
+ try {
407
+ await runtime.writeFile(path, content);
408
+ } catch (err) {
409
+ console.error(`Failed to write file ${path}:`, err);
410
+ }
411
+
412
+ setState((prev) => {
413
+ const newFiles = { ...prev.files, [path]: content };
414
+ const newChanges = { ...prev.fileChanges };
415
+ if (!(path in prev.originalFiles)) {
416
+ newChanges[path] = "new";
417
+ } else if (prev.originalFiles[path] !== content) {
418
+ newChanges[path] = "modified";
419
+ } else {
420
+ // Reverted back to original — remove from changes
421
+ delete newChanges[path];
422
+ }
423
+ return {
145
424
  ...prev,
146
- previewUrl: url,
147
- status: "ready",
148
- }));
149
- onServerReady?.(port, url);
425
+ files: newFiles,
426
+ fileChanges: newChanges,
427
+ };
150
428
  });
429
+ }, []);
151
430
 
152
- // Boot
153
- runtime.boot().catch((err) => {
154
- const msg = err instanceof Error ? err.message : String(err);
155
- onError?.(msg);
156
- });
431
+ // -------------------------------------------------------------------------
432
+ // Boot on mount
433
+ // -------------------------------------------------------------------------
434
+
435
+ useEffect(() => {
436
+ if (bootedRef.current) return;
437
+
438
+ const config = resolvedConfig();
439
+
440
+ if (config) {
441
+ // Direct files or template — boot immediately
442
+ bootedRef.current = true;
443
+ if (Object.keys(config.files).length === 0) return;
444
+ bootWithFiles(config.files, config.entryCommand, config.port);
445
+ }
446
+ // If no config (no files, no template), sandbox waits for
447
+ // updateFiles() to be called via the imperative handle.
157
448
 
158
449
  // Cleanup on unmount
159
450
  return () => {
160
- runtime.teardown();
451
+ runtimeRef.current?.teardown();
161
452
  runtimeRef.current = null;
162
453
  };
163
454
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
164
455
 
165
- // File change handler
456
+ // -------------------------------------------------------------------------
457
+ // File editing (from the CodeEditor UI)
458
+ // -------------------------------------------------------------------------
459
+
166
460
  const handleFileChange = useCallback(
167
461
  async (path: string, content: string) => {
168
462
  const runtime = runtimeRef.current;
169
- if (!runtime) {
170
- return;
171
- }
463
+ if (!runtime) return;
172
464
 
173
- // Write to Nodepod FS
174
465
  try {
175
466
  await runtime.writeFile(path, content);
176
467
  } catch (err) {
177
468
  console.error(`Failed to write file ${path}:`, err);
178
469
  }
179
470
 
180
- // Update state
181
- setState((prev) => ({
182
- ...prev,
183
- files: { ...prev.files, [path]: content },
184
- }));
471
+ setState((prev) => {
472
+ const newFiles = { ...prev.files, [path]: content };
473
+ // Recompute change status for this file against originalFiles.
474
+ // Only update this file's status — don't recompute everything.
475
+ const newChanges = { ...prev.fileChanges };
476
+ if (!(path in prev.originalFiles)) {
477
+ newChanges[path] = "new";
478
+ } else if (prev.originalFiles[path] !== content) {
479
+ newChanges[path] = "modified";
480
+ } else {
481
+ // Reverted back to original — remove from changes
482
+ delete newChanges[path];
483
+ }
484
+
485
+ return {
486
+ ...prev,
487
+ files: newFiles,
488
+ fileChanges: newChanges,
489
+ };
490
+ });
185
491
 
186
492
  onFileChange?.(path, content);
187
493
  },
188
494
  [onFileChange],
189
495
  );
190
496
 
191
- // Select a file
497
+ // -------------------------------------------------------------------------
498
+ // File selection
499
+ // -------------------------------------------------------------------------
500
+
192
501
  const handleSelectFile = useCallback((path: string) => {
193
502
  setSelectedFile(path);
194
503
  setOpenFiles((prev) => (prev.includes(path) ? prev : [...prev, path]));
195
504
  }, []);
196
505
 
197
- // Close a file tab
198
506
  const handleCloseFile = useCallback(
199
507
  (path: string) => {
200
508
  setOpenFiles((prev) => {
@@ -208,20 +516,41 @@ export function useRuntime(props: CodeSandboxProps) {
208
516
  [selectedFile],
209
517
  );
210
518
 
211
- // Restart the server
519
+ // -------------------------------------------------------------------------
520
+ // Server control
521
+ // -------------------------------------------------------------------------
522
+
212
523
  const restart = useCallback(async () => {
213
524
  const runtime = runtimeRef.current;
214
- if (!runtime) {
215
- return;
216
- }
525
+ if (!runtime) return;
217
526
  await runtime.restart();
218
527
  }, []);
219
528
 
220
- // Get changed files for git operations
529
+ // -------------------------------------------------------------------------
530
+ // State readers (for imperative handle)
531
+ // -------------------------------------------------------------------------
532
+
533
+ const getFiles = useCallback((): FileMap => {
534
+ return runtimeRef.current?.getCurrentFiles() ?? {};
535
+ }, []);
536
+
221
537
  const getChangedFiles = useCallback((): FileMap => {
222
538
  return runtimeRef.current?.getChangedFiles() ?? {};
223
539
  }, []);
224
540
 
541
+ const getFileChanges = useCallback((): Record<string, FileChangeStatus> => {
542
+ // Read from React state since it's the source of truth for changes
543
+ return state.fileChanges;
544
+ }, [state.fileChanges]);
545
+
546
+ const getErrors = useCallback((): SandboxError[] => {
547
+ return state.errors;
548
+ }, [state.errors]);
549
+
550
+ const getState = useCallback((): RuntimeState => {
551
+ return state;
552
+ }, [state]);
553
+
225
554
  return {
226
555
  state,
227
556
  selectedFile,
@@ -229,8 +558,16 @@ export function useRuntime(props: CodeSandboxProps) {
229
558
  handleFileChange,
230
559
  handleSelectFile,
231
560
  handleCloseFile,
561
+ handleBrowserError,
232
562
  restart,
563
+ // Imperative methods (exposed via handle)
564
+ updateFiles,
565
+ updateFile,
566
+ getFiles,
233
567
  getChangedFiles,
568
+ getFileChanges,
569
+ getErrors,
570
+ getState,
234
571
  runtime: runtimeRef,
235
572
  };
236
573
  }
package/src/index.ts CHANGED
@@ -31,6 +31,9 @@ export { getTemplate, listTemplates } from "./templates";
31
31
  export type {
32
32
  FileMap,
33
33
  FileNode,
34
+ FileChangeStatus,
35
+ SandboxError,
36
+ SandboxErrorCategory,
34
37
  BootStage,
35
38
  BootProgress,
36
39
  RuntimeConfig,
@@ -40,6 +43,7 @@ export type {
40
43
  GitPRRequest,
41
44
  GitPRResult,
42
45
  CodeSandboxProps,
46
+ CodeSandboxHandle,
43
47
  FileTreeProps,
44
48
  CodeEditorProps,
45
49
  TerminalProps,