@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.
- package/dist/components/CodeEditor.d.ts +18 -5
- package/dist/components/FileTree.d.ts +2 -1
- package/dist/components/Preview.d.ts +15 -3
- package/dist/hooks/useRuntime.d.ts +24 -1
- package/dist/index.cjs +351 -83
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8698 -7956
- package/dist/index.js.map +1 -1
- package/dist/services/runtime.d.ts +58 -1
- package/dist/types.d.ts +97 -1
- package/package.json +1 -1
- package/src/components/CodeEditor.tsx +141 -11
- package/src/components/FileTree.tsx +66 -4
- package/src/components/Preview.tsx +188 -5
- package/src/components/Workbench.tsx +12 -2
- package/src/hooks/useRuntime.ts +450 -79
- package/src/index.ts +3 -0
- package/src/services/runtime.ts +240 -1
- package/src/styles.css +96 -0
- package/src/templates/fullstack-starter.ts +211 -1
- package/src/types.ts +113 -1
|
@@ -1,22 +1,204 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Preview — Full-width iframe that renders the Nodepod virtual server.
|
|
3
3
|
*
|
|
4
|
-
* Loads the URL from Nodepod's Service Worker proxy (/
|
|
4
|
+
* Loads the URL from Nodepod's Service Worker proxy (/__preview__/PORT/).
|
|
5
5
|
* The toolbar handles the URL bar and refresh — this is just the iframe.
|
|
6
|
+
*
|
|
7
|
+
* Error capture: After the iframe loads, we inject a small script that
|
|
8
|
+
* hooks `window.onerror`, `window.onunhandledrejection`, and
|
|
9
|
+
* `console.error` inside the iframe. These are relayed to the parent
|
|
10
|
+
* via `postMessage`, where we parse them into SandboxError objects
|
|
11
|
+
* and forward to the `onBrowserError` callback.
|
|
12
|
+
*
|
|
13
|
+
* The iframe and parent are same-origin (both served through the
|
|
14
|
+
* Nodepod Service Worker), so direct contentWindow access works.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useRef, useEffect, useCallback } from "react";
|
|
18
|
+
import type {
|
|
19
|
+
PreviewProps,
|
|
20
|
+
SandboxError,
|
|
21
|
+
SandboxErrorCategory,
|
|
22
|
+
} from "../types";
|
|
23
|
+
|
|
24
|
+
/** Message type sent from the error-capture script inside the iframe */
|
|
25
|
+
interface IframeErrorMessage {
|
|
26
|
+
type: "sandbox-error";
|
|
27
|
+
category: SandboxErrorCategory;
|
|
28
|
+
message: string;
|
|
29
|
+
stack?: string;
|
|
30
|
+
filename?: string;
|
|
31
|
+
lineno?: number;
|
|
32
|
+
colno?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the error-capture script injected into the iframe.
|
|
37
|
+
*
|
|
38
|
+
* This script runs inside the preview iframe's global scope and
|
|
39
|
+
* intercepts three error vectors:
|
|
40
|
+
* 1. window.onerror — uncaught exceptions
|
|
41
|
+
* 2. window.onunhandledrejection — unhandled Promise rejections
|
|
42
|
+
* 3. console.error — explicit error logging
|
|
43
|
+
*
|
|
44
|
+
* Each error is posted to the parent via postMessage with a
|
|
45
|
+
* structured payload so the parent can construct a SandboxError.
|
|
6
46
|
*/
|
|
47
|
+
function buildErrorCaptureScript(): string {
|
|
48
|
+
return `
|
|
49
|
+
(function() {
|
|
50
|
+
// Guard against double-injection
|
|
51
|
+
if (window.__sandboxErrorCapture) return;
|
|
52
|
+
window.__sandboxErrorCapture = true;
|
|
53
|
+
|
|
54
|
+
var post = function(category, message, stack, filename, lineno, colno) {
|
|
55
|
+
try {
|
|
56
|
+
window.parent.postMessage({
|
|
57
|
+
type: 'sandbox-error',
|
|
58
|
+
category: category,
|
|
59
|
+
message: String(message || 'Unknown error'),
|
|
60
|
+
stack: stack || undefined,
|
|
61
|
+
filename: filename || undefined,
|
|
62
|
+
lineno: lineno || undefined,
|
|
63
|
+
colno: colno || undefined
|
|
64
|
+
}, '*');
|
|
65
|
+
} catch(e) { /* swallow — parent may not be listening */ }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 1. Uncaught exceptions
|
|
69
|
+
window.onerror = function(msg, source, lineno, colno, error) {
|
|
70
|
+
post(
|
|
71
|
+
'browser-error',
|
|
72
|
+
msg,
|
|
73
|
+
error && error.stack ? error.stack : undefined,
|
|
74
|
+
source,
|
|
75
|
+
lineno,
|
|
76
|
+
colno
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// 2. Unhandled promise rejections
|
|
81
|
+
window.onunhandledrejection = function(event) {
|
|
82
|
+
var reason = event.reason;
|
|
83
|
+
var msg = reason instanceof Error ? reason.message : String(reason);
|
|
84
|
+
var stack = reason instanceof Error ? reason.stack : undefined;
|
|
85
|
+
post('browser-unhandled-rejection', msg, stack);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// 3. console.error intercept
|
|
89
|
+
var origError = console.error;
|
|
90
|
+
console.error = function() {
|
|
91
|
+
// Forward to original so DevTools still shows it
|
|
92
|
+
origError.apply(console, arguments);
|
|
93
|
+
var parts = [];
|
|
94
|
+
for (var i = 0; i < arguments.length; i++) {
|
|
95
|
+
var arg = arguments[i];
|
|
96
|
+
if (arg instanceof Error) {
|
|
97
|
+
parts.push(arg.message);
|
|
98
|
+
// Use the stack from the first Error argument
|
|
99
|
+
if (parts.length === 1) {
|
|
100
|
+
post('browser-console-error', arg.message, arg.stack);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
parts.push(String(arg));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
post('browser-console-error', parts.join(' '));
|
|
108
|
+
};
|
|
109
|
+
})();
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
7
112
|
|
|
8
|
-
|
|
9
|
-
|
|
113
|
+
/** Counter for unique error IDs within this component instance */
|
|
114
|
+
let browserErrorCounter = 0;
|
|
10
115
|
|
|
11
116
|
/**
|
|
12
117
|
* Preview component — renders the dev server output in a full-width iframe.
|
|
13
118
|
*
|
|
14
|
-
* The URL points to /
|
|
119
|
+
* The URL points to /__preview__/PORT/ which is intercepted by the
|
|
15
120
|
* Nodepod Service Worker and proxied to the Express server running
|
|
16
121
|
* in the Web Worker.
|
|
122
|
+
*
|
|
123
|
+
* After the iframe loads, an error-capture script is injected so that
|
|
124
|
+
* runtime errors inside the preview are surfaced to the AI agent.
|
|
17
125
|
*/
|
|
18
|
-
export function Preview({ url, className = "" }: PreviewProps) {
|
|
126
|
+
export function Preview({ url, className = "", onBrowserError }: PreviewProps) {
|
|
19
127
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
128
|
+
const onBrowserErrorRef = useRef(onBrowserError);
|
|
129
|
+
onBrowserErrorRef.current = onBrowserError;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Inject the error-capture script into the iframe's contentWindow.
|
|
133
|
+
*
|
|
134
|
+
* Called on iframe `load` event. We use direct contentWindow access
|
|
135
|
+
* (same-origin) rather than srcdoc to avoid breaking the SW proxy.
|
|
136
|
+
*/
|
|
137
|
+
const injectErrorCapture = useCallback(() => {
|
|
138
|
+
const iframe = iframeRef.current;
|
|
139
|
+
if (!iframe) return;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const iframeDoc =
|
|
143
|
+
iframe.contentDocument || iframe.contentWindow?.document;
|
|
144
|
+
if (!iframeDoc) return;
|
|
145
|
+
|
|
146
|
+
const script = iframeDoc.createElement("script");
|
|
147
|
+
script.textContent = buildErrorCaptureScript();
|
|
148
|
+
// Append to <head> (or <body> if <head> doesn't exist)
|
|
149
|
+
const target =
|
|
150
|
+
iframeDoc.head || iframeDoc.body || iframeDoc.documentElement;
|
|
151
|
+
if (target) {
|
|
152
|
+
target.appendChild(script);
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// Cross-origin restriction — shouldn't happen with SW proxy but
|
|
156
|
+
// degrade gracefully if it does.
|
|
157
|
+
console.warn(
|
|
158
|
+
"[CodeSandbox:Preview] Could not inject error capture:",
|
|
159
|
+
err,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
// Listen for postMessage from the iframe
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const handler = (event: MessageEvent) => {
|
|
167
|
+
if (!event.data || event.data.type !== "sandbox-error") return;
|
|
168
|
+
|
|
169
|
+
const data = event.data as IframeErrorMessage;
|
|
170
|
+
const cb = onBrowserErrorRef.current;
|
|
171
|
+
if (!cb) return;
|
|
172
|
+
|
|
173
|
+
// Parse filename to extract relative file path.
|
|
174
|
+
// The iframe's script src URLs look like:
|
|
175
|
+
// https://host/__preview__/3000/js/app.js
|
|
176
|
+
// We want just "public/js/app.js" or "js/app.js".
|
|
177
|
+
let filePath: string | undefined;
|
|
178
|
+
if (data.filename) {
|
|
179
|
+
const match = data.filename.match(
|
|
180
|
+
/\/__(?:preview|virtual)__\/\d+(\/.*)/,
|
|
181
|
+
);
|
|
182
|
+
filePath = match ? match[1].replace(/^\//, "") : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const error: SandboxError = {
|
|
186
|
+
id: `browser_${++browserErrorCounter}_${Date.now()}`,
|
|
187
|
+
category: data.category,
|
|
188
|
+
message: data.message,
|
|
189
|
+
stack: data.stack,
|
|
190
|
+
filePath,
|
|
191
|
+
line: data.lineno ?? undefined,
|
|
192
|
+
column: data.colno ?? undefined,
|
|
193
|
+
timestamp: new Date().toISOString(),
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
cb(error);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
window.addEventListener("message", handler);
|
|
200
|
+
return () => window.removeEventListener("message", handler);
|
|
201
|
+
}, []);
|
|
20
202
|
|
|
21
203
|
// Reload iframe when URL changes
|
|
22
204
|
useEffect(() => {
|
|
@@ -34,6 +216,7 @@ export function Preview({ url, className = "" }: PreviewProps) {
|
|
|
34
216
|
className="w-full h-full border-none"
|
|
35
217
|
title="Preview"
|
|
36
218
|
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"
|
|
219
|
+
onLoad={injectErrorCapture}
|
|
37
220
|
/>
|
|
38
221
|
) : (
|
|
39
222
|
<div className="flex items-center justify-center h-full bg-sb-bg">
|
|
@@ -32,7 +32,7 @@ import { Preview } from "./Preview";
|
|
|
32
32
|
import { BootOverlay } from "./BootOverlay";
|
|
33
33
|
import { ViewSlider, type WorkbenchView } from "./ViewSlider";
|
|
34
34
|
import { useRuntime } from "../hooks/useRuntime";
|
|
35
|
-
import type { CodeSandboxProps } from "../types";
|
|
35
|
+
import type { CodeSandboxProps, FileChangeStatus } from "../types";
|
|
36
36
|
|
|
37
37
|
/** Transition for view crossfade */
|
|
38
38
|
const VIEW_TRANSITION = {
|
|
@@ -60,6 +60,7 @@ export function CodeSandbox(props: CodeSandboxProps) {
|
|
|
60
60
|
handleFileChange,
|
|
61
61
|
handleSelectFile,
|
|
62
62
|
handleCloseFile,
|
|
63
|
+
handleBrowserError,
|
|
63
64
|
restart,
|
|
64
65
|
} = useRuntime(props);
|
|
65
66
|
|
|
@@ -118,6 +119,8 @@ export function CodeSandbox(props: CodeSandboxProps) {
|
|
|
118
119
|
<CodeView
|
|
119
120
|
fileTree={fileTree}
|
|
120
121
|
files={state.files}
|
|
122
|
+
originalFiles={state.originalFiles}
|
|
123
|
+
fileChanges={state.fileChanges}
|
|
121
124
|
selectedFile={selectedFile}
|
|
122
125
|
openFiles={openFiles}
|
|
123
126
|
terminalOutput={state.terminalOutput}
|
|
@@ -137,7 +140,7 @@ export function CodeSandbox(props: CodeSandboxProps) {
|
|
|
137
140
|
}}
|
|
138
141
|
transition={{ duration: 0.15 }}
|
|
139
142
|
>
|
|
140
|
-
<Preview url={state.previewUrl} />
|
|
143
|
+
<Preview url={state.previewUrl} onBrowserError={handleBrowserError} />
|
|
141
144
|
</motion.div>
|
|
142
145
|
</div>
|
|
143
146
|
</div>
|
|
@@ -241,6 +244,8 @@ function RefreshIcon() {
|
|
|
241
244
|
interface CodeViewProps {
|
|
242
245
|
fileTree: ReturnType<typeof buildFileTree>;
|
|
243
246
|
files: Record<string, string>;
|
|
247
|
+
originalFiles: Record<string, string>;
|
|
248
|
+
fileChanges: Record<string, FileChangeStatus>;
|
|
244
249
|
selectedFile: string | null;
|
|
245
250
|
openFiles: string[];
|
|
246
251
|
terminalOutput: string[];
|
|
@@ -257,6 +262,8 @@ interface CodeViewProps {
|
|
|
257
262
|
function CodeView({
|
|
258
263
|
fileTree,
|
|
259
264
|
files,
|
|
265
|
+
originalFiles,
|
|
266
|
+
fileChanges,
|
|
260
267
|
selectedFile,
|
|
261
268
|
openFiles,
|
|
262
269
|
terminalOutput,
|
|
@@ -273,6 +280,7 @@ function CodeView({
|
|
|
273
280
|
files={fileTree}
|
|
274
281
|
selectedFile={selectedFile}
|
|
275
282
|
onSelectFile={onSelectFile}
|
|
283
|
+
fileChanges={fileChanges}
|
|
276
284
|
/>
|
|
277
285
|
</Allotment.Pane>
|
|
278
286
|
|
|
@@ -282,6 +290,8 @@ function CodeView({
|
|
|
282
290
|
<Allotment.Pane preferredSize="70%">
|
|
283
291
|
<CodeEditor
|
|
284
292
|
files={files}
|
|
293
|
+
originalFiles={originalFiles}
|
|
294
|
+
fileChanges={fileChanges}
|
|
285
295
|
activeFile={selectedFile}
|
|
286
296
|
openFiles={openFiles}
|
|
287
297
|
onSelectFile={onSelectFile}
|