@illuma-ai/code-sandbox 1.0.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.
@@ -0,0 +1,536 @@
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 { BootProgress, BootStage, FileMap, RuntimeConfig } from "../types";
13
+
14
+ /** Callback type for progress updates */
15
+ type ProgressCallback = (progress: BootProgress) => void;
16
+ /** Callback type for terminal output lines */
17
+ type OutputCallback = (line: string) => void;
18
+ /** Callback type for server ready */
19
+ type ServerReadyCallback = (port: number, url: string) => void;
20
+
21
+ /** Debug log prefix for easy filtering in DevTools console */
22
+ const DBG = "[CodeSandbox:Runtime]";
23
+
24
+ /**
25
+ * Manages the Nodepod runtime lifecycle.
26
+ *
27
+ * Responsibilities:
28
+ * - Boot Nodepod with COOP/COEP-compatible service worker
29
+ * - Write project files into the virtual filesystem
30
+ * - Install npm dependencies from package.json
31
+ * - Start the entry command (e.g., "node server.js")
32
+ * - Track file modifications for git diffing
33
+ * - Provide preview URL for the iframe
34
+ */
35
+ export class NodepodRuntime {
36
+ private nodepod: Nodepod | null = null;
37
+ private serverProcess: NodepodProcess | null = null;
38
+ private config: RuntimeConfig;
39
+ private originalFiles: FileMap = {};
40
+ private currentFiles: FileMap = {};
41
+ private terminalOutput: string[] = [];
42
+ private status: BootStage = "initializing";
43
+ private error: string | null = null;
44
+ private previewUrl: string | null = null;
45
+
46
+ // Callbacks
47
+ private onProgress: ProgressCallback | null = null;
48
+ private onOutput: OutputCallback | null = null;
49
+ private onServerReady: ServerReadyCallback | null = null;
50
+
51
+ constructor(config: RuntimeConfig) {
52
+ this.config = {
53
+ workdir: "/app",
54
+ port: 3000,
55
+ ...config,
56
+ };
57
+ console.log(DBG, "constructor", {
58
+ workdir: this.config.workdir,
59
+ port: this.config.port,
60
+ entryCommand: this.config.entryCommand,
61
+ fileCount: Object.keys(this.config.files).length,
62
+ files: Object.keys(this.config.files),
63
+ });
64
+ }
65
+
66
+ /** Register a progress callback */
67
+ setProgressCallback(cb: ProgressCallback): void {
68
+ this.onProgress = cb;
69
+ }
70
+
71
+ /** Register a terminal output callback */
72
+ setOutputCallback(cb: OutputCallback): void {
73
+ this.onOutput = cb;
74
+ }
75
+
76
+ /** Register a server ready callback */
77
+ setServerReadyCallback(cb: ServerReadyCallback): void {
78
+ this.onServerReady = cb;
79
+ }
80
+
81
+ /** Get the current preview URL (null if server not ready) */
82
+ getPreviewUrl(): string | null {
83
+ return this.previewUrl;
84
+ }
85
+
86
+ /** Get current status */
87
+ getStatus(): BootStage {
88
+ return this.status;
89
+ }
90
+
91
+ /** Get all terminal output */
92
+ getTerminalOutput(): string[] {
93
+ return [...this.terminalOutput];
94
+ }
95
+
96
+ /** Get current files (with edits applied) */
97
+ getCurrentFiles(): FileMap {
98
+ return { ...this.currentFiles };
99
+ }
100
+
101
+ /** Get the original files (as loaded, before any edits) */
102
+ getOriginalFiles(): FileMap {
103
+ return { ...this.originalFiles };
104
+ }
105
+
106
+ /** Get only the files that have been modified since loading */
107
+ getChangedFiles(): FileMap {
108
+ const changes: FileMap = {};
109
+ for (const [path, content] of Object.entries(this.currentFiles)) {
110
+ if (this.originalFiles[path] !== content) {
111
+ changes[path] = content;
112
+ }
113
+ }
114
+ return changes;
115
+ }
116
+
117
+ /** Get the error message if status is 'error' */
118
+ getError(): string | null {
119
+ return this.error;
120
+ }
121
+
122
+ // -------------------------------------------------------------------------
123
+ // Lifecycle
124
+ // -------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Boot the runtime: initialize Nodepod → write files → install → start server.
128
+ *
129
+ * This is the main entry point. Call this once.
130
+ * The progress callback will fire at each stage.
131
+ */
132
+ async boot(): Promise<void> {
133
+ const t0 = performance.now();
134
+ console.log(DBG, "boot() started");
135
+
136
+ try {
137
+ // Stage 1: Initialize Nodepod
138
+ this.emitProgress("initializing", "Booting runtime environment...", 5);
139
+
140
+ const port = this.config.port ?? 3000;
141
+ console.log(DBG, "boot: calling Nodepod.boot()", {
142
+ swUrl: "/__sw__.js",
143
+ workdir: this.config.workdir,
144
+ port,
145
+ });
146
+
147
+ // Check preconditions Nodepod will check
148
+ console.log(
149
+ DBG,
150
+ "boot: SharedArrayBuffer available?",
151
+ typeof SharedArrayBuffer !== "undefined",
152
+ );
153
+ console.log(
154
+ DBG,
155
+ "boot: Worker available?",
156
+ typeof Worker !== "undefined",
157
+ );
158
+
159
+ const tBoot = performance.now();
160
+ this.nodepod = await Nodepod.boot({
161
+ swUrl: "/__sw__.js",
162
+ workdir: this.config.workdir,
163
+ env: this.config.env,
164
+ onServerReady: (readyPort: number, url: string) => {
165
+ console.log(DBG, "onServerReady fired!", {
166
+ readyPort,
167
+ url,
168
+ expectedPort: port,
169
+ });
170
+ if (readyPort === port) {
171
+ this.previewUrl = this.rewritePreviewUrl(url);
172
+ console.log(DBG, "previewUrl set to:", this.previewUrl);
173
+ this.emitProgress("ready", "Application is running", 100);
174
+ this.onServerReady?.(readyPort, this.previewUrl);
175
+ }
176
+ },
177
+ });
178
+ console.log(
179
+ DBG,
180
+ `boot: Nodepod.boot() completed in ${(performance.now() - tBoot).toFixed(0)}ms`,
181
+ );
182
+
183
+ this.emitProgress("initializing", "Runtime environment ready", 15);
184
+
185
+ // Stage 2: Write project files
186
+ this.emitProgress("writing-files", "Writing project files...", 20);
187
+ const tFiles = performance.now();
188
+ await this.writeFiles(this.config.files);
189
+ console.log(
190
+ DBG,
191
+ `boot: writeFiles completed in ${(performance.now() - tFiles).toFixed(0)}ms`,
192
+ );
193
+ this.emitProgress(
194
+ "writing-files",
195
+ `Wrote ${Object.keys(this.config.files).length} files`,
196
+ 35,
197
+ );
198
+
199
+ // Verify files were actually written
200
+ try {
201
+ const workdir = this.config.workdir ?? "/app";
202
+ const testFile = Object.keys(this.config.files)[0];
203
+ if (testFile) {
204
+ const fullPath = testFile.startsWith("/")
205
+ ? testFile
206
+ : `${workdir}/${testFile}`;
207
+ const exists = await this.nodepod.fs.exists(fullPath);
208
+ console.log(DBG, `boot: verify file "${fullPath}" exists:`, exists);
209
+ if (exists) {
210
+ const content = await this.nodepod.fs.readFile(fullPath, "utf-8");
211
+ console.log(
212
+ DBG,
213
+ `boot: verify file "${fullPath}" has ${content.length} chars`,
214
+ );
215
+ }
216
+ }
217
+ } catch (verifyErr) {
218
+ console.warn(DBG, "boot: file verification failed:", verifyErr);
219
+ }
220
+
221
+ // Stage 3: Install dependencies (if package.json exists)
222
+ if (this.config.files["package.json"]) {
223
+ this.emitProgress("installing", "Installing dependencies...", 40);
224
+ const tInstall = performance.now();
225
+ await this.installDependencies();
226
+ console.log(
227
+ DBG,
228
+ `boot: installDependencies completed in ${(performance.now() - tInstall).toFixed(0)}ms`,
229
+ );
230
+ this.emitProgress("installing", "Dependencies installed", 70);
231
+ } else {
232
+ console.log(DBG, "boot: no package.json found, skipping install");
233
+ }
234
+
235
+ // Stage 4: Start the entry command
236
+ this.emitProgress("starting", "Starting application...", 75);
237
+ const tStart = performance.now();
238
+ await this.startServer();
239
+ console.log(
240
+ DBG,
241
+ `boot: startServer returned in ${(performance.now() - tStart).toFixed(0)}ms`,
242
+ );
243
+ console.log(
244
+ DBG,
245
+ `boot: total elapsed ${(performance.now() - t0).toFixed(0)}ms, status=${this.status}`,
246
+ );
247
+
248
+ // Note: 'ready' is emitted by the onServerReady callback above.
249
+ // If the server doesn't register a port (e.g., non-HTTP scripts),
250
+ // we mark ready after a short delay.
251
+ // Also poll more aggressively: check at 1s, 3s, 5s, 10s
252
+ const fallbackDelays = [1000, 3000, 5000, 10000];
253
+ for (const delay of fallbackDelays) {
254
+ setTimeout(() => {
255
+ if (this.status === "starting") {
256
+ // Check if we got a preview URL via port registration
257
+ const url = this.nodepod?.port(port);
258
+ console.log(
259
+ DBG,
260
+ `boot: fallback check @${delay}ms, port(${port}) =`,
261
+ url,
262
+ "status =",
263
+ this.status,
264
+ );
265
+ if (url) {
266
+ this.previewUrl = this.rewritePreviewUrl(url);
267
+ console.log(DBG, "fallback: previewUrl set to:", this.previewUrl);
268
+ this.emitProgress("ready", "Application is running", 100);
269
+ this.onServerReady?.(port, this.previewUrl);
270
+ } else if (delay === fallbackDelays[fallbackDelays.length - 1]) {
271
+ // Last check - report failure
272
+ console.error(
273
+ DBG,
274
+ "boot: server never became ready after 10s. Process state:",
275
+ {
276
+ processExited: this.serverProcess?.exited,
277
+ previewUrl: this.previewUrl,
278
+ status: this.status,
279
+ },
280
+ );
281
+ this.appendOutput(
282
+ "[system] Server did not start within 10 seconds. Check the logs above for errors.",
283
+ );
284
+ }
285
+ }
286
+ }, delay);
287
+ }
288
+ } catch (err) {
289
+ const message = err instanceof Error ? err.message : String(err);
290
+ console.error(DBG, "boot: FATAL ERROR:", err);
291
+ this.error = message;
292
+ this.emitProgress("error", `Error: ${message}`, 0);
293
+ throw err;
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Write a single file to the virtual filesystem.
299
+ * Used for editor changes after boot.
300
+ */
301
+ async writeFile(path: string, content: string): Promise<void> {
302
+ if (!this.nodepod) {
303
+ throw new Error("Runtime not booted");
304
+ }
305
+
306
+ const fullPath = this.resolvePath(path);
307
+ await this.nodepod.fs.writeFile(fullPath, content);
308
+ this.currentFiles[path] = content;
309
+ }
310
+
311
+ /**
312
+ * Read a file from the virtual filesystem.
313
+ */
314
+ async readFile(path: string): Promise<string> {
315
+ if (!this.nodepod) {
316
+ throw new Error("Runtime not booted");
317
+ }
318
+ const fullPath = this.resolvePath(path);
319
+ return await this.nodepod.fs.readFile(fullPath, "utf-8");
320
+ }
321
+
322
+ /**
323
+ * Restart the server process.
324
+ * Kills the current process and re-runs the entry command.
325
+ */
326
+ async restart(): Promise<void> {
327
+ if (this.serverProcess && !this.serverProcess.exited) {
328
+ this.serverProcess.kill();
329
+ // Wait for the process to exit
330
+ await this.serverProcess.completion.catch(() => {});
331
+ }
332
+ this.previewUrl = null;
333
+ this.emitProgress("starting", "Restarting application...", 75);
334
+ await this.startServer();
335
+ }
336
+
337
+ /**
338
+ * Tear down the runtime.
339
+ * Kills all processes and releases resources.
340
+ */
341
+ teardown(): void {
342
+ if (this.serverProcess && !this.serverProcess.exited) {
343
+ this.serverProcess.kill();
344
+ }
345
+ this.nodepod?.teardown();
346
+ this.nodepod = null;
347
+ this.serverProcess = null;
348
+ this.previewUrl = null;
349
+ this.status = "initializing";
350
+ this.terminalOutput = [];
351
+ }
352
+
353
+ /** Get raw Nodepod instance for advanced usage */
354
+ getNodepod(): Nodepod | null {
355
+ return this.nodepod;
356
+ }
357
+
358
+ // -------------------------------------------------------------------------
359
+ // Private helpers
360
+ // -------------------------------------------------------------------------
361
+
362
+ /**
363
+ * Rewrite a Nodepod virtual server URL to use /__preview__/ instead of /__virtual__/.
364
+ *
365
+ * WHY: Nodepod's serverUrl() returns /__virtual__/{port} URLs, but the
366
+ * Service Worker only tracks iframe client IDs for /__preview__/{port}
367
+ * navigations. Without client tracking, subresource requests (JS, CSS,
368
+ * API calls) from the preview iframe are NOT intercepted by the SW —
369
+ * they fall through to the real dev server and 404.
370
+ */
371
+ private rewritePreviewUrl(url: string): string {
372
+ return url.replace("/__virtual__/", "/__preview__/");
373
+ }
374
+
375
+ /** Resolve a relative path against the workdir */
376
+ private resolvePath(relativePath: string): string {
377
+ const workdir = this.config.workdir ?? "/app";
378
+ // Normalize: remove leading ./ and ensure proper path joining
379
+ const clean = relativePath.replace(/^\.\//, "");
380
+ if (clean.startsWith("/")) {
381
+ return clean;
382
+ }
383
+ return `${workdir}/${clean}`;
384
+ }
385
+
386
+ /** Write all project files to the virtual filesystem */
387
+ private async writeFiles(files: FileMap): Promise<void> {
388
+ if (!this.nodepod) {
389
+ return;
390
+ }
391
+
392
+ // Store originals for change tracking
393
+ this.originalFiles = { ...files };
394
+ this.currentFiles = { ...files };
395
+
396
+ // Write files in parallel batches of 10
397
+ const entries = Object.entries(files);
398
+ const batchSize = 10;
399
+
400
+ for (let i = 0; i < entries.length; i += batchSize) {
401
+ const batch = entries.slice(i, i + batchSize);
402
+ await Promise.all(
403
+ batch.map(([path, content]) => {
404
+ const fullPath = this.resolvePath(path);
405
+ return this.nodepod!.fs.writeFile(fullPath, content);
406
+ }),
407
+ );
408
+
409
+ // Update progress within the writing-files stage
410
+ const percent = 20 + Math.round((i / entries.length) * 15);
411
+ this.emitProgress(
412
+ "writing-files",
413
+ `Writing files... (${Math.min(i + batchSize, entries.length)}/${entries.length})`,
414
+ percent,
415
+ );
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Install npm dependencies by spawning `npm install` in Nodepod.
421
+ *
422
+ * Nodepod's DependencyInstaller handles this internally via its
423
+ * package registry. We spawn `node -e` with a require to trigger it,
424
+ * or use the built-in npm install mechanism.
425
+ */
426
+ private async installDependencies(): Promise<void> {
427
+ if (!this.nodepod) {
428
+ return;
429
+ }
430
+
431
+ const workdir = this.config.workdir ?? "/app";
432
+
433
+ try {
434
+ // Parse package.json to find dependencies
435
+ const pkgJsonContent = this.config.files["package.json"];
436
+ if (!pkgJsonContent) {
437
+ return;
438
+ }
439
+
440
+ const pkgJson = JSON.parse(pkgJsonContent);
441
+ const deps = {
442
+ ...(pkgJson.dependencies || {}),
443
+ ...(pkgJson.devDependencies || {}),
444
+ };
445
+
446
+ const depNames = Object.keys(deps);
447
+ if (depNames.length === 0) {
448
+ return;
449
+ }
450
+
451
+ this.appendOutput(`Installing ${depNames.length} dependencies...`);
452
+
453
+ // Use Nodepod's built-in package installer for each dependency
454
+ for (let i = 0; i < depNames.length; i++) {
455
+ const dep = depNames[i];
456
+ const version = deps[dep];
457
+ const specifier = version
458
+ ? `${dep}@${version.replace(/[\^~>=<]/g, "")}`
459
+ : dep;
460
+
461
+ try {
462
+ await this.nodepod.packages.install(specifier);
463
+ this.appendOutput(` ✓ ${dep}`);
464
+ } catch (err) {
465
+ this.appendOutput(
466
+ ` ✗ ${dep}: ${err instanceof Error ? err.message : err}`,
467
+ );
468
+ }
469
+
470
+ // Update progress
471
+ const percent = 40 + Math.round((i / depNames.length) * 30);
472
+ this.emitProgress(
473
+ "installing",
474
+ `Installing ${dep}... (${i + 1}/${depNames.length})`,
475
+ percent,
476
+ );
477
+ }
478
+
479
+ this.appendOutput("Dependencies installed.");
480
+ } catch (err) {
481
+ this.appendOutput(`Warning: Could not parse package.json: ${err}`);
482
+ }
483
+ }
484
+
485
+ /** Start the entry command (e.g., "node server.js") */
486
+ private async startServer(): Promise<void> {
487
+ if (!this.nodepod) {
488
+ return;
489
+ }
490
+
491
+ const workdir = this.config.workdir ?? "/app";
492
+ const command = this.config.entryCommand;
493
+
494
+ // Parse the command: "node server.js" → cmd="node", args=["server.js"]
495
+ const parts = command.split(/\s+/);
496
+ const cmd = parts[0];
497
+ const args = parts.slice(1);
498
+
499
+ this.appendOutput(`\n$ ${command}`);
500
+
501
+ this.serverProcess = await this.nodepod.spawn(cmd, args, { cwd: workdir });
502
+
503
+ this.serverProcess.on("output", (chunk: string) => {
504
+ this.appendOutput(chunk);
505
+ });
506
+
507
+ this.serverProcess.on("error", (chunk: string) => {
508
+ this.appendOutput(`[stderr] ${chunk}`);
509
+ });
510
+
511
+ this.serverProcess.on("exit", (code: number) => {
512
+ this.appendOutput(`\nProcess exited with code ${code}`);
513
+ if (code !== 0 && this.status !== "ready") {
514
+ this.error = `Process exited with code ${code}`;
515
+ this.emitProgress("error", `Process exited with code ${code}`, 0);
516
+ }
517
+ });
518
+ }
519
+
520
+ /** Emit a progress update */
521
+ private emitProgress(
522
+ stage: BootStage,
523
+ message: string,
524
+ percent: number,
525
+ ): void {
526
+ this.status = stage;
527
+ const progress: BootProgress = { stage, message, percent };
528
+ this.onProgress?.(progress);
529
+ }
530
+
531
+ /** Append a line to terminal output */
532
+ private appendOutput(line: string): void {
533
+ this.terminalOutput.push(line);
534
+ this.onOutput?.(line);
535
+ }
536
+ }
package/src/styles.css ADDED
@@ -0,0 +1,24 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* Code Sandbox CSS custom properties — overridable by consumers */
6
+ :root {
7
+ --sb-bg: #1e1e1e;
8
+ --sb-bg-alt: #252526;
9
+ --sb-bg-hover: #2a2d2e;
10
+ --sb-bg-active: #37373d;
11
+ --sb-sidebar: #252526;
12
+ --sb-editor: #1e1e1e;
13
+ --sb-terminal: #1e1e1e;
14
+ --sb-preview: #ffffff;
15
+ --sb-border: #3c3c3c;
16
+ --sb-text: #cccccc;
17
+ --sb-text-muted: #858585;
18
+ --sb-text-active: #ffffff;
19
+ --sb-accent: #007acc;
20
+ --sb-accent-hover: #1a8ad4;
21
+ --sb-success: #4ec9b0;
22
+ --sb-warning: #cca700;
23
+ --sb-error: #f44747;
24
+ }