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