@illuma-ai/code-sandbox 1.0.0 → 1.1.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,27 +3,93 @@
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
+ * Supports two file sources:
8
+ * 1. Direct files (via `files` prop or `template` prop)
9
+ * 2. GitHub repo (via `github` prop) — with branch polling for hot-reload
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";
9
18
  import { NodepodRuntime } from "../services/runtime";
19
+ import { GitService } from "../services/git";
10
20
  import { getTemplate } from "../templates";
11
21
  import type {
12
22
  BootProgress,
13
23
  CodeSandboxProps,
24
+ FileChangeStatus,
14
25
  FileMap,
26
+ GitHubRepo,
15
27
  RuntimeState,
28
+ SandboxError,
16
29
  } from "../types";
17
30
 
31
+ /** Debug log prefix for easy filtering in DevTools */
32
+ const DBG = "[CodeSandbox:useRuntime]";
33
+
34
+ /** Default polling interval for GitHub branch changes (ms) */
35
+ const DEFAULT_POLL_INTERVAL = 5_000;
36
+
37
+ /**
38
+ * Compute per-file change statuses between an original and current file set.
39
+ *
40
+ * Returns only files with a non-"unchanged" status (new, modified, deleted).
41
+ * Files that are identical are omitted — treat missing keys as "unchanged".
42
+ */
43
+ function computeFileChanges(
44
+ original: FileMap,
45
+ current: FileMap,
46
+ ): Record<string, FileChangeStatus> {
47
+ const changes: Record<string, FileChangeStatus> = {};
48
+
49
+ // Check current files against original
50
+ for (const path of Object.keys(current)) {
51
+ if (!(path in original)) {
52
+ changes[path] = "new";
53
+ } else if (original[path] !== current[path]) {
54
+ changes[path] = "modified";
55
+ }
56
+ // unchanged — omit
57
+ }
58
+
59
+ // Check for deleted files (in original but not in current)
60
+ for (const path of Object.keys(original)) {
61
+ if (!(path in current)) {
62
+ changes[path] = "deleted";
63
+ }
64
+ }
65
+
66
+ return changes;
67
+ }
68
+
18
69
  /**
19
70
  * Hook that manages the full Nodepod runtime lifecycle.
20
71
  *
21
72
  * @param props - CodeSandbox component props
22
73
  * @returns Reactive runtime state + control functions
74
+ *
75
+ * @example
76
+ * ```tsx
77
+ * // Direct files
78
+ * const { state } = useRuntime({ files: myFiles, entryCommand: 'node server.js' });
79
+ *
80
+ * // GitHub repo with auto-polling
81
+ * const { state } = useRuntime({
82
+ * github: { owner: 'illuma-ai', repo: 'fullstack-starter', branch: 'main' },
83
+ * gitToken: 'ghp_xxx',
84
+ * onSandboxError: (err) => agent.fix(err),
85
+ * });
86
+ * ```
23
87
  */
24
88
  export function useRuntime(props: CodeSandboxProps) {
25
89
  const {
26
90
  files: propFiles,
91
+ github,
92
+ gitToken,
27
93
  template,
28
94
  entryCommand: propEntryCommand,
29
95
  port: propPort,
@@ -32,10 +98,19 @@ export function useRuntime(props: CodeSandboxProps) {
32
98
  onServerReady,
33
99
  onProgress,
34
100
  onError,
101
+ onSandboxError,
35
102
  } = props;
36
103
 
37
104
  const runtimeRef = useRef<NodepodRuntime | null>(null);
38
105
  const bootedRef = useRef(false);
106
+ /** Ref for the latest onSandboxError callback (avoids stale closures) */
107
+ const onSandboxErrorRef = useRef(onSandboxError);
108
+ onSandboxErrorRef.current = onSandboxError;
109
+
110
+ /** SHA of the latest commit we've seen on the polled branch */
111
+ const lastCommitShaRef = useRef<string | null>(null);
112
+ /** Interval ID for GitHub polling */
113
+ const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
39
114
 
40
115
  const [state, setState] = useState<RuntimeState>({
41
116
  status: "initializing",
@@ -43,13 +118,62 @@ export function useRuntime(props: CodeSandboxProps) {
43
118
  previewUrl: null,
44
119
  terminalOutput: [],
45
120
  files: {},
121
+ originalFiles: {},
122
+ fileChanges: {},
46
123
  error: null,
124
+ errors: [],
47
125
  });
48
126
 
49
127
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
50
128
  const [openFiles, setOpenFiles] = useState<string[]>([]);
51
129
 
52
- // Resolve files + entry command from props or template
130
+ // -------------------------------------------------------------------------
131
+ // Error handling — unified pipeline
132
+ // -------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Handle a structured error from any source (runtime or browser).
136
+ *
137
+ * Appends to the errors array in state and forwards to onSandboxError.
138
+ * The runtime calls reportError for process errors; the Preview
139
+ * component calls handleBrowserError for iframe errors.
140
+ */
141
+ const handleSandboxError = useCallback((error: SandboxError) => {
142
+ setState((prev) => ({
143
+ ...prev,
144
+ errors: [...prev.errors, error],
145
+ }));
146
+ onSandboxErrorRef.current?.(error);
147
+ }, []);
148
+
149
+ /**
150
+ * Handle a browser error from the preview iframe.
151
+ *
152
+ * Called by the Preview component's onBrowserError callback.
153
+ * We enrich the error with source context from the runtime
154
+ * (if the file exists in our virtual FS) then record it.
155
+ */
156
+ const handleBrowserError = useCallback(
157
+ async (error: SandboxError) => {
158
+ const runtime = runtimeRef.current;
159
+ if (runtime) {
160
+ // reportError enriches with source context and records internally
161
+ await runtime.reportError(error);
162
+ }
163
+ // Also update React state
164
+ handleSandboxError(error);
165
+ },
166
+ [handleSandboxError],
167
+ );
168
+
169
+ // -------------------------------------------------------------------------
170
+ // Config resolution
171
+ // -------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Resolve files + entry command from props or template.
175
+ * Returns null if using GitHub (files loaded async).
176
+ */
53
177
  const resolvedConfig = useCallback(() => {
54
178
  if (propFiles) {
55
179
  return {
@@ -70,131 +194,373 @@ export function useRuntime(props: CodeSandboxProps) {
70
194
  }
71
195
  }
72
196
 
197
+ // GitHub mode — files loaded asynchronously
198
+ if (github) {
199
+ return null;
200
+ }
201
+
73
202
  // Default: empty project
74
203
  return {
75
204
  files: {},
76
205
  entryCommand: propEntryCommand || "node server.js",
77
206
  port: propPort || 3000,
78
207
  };
79
- }, [propFiles, template, propEntryCommand, propPort]);
208
+ }, [propFiles, template, github, propEntryCommand, propPort]);
80
209
 
81
- // Boot the runtime on mount
82
- useEffect(() => {
83
- if (bootedRef.current) {
84
- return;
85
- }
86
- bootedRef.current = true;
210
+ // -------------------------------------------------------------------------
211
+ // Boot helpers
212
+ // -------------------------------------------------------------------------
87
213
 
88
- const config = resolvedConfig();
89
- if (Object.keys(config.files).length === 0) {
90
- return;
91
- }
214
+ /**
215
+ * Boot the runtime with a resolved file set.
216
+ *
217
+ * Shared between direct-file mode and GitHub mode.
218
+ */
219
+ const bootWithFiles = useCallback(
220
+ (files: FileMap, entryCommand: string, port: number) => {
221
+ const runtime = new NodepodRuntime({
222
+ files,
223
+ entryCommand,
224
+ port,
225
+ env,
226
+ });
227
+
228
+ runtimeRef.current = runtime;
229
+
230
+ // Set initial files state + open the first file
231
+ setState((prev) => ({
232
+ ...prev,
233
+ files,
234
+ }));
235
+
236
+ const fileNames = Object.keys(files);
237
+ const entryFile =
238
+ fileNames.find((f) => f === "server.js") ||
239
+ fileNames.find((f) => f === "index.js") ||
240
+ fileNames.find((f) => f.endsWith(".js")) ||
241
+ fileNames[0];
242
+
243
+ if (entryFile) {
244
+ setSelectedFile(entryFile);
245
+ setOpenFiles([entryFile]);
246
+ }
247
+
248
+ // Wire progress callback
249
+ runtime.setProgressCallback((progress: BootProgress) => {
250
+ setState((prev) => ({
251
+ ...prev,
252
+ status: progress.stage,
253
+ progress,
254
+ previewUrl: runtime.getPreviewUrl(),
255
+ error: progress.stage === "error" ? progress.message : prev.error,
256
+ }));
257
+ onProgress?.(progress);
258
+ if (progress.stage === "error") {
259
+ onError?.(progress.message);
260
+ }
261
+ });
262
+
263
+ // Wire terminal output callback
264
+ runtime.setOutputCallback((line: string) => {
265
+ setState((prev) => ({
266
+ ...prev,
267
+ terminalOutput: [...prev.terminalOutput, line],
268
+ }));
269
+ });
270
+
271
+ // Wire server ready callback
272
+ runtime.setServerReadyCallback((readyPort: number, url: string) => {
273
+ setState((prev) => ({
274
+ ...prev,
275
+ previewUrl: url,
276
+ status: "ready",
277
+ }));
278
+ onServerReady?.(readyPort, url);
279
+ });
280
+
281
+ // Wire structured error callback
282
+ runtime.setErrorCallback(handleSandboxError);
283
+
284
+ // Boot
285
+ runtime.boot().catch((err) => {
286
+ const msg = err instanceof Error ? err.message : String(err);
287
+ onError?.(msg);
288
+ });
289
+
290
+ return runtime;
291
+ },
292
+ [env, onProgress, onError, onServerReady, handleSandboxError],
293
+ );
294
+
295
+ // -------------------------------------------------------------------------
296
+ // GitHub branch polling
297
+ // -------------------------------------------------------------------------
298
+
299
+ /**
300
+ * Fetch the latest commit SHA for a branch.
301
+ * Used to detect when the agent pushes new commits.
302
+ */
303
+ const fetchLatestSha = useCallback(
304
+ async (repo: GitHubRepo, token: string): Promise<string | null> => {
305
+ try {
306
+ const { owner, repo: repoName, branch = "main" } = repo;
307
+ const res = await fetch(
308
+ `https://api.github.com/repos/${owner}/${repoName}/git/ref/heads/${branch}`,
309
+ {
310
+ headers: {
311
+ Authorization: `Bearer ${token}`,
312
+ Accept: "application/vnd.github.v3+json",
313
+ },
314
+ },
315
+ );
316
+ if (!res.ok) return null;
317
+ const data = await res.json();
318
+ return data.object?.sha ?? null;
319
+ } catch {
320
+ return null;
321
+ }
322
+ },
323
+ [],
324
+ );
325
+
326
+ /**
327
+ * Update files in the running sandbox when changes are detected.
328
+ *
329
+ * Writes new/changed files to Nodepod's virtual FS and restarts
330
+ * the server process. Deleted files are NOT removed (Nodepod FS
331
+ * doesn't support delete yet — they just become stale).
332
+ *
333
+ * Stores the previous file set as `originalFiles` so the diff
334
+ * viewer can show what changed.
335
+ *
336
+ * @param newFiles - The full new file set from the repo
337
+ */
338
+ const updateFiles = useCallback(async (newFiles: FileMap) => {
339
+ const runtime = runtimeRef.current;
340
+ if (!runtime) return;
341
+
342
+ const currentFiles = runtime.getCurrentFiles();
343
+ let changed = false;
92
344
 
93
- const runtime = new NodepodRuntime({
94
- files: config.files,
95
- entryCommand: config.entryCommand,
96
- port: config.port,
97
- env,
98
- });
345
+ for (const [path, content] of Object.entries(newFiles)) {
346
+ if (currentFiles[path] !== content) {
347
+ try {
348
+ await runtime.writeFile(path, content);
349
+ changed = true;
350
+ console.log(DBG, `updated file: ${path}`);
351
+ } catch (err) {
352
+ console.warn(DBG, `failed to write ${path}:`, err);
353
+ }
354
+ }
355
+ }
99
356
 
100
- runtimeRef.current = runtime;
357
+ // Compute change status before updating state.
358
+ // The "original" is what we had before this update.
359
+ const fileChanges = computeFileChanges(currentFiles, newFiles);
101
360
 
102
- // Set initial files state + open the first file
361
+ // Update React state with new file set + diff info
103
362
  setState((prev) => ({
104
363
  ...prev,
105
- files: config.files,
364
+ originalFiles: currentFiles,
365
+ files: newFiles,
366
+ fileChanges,
106
367
  }));
107
368
 
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]);
369
+ // Restart server if any files changed
370
+ if (changed) {
371
+ console.log(
372
+ DBG,
373
+ `files changed restarting server (${Object.keys(fileChanges).length} files differ)`,
374
+ );
375
+ try {
376
+ await runtime.restart();
377
+ } catch (err) {
378
+ console.error(DBG, "restart failed:", err);
379
+ }
119
380
  }
381
+ }, []);
120
382
 
121
- // Wire callbacks
122
- runtime.setProgressCallback((progress: BootProgress) => {
123
- setState((prev) => ({
124
- ...prev,
125
- status: progress.stage,
126
- progress,
127
- previewUrl: runtime.getPreviewUrl(),
128
- error: progress.stage === "error" ? progress.message : prev.error,
129
- }));
130
- onProgress?.(progress);
131
- if (progress.stage === "error") {
132
- onError?.(progress.message);
383
+ /**
384
+ * Start polling a GitHub branch for new commits.
385
+ *
386
+ * When a new commit is detected:
387
+ * 1. Fetch the full file tree via GitService.cloneRepo()
388
+ * 2. Diff against current files
389
+ * 3. Write changed files to Nodepod FS
390
+ * 4. Restart the server
391
+ */
392
+ const startPolling = useCallback(
393
+ (repo: GitHubRepo, token: string) => {
394
+ // Stop any existing polling
395
+ if (pollIntervalRef.current) {
396
+ clearInterval(pollIntervalRef.current);
133
397
  }
134
- });
135
398
 
136
- runtime.setOutputCallback((line: string) => {
137
- setState((prev) => ({
138
- ...prev,
139
- terminalOutput: [...prev.terminalOutput, line],
140
- }));
141
- });
399
+ console.log(DBG, "starting branch polling", {
400
+ owner: repo.owner,
401
+ repo: repo.repo,
402
+ branch: repo.branch || "main",
403
+ interval: DEFAULT_POLL_INTERVAL,
404
+ });
405
+
406
+ pollIntervalRef.current = setInterval(async () => {
407
+ const latestSha = await fetchLatestSha(repo, token);
408
+ if (!latestSha) return;
409
+
410
+ if (
411
+ lastCommitShaRef.current &&
412
+ latestSha !== lastCommitShaRef.current
413
+ ) {
414
+ console.log(DBG, "new commit detected:", latestSha);
415
+ lastCommitShaRef.current = latestSha;
416
+
417
+ // Fetch updated files
418
+ const git = new GitService(token);
419
+ try {
420
+ const newFiles = await git.cloneRepo(repo);
421
+ await updateFiles(newFiles);
422
+ } catch (err) {
423
+ console.error(DBG, "failed to fetch updated files:", err);
424
+ }
425
+ } else if (!lastCommitShaRef.current) {
426
+ // First poll — just record the SHA, don't re-fetch
427
+ lastCommitShaRef.current = latestSha;
428
+ }
429
+ }, DEFAULT_POLL_INTERVAL);
430
+ },
431
+ [fetchLatestSha, updateFiles],
432
+ );
433
+
434
+ // -------------------------------------------------------------------------
435
+ // Boot on mount
436
+ // -------------------------------------------------------------------------
437
+
438
+ useEffect(() => {
439
+ if (bootedRef.current) return;
440
+ bootedRef.current = true;
441
+
442
+ const config = resolvedConfig();
443
+
444
+ if (config) {
445
+ // Direct files or template — boot immediately
446
+ if (Object.keys(config.files).length === 0) return;
447
+ bootWithFiles(config.files, config.entryCommand, config.port);
448
+ } else if (github && gitToken) {
449
+ // GitHub mode — fetch files then boot, then start polling
450
+ const entryCommand = propEntryCommand || "node server.js";
451
+ const port = propPort || 3000;
142
452
 
143
- runtime.setServerReadyCallback((port: number, url: string) => {
144
453
  setState((prev) => ({
145
454
  ...prev,
146
- previewUrl: url,
147
- status: "ready",
455
+ progress: {
456
+ stage: "initializing",
457
+ message: "Cloning repository from GitHub...",
458
+ percent: 5,
459
+ },
148
460
  }));
149
- onServerReady?.(port, url);
150
- });
151
461
 
152
- // Boot
153
- runtime.boot().catch((err) => {
154
- const msg = err instanceof Error ? err.message : String(err);
155
- onError?.(msg);
156
- });
462
+ const git = new GitService(gitToken);
463
+ git
464
+ .cloneRepo(github, (percent, message) => {
465
+ setState((prev) => ({
466
+ ...prev,
467
+ progress: { stage: "initializing", message, percent },
468
+ }));
469
+ })
470
+ .then((files) => {
471
+ // Infer entry command from package.json if not provided
472
+ let resolvedEntryCommand = entryCommand;
473
+ if (!propEntryCommand && files["package.json"]) {
474
+ try {
475
+ const pkg = JSON.parse(files["package.json"]);
476
+ if (pkg.scripts?.start) {
477
+ // Convert "node server.js" style commands
478
+ resolvedEntryCommand = pkg.scripts.start;
479
+ }
480
+ } catch {
481
+ /* use default */
482
+ }
483
+ }
484
+
485
+ const runtime = bootWithFiles(files, resolvedEntryCommand, port);
486
+
487
+ // Start polling for branch changes
488
+ startPolling(github, gitToken);
489
+ })
490
+ .catch((err) => {
491
+ const msg = err instanceof Error ? err.message : String(err);
492
+ console.error(DBG, "GitHub clone failed:", err);
493
+ setState((prev) => ({
494
+ ...prev,
495
+ status: "error",
496
+ error: msg,
497
+ progress: { stage: "error", message: msg, percent: 0 },
498
+ }));
499
+ onError?.(msg);
500
+ });
501
+ }
157
502
 
158
503
  // Cleanup on unmount
159
504
  return () => {
160
- runtime.teardown();
505
+ runtimeRef.current?.teardown();
161
506
  runtimeRef.current = null;
507
+ if (pollIntervalRef.current) {
508
+ clearInterval(pollIntervalRef.current);
509
+ pollIntervalRef.current = null;
510
+ }
162
511
  };
163
512
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
164
513
 
165
- // File change handler
514
+ // -------------------------------------------------------------------------
515
+ // File editing
516
+ // -------------------------------------------------------------------------
517
+
166
518
  const handleFileChange = useCallback(
167
519
  async (path: string, content: string) => {
168
520
  const runtime = runtimeRef.current;
169
- if (!runtime) {
170
- return;
171
- }
521
+ if (!runtime) return;
172
522
 
173
- // Write to Nodepod FS
174
523
  try {
175
524
  await runtime.writeFile(path, content);
176
525
  } catch (err) {
177
526
  console.error(`Failed to write file ${path}:`, err);
178
527
  }
179
528
 
180
- // Update state
181
- setState((prev) => ({
182
- ...prev,
183
- files: { ...prev.files, [path]: content },
184
- }));
529
+ setState((prev) => {
530
+ const newFiles = { ...prev.files, [path]: content };
531
+ // Recompute change status for this file against originalFiles.
532
+ // Only update this file's status — don't recompute everything.
533
+ const newChanges = { ...prev.fileChanges };
534
+ if (!(path in prev.originalFiles)) {
535
+ newChanges[path] = "new";
536
+ } else if (prev.originalFiles[path] !== content) {
537
+ newChanges[path] = "modified";
538
+ } else {
539
+ // Reverted back to original — remove from changes
540
+ delete newChanges[path];
541
+ }
542
+
543
+ return {
544
+ ...prev,
545
+ files: newFiles,
546
+ fileChanges: newChanges,
547
+ };
548
+ });
185
549
 
186
550
  onFileChange?.(path, content);
187
551
  },
188
552
  [onFileChange],
189
553
  );
190
554
 
191
- // Select a file
555
+ // -------------------------------------------------------------------------
556
+ // File selection
557
+ // -------------------------------------------------------------------------
558
+
192
559
  const handleSelectFile = useCallback((path: string) => {
193
560
  setSelectedFile(path);
194
561
  setOpenFiles((prev) => (prev.includes(path) ? prev : [...prev, path]));
195
562
  }, []);
196
563
 
197
- // Close a file tab
198
564
  const handleCloseFile = useCallback(
199
565
  (path: string) => {
200
566
  setOpenFiles((prev) => {
@@ -208,16 +574,20 @@ export function useRuntime(props: CodeSandboxProps) {
208
574
  [selectedFile],
209
575
  );
210
576
 
211
- // Restart the server
577
+ // -------------------------------------------------------------------------
578
+ // Server control
579
+ // -------------------------------------------------------------------------
580
+
212
581
  const restart = useCallback(async () => {
213
582
  const runtime = runtimeRef.current;
214
- if (!runtime) {
215
- return;
216
- }
583
+ if (!runtime) return;
217
584
  await runtime.restart();
218
585
  }, []);
219
586
 
220
- // Get changed files for git operations
587
+ // -------------------------------------------------------------------------
588
+ // Git operations
589
+ // -------------------------------------------------------------------------
590
+
221
591
  const getChangedFiles = useCallback((): FileMap => {
222
592
  return runtimeRef.current?.getChangedFiles() ?? {};
223
593
  }, []);
@@ -229,6 +599,7 @@ export function useRuntime(props: CodeSandboxProps) {
229
599
  handleFileChange,
230
600
  handleSelectFile,
231
601
  handleCloseFile,
602
+ handleBrowserError,
232
603
  restart,
233
604
  getChangedFiles,
234
605
  runtime: runtimeRef,
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,