@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.
@@ -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 (/__virtual__/PORT/).
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
- import React, { useRef, useEffect } from "react";
9
- import type { PreviewProps } from "../types";
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 /__virtual__/PORT/ which is intercepted by the
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}