@illuma-ai/code-sandbox 1.1.0 → 1.2.1

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.
@@ -4,9 +4,9 @@
4
4
  * Manages the Nodepod runtime instance and exposes reactive state
5
5
  * for all UI components (progress, files, terminal output, preview URL).
6
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
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
10
  *
11
11
  * Error exposure: wires the runtime's structured error system and the
12
12
  * preview iframe's browser error capture into a unified `errors[]` array
@@ -16,14 +16,12 @@
16
16
 
17
17
  import { useCallback, useEffect, useRef, useState } from "react";
18
18
  import { NodepodRuntime } from "../services/runtime";
19
- import { GitService } from "../services/git";
20
19
  import { getTemplate } from "../templates";
21
20
  import type {
22
21
  BootProgress,
23
22
  CodeSandboxProps,
24
23
  FileChangeStatus,
25
24
  FileMap,
26
- GitHubRepo,
27
25
  RuntimeState,
28
26
  SandboxError,
29
27
  } from "../types";
@@ -31,9 +29,6 @@ import type {
31
29
  /** Debug log prefix for easy filtering in DevTools */
32
30
  const DBG = "[CodeSandbox:useRuntime]";
33
31
 
34
- /** Default polling interval for GitHub branch changes (ms) */
35
- const DEFAULT_POLL_INTERVAL = 5_000;
36
-
37
32
  /**
38
33
  * Compute per-file change statuses between an original and current file set.
39
34
  *
@@ -69,27 +64,27 @@ function computeFileChanges(
69
64
  /**
70
65
  * Hook that manages the full Nodepod runtime lifecycle.
71
66
  *
67
+ * The sandbox is a pure renderer — it receives files via props or the
68
+ * imperative handle. No polling, no storage backends.
69
+ *
72
70
  * @param props - CodeSandbox component props
73
- * @returns Reactive runtime state + control functions
71
+ * @returns Reactive runtime state + control functions + imperative methods
74
72
  *
75
73
  * @example
76
74
  * ```tsx
77
- * // Direct files
78
- * const { state } = useRuntime({ files: myFiles, entryCommand: 'node server.js' });
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);
79
80
  *
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
- * });
81
+ * // Or update a single file:
82
+ * await runtime.updateFile('server.js', newContent);
86
83
  * ```
87
84
  */
88
85
  export function useRuntime(props: CodeSandboxProps) {
89
86
  const {
90
87
  files: propFiles,
91
- github,
92
- gitToken,
93
88
  template,
94
89
  entryCommand: propEntryCommand,
95
90
  port: propPort,
@@ -99,6 +94,7 @@ export function useRuntime(props: CodeSandboxProps) {
99
94
  onProgress,
100
95
  onError,
101
96
  onSandboxError,
97
+ onFilesUpdated,
102
98
  } = props;
103
99
 
104
100
  const runtimeRef = useRef<NodepodRuntime | null>(null);
@@ -107,10 +103,9 @@ export function useRuntime(props: CodeSandboxProps) {
107
103
  const onSandboxErrorRef = useRef(onSandboxError);
108
104
  onSandboxErrorRef.current = onSandboxError;
109
105
 
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);
106
+ /** Ref for latest onFilesUpdated callback */
107
+ const onFilesUpdatedRef = useRef(onFilesUpdated);
108
+ onFilesUpdatedRef.current = onFilesUpdated;
114
109
 
115
110
  const [state, setState] = useState<RuntimeState>({
116
111
  status: "initializing",
@@ -172,13 +167,24 @@ export function useRuntime(props: CodeSandboxProps) {
172
167
 
173
168
  /**
174
169
  * Resolve files + entry command from props or template.
175
- * Returns null if using GitHub (files loaded async).
176
170
  */
177
171
  const resolvedConfig = useCallback(() => {
178
- 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
+ }
179
185
  return {
180
186
  files: propFiles,
181
- entryCommand: propEntryCommand || "node server.js",
187
+ entryCommand,
182
188
  port: propPort || 3000,
183
189
  };
184
190
  }
@@ -194,18 +200,9 @@ export function useRuntime(props: CodeSandboxProps) {
194
200
  }
195
201
  }
196
202
 
197
- // GitHub modefiles loaded asynchronously
198
- if (github) {
199
- return null;
200
- }
201
-
202
- // Default: empty project
203
- return {
204
- files: {},
205
- entryCommand: propEntryCommand || "node server.js",
206
- port: propPort || 3000,
207
- };
208
- }, [propFiles, template, github, propEntryCommand, propPort]);
203
+ // No files provided return null (sandbox waits for updateFiles)
204
+ return null;
205
+ }, [propFiles, template, propEntryCommand, propPort]);
209
206
 
210
207
  // -------------------------------------------------------------------------
211
208
  // Boot helpers
@@ -213,8 +210,6 @@ export function useRuntime(props: CodeSandboxProps) {
213
210
 
214
211
  /**
215
212
  * Boot the runtime with a resolved file set.
216
- *
217
- * Shared between direct-file mode and GitHub mode.
218
213
  */
219
214
  const bootWithFiles = useCallback(
220
215
  (files: FileMap, entryCommand: string, port: number) => {
@@ -293,143 +288,145 @@ export function useRuntime(props: CodeSandboxProps) {
293
288
  );
294
289
 
295
290
  // -------------------------------------------------------------------------
296
- // GitHub branch polling
291
+ // Imperative file update methods
297
292
  // -------------------------------------------------------------------------
298
293
 
299
294
  /**
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.
295
+ * Push a complete new file set into the sandbox.
328
296
  *
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).
297
+ * Diffs against current files, writes only changed files to Nodepod FS,
298
+ * and restarts the server if anything changed (unless restartServer=false).
332
299
  *
333
- * Stores the previous file set as `originalFiles` so the diff
334
- * viewer can show what changed.
300
+ * The previous file set becomes `originalFiles` for diff tracking.
335
301
  *
336
- * @param newFiles - The full new file set from the repo
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 a remote source, then calls updateFiles).
337
305
  */
338
- const updateFiles = useCallback(async (newFiles: FileMap) => {
339
- const runtime = runtimeRef.current;
340
- if (!runtime) return;
306
+ const updateFiles = useCallback(
307
+ async (newFiles: FileMap, options?: { restartServer?: boolean }) => {
308
+ const shouldRestart = options?.restartServer !== false;
309
+ const runtime = runtimeRef.current;
341
310
 
342
- const currentFiles = runtime.getCurrentFiles();
343
- let changed = false;
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;
344
321
 
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);
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
+ }
353
333
  }
334
+ const port = propPort || 3000;
335
+
336
+ bootWithFiles(newFiles, entryCommand, port);
337
+ return;
354
338
  }
355
- }
356
339
 
357
- // Compute change status before updating state.
358
- // The "original" is what we had before this update.
359
- const fileChanges = computeFileChanges(currentFiles, newFiles);
340
+ // Runtime exists diff and write changed files
341
+ const currentFiles = runtime.getCurrentFiles();
342
+ let changed = false;
360
343
 
361
- // Update React state with new file set + diff info
362
- setState((prev) => ({
363
- ...prev,
364
- originalFiles: currentFiles,
365
- files: newFiles,
366
- fileChanges,
367
- }));
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
+ }
368
355
 
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);
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
360
+ setState((prev) => ({
361
+ ...prev,
362
+ originalFiles: currentFiles,
363
+ files: newFiles,
364
+ fileChanges,
365
+ }));
366
+
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
+ }
379
378
  }
380
- }
381
- }, []);
379
+
380
+ // Notify host that files have been processed
381
+ onFilesUpdatedRef.current?.(fileChanges);
382
+ },
383
+ [propEntryCommand, propPort, bootWithFiles],
384
+ );
382
385
 
383
386
  /**
384
- * Start polling a GitHub branch for new commits.
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.
385
392
  *
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
393
+ * This is the same path as editor edits, but callable imperatively
394
+ * (e.g., when the agent modifies a single file).
391
395
  */
392
- const startPolling = useCallback(
393
- (repo: GitHubRepo, token: string) => {
394
- // Stop any existing polling
395
- if (pollIntervalRef.current) {
396
- clearInterval(pollIntervalRef.current);
397
- }
398
-
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;
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
+ }
409
405
 
410
- if (
411
- lastCommitShaRef.current &&
412
- latestSha !== lastCommitShaRef.current
413
- ) {
414
- console.log(DBG, "new commit detected:", latestSha);
415
- lastCommitShaRef.current = latestSha;
406
+ try {
407
+ await runtime.writeFile(path, content);
408
+ } catch (err) {
409
+ console.error(`Failed to write file ${path}:`, err);
410
+ }
416
411
 
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
- );
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 {
424
+ ...prev,
425
+ files: newFiles,
426
+ fileChanges: newChanges,
427
+ };
428
+ });
429
+ }, []);
433
430
 
434
431
  // -------------------------------------------------------------------------
435
432
  // Boot on mount
@@ -437,82 +434,27 @@ export function useRuntime(props: CodeSandboxProps) {
437
434
 
438
435
  useEffect(() => {
439
436
  if (bootedRef.current) return;
440
- bootedRef.current = true;
441
437
 
442
438
  const config = resolvedConfig();
443
439
 
444
440
  if (config) {
445
441
  // Direct files or template — boot immediately
442
+ bootedRef.current = true;
446
443
  if (Object.keys(config.files).length === 0) return;
447
444
  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;
452
-
453
- setState((prev) => ({
454
- ...prev,
455
- progress: {
456
- stage: "initializing",
457
- message: "Cloning repository from GitHub...",
458
- percent: 5,
459
- },
460
- }));
461
-
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
445
  }
446
+ // If no config (no files, no template), sandbox waits for
447
+ // updateFiles() to be called via the imperative handle.
502
448
 
503
449
  // Cleanup on unmount
504
450
  return () => {
505
451
  runtimeRef.current?.teardown();
506
452
  runtimeRef.current = null;
507
- if (pollIntervalRef.current) {
508
- clearInterval(pollIntervalRef.current);
509
- pollIntervalRef.current = null;
510
- }
511
453
  };
512
454
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
513
455
 
514
456
  // -------------------------------------------------------------------------
515
- // File editing
457
+ // File editing (from the CodeEditor UI)
516
458
  // -------------------------------------------------------------------------
517
459
 
518
460
  const handleFileChange = useCallback(
@@ -585,13 +527,30 @@ export function useRuntime(props: CodeSandboxProps) {
585
527
  }, []);
586
528
 
587
529
  // -------------------------------------------------------------------------
588
- // Git operations
530
+ // State readers (for imperative handle)
589
531
  // -------------------------------------------------------------------------
590
532
 
533
+ const getFiles = useCallback((): FileMap => {
534
+ return runtimeRef.current?.getCurrentFiles() ?? {};
535
+ }, []);
536
+
591
537
  const getChangedFiles = useCallback((): FileMap => {
592
538
  return runtimeRef.current?.getChangedFiles() ?? {};
593
539
  }, []);
594
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
+
595
554
  return {
596
555
  state,
597
556
  selectedFile,
@@ -601,7 +560,14 @@ export function useRuntime(props: CodeSandboxProps) {
601
560
  handleCloseFile,
602
561
  handleBrowserError,
603
562
  restart,
563
+ // Imperative methods (exposed via handle)
564
+ updateFiles,
565
+ updateFile,
566
+ getFiles,
604
567
  getChangedFiles,
568
+ getFileChanges,
569
+ getErrors,
570
+ getState,
605
571
  runtime: runtimeRef,
606
572
  };
607
573
  }
package/src/index.ts CHANGED
@@ -19,7 +19,6 @@ export type { WorkbenchView } from "./components/ViewSlider";
19
19
 
20
20
  // Services
21
21
  export { NodepodRuntime } from "./services/runtime";
22
- export { GitService } from "./services/git";
23
22
 
24
23
  // Hooks
25
24
  export { useRuntime } from "./hooks/useRuntime";
@@ -38,11 +37,8 @@ export type {
38
37
  BootProgress,
39
38
  RuntimeConfig,
40
39
  RuntimeState,
41
- GitHubRepo,
42
- GitCommitRequest,
43
- GitPRRequest,
44
- GitPRResult,
45
40
  CodeSandboxProps,
41
+ CodeSandboxHandle,
46
42
  FileTreeProps,
47
43
  CodeEditorProps,
48
44
  TerminalProps,
@@ -38,7 +38,7 @@ const DBG = "[CodeSandbox:Runtime]";
38
38
  * - Write project files into the virtual filesystem
39
39
  * - Install npm dependencies from package.json
40
40
  * - Start the entry command (e.g., "node server.js")
41
- * - Track file modifications for git diffing
41
+ * - Track file modifications for diffing
42
42
  * - Provide preview URL for the iframe
43
43
  */
44
44
  export class NodepodRuntime {