@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.
- package/LICENSE +15 -0
- package/dist/__sw__.js +712 -0
- package/dist/code-sandbox.css +1 -0
- package/dist/components/BootOverlay.d.ts +17 -0
- package/dist/components/CodeEditor.d.ts +11 -0
- package/dist/components/FileTree.d.ts +19 -0
- package/dist/components/Preview.d.ts +15 -0
- package/dist/components/Terminal.d.ts +15 -0
- package/dist/components/ViewSlider.d.ts +25 -0
- package/dist/components/Workbench.d.ts +28 -0
- package/dist/hooks/useRuntime.d.ts +25 -0
- package/dist/index.cjs +50074 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +77335 -0
- package/dist/index.js.map +1 -0
- package/dist/services/git.d.ts +57 -0
- package/dist/services/runtime.d.ts +119 -0
- package/dist/templates/fullstack-starter.d.ts +38 -0
- package/dist/templates/index.d.ts +38 -0
- package/dist/types.d.ts +137 -0
- package/package.json +69 -0
- package/src/components/BootOverlay.tsx +145 -0
- package/src/components/CodeEditor.tsx +168 -0
- package/src/components/FileTree.tsx +286 -0
- package/src/components/Preview.tsx +50 -0
- package/src/components/Terminal.tsx +68 -0
- package/src/components/ViewSlider.tsx +87 -0
- package/src/components/Workbench.tsx +301 -0
- package/src/hooks/useRuntime.ts +236 -0
- package/src/index.ts +48 -0
- package/src/services/git.ts +415 -0
- package/src/services/runtime.ts +536 -0
- package/src/styles.css +24 -0
- package/src/templates/fullstack-starter.ts +3297 -0
- package/src/templates/index.ts +607 -0
- package/src/types.ts +179 -0
|
@@ -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
|
+
}
|