@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.
- package/LICENSE +18 -12
- package/README.md +10 -11
- package/dist/index.cjs +91 -96
- package/dist/index.js +11895 -17954
- package/dist/styles.css +1 -1
- package/package.json +9 -12
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/src/components/BootOverlay.tsx +0 -145
- package/src/components/CodeEditor.tsx +0 -298
- package/src/components/FileTree.tsx +0 -678
- package/src/components/Preview.tsx +0 -262
- package/src/components/Terminal.tsx +0 -111
- package/src/components/ViewSlider.tsx +0 -87
- package/src/components/Workbench.tsx +0 -382
- package/src/hooks/useRuntime.ts +0 -637
- package/src/index.ts +0 -51
- package/src/services/runtime.ts +0 -775
- package/src/styles.css +0 -178
- package/src/templates/fullstack-starter.ts +0 -3507
- package/src/templates/index.ts +0 -607
- package/src/types.ts +0 -375
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Preview — Full-width iframe that renders the Nodepod virtual server.
|
|
3
|
-
*
|
|
4
|
-
* Loads the URL from Nodepod's Service Worker proxy (/__preview__/PORT/).
|
|
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.
|
|
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
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Counter for unique error IDs within this component instance */
|
|
114
|
-
let browserErrorCounter = 0;
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Preview component — renders the dev server output in a full-width iframe.
|
|
118
|
-
*
|
|
119
|
-
* The URL points to /__preview__/PORT/ which is intercepted by the
|
|
120
|
-
* Nodepod Service Worker and proxied to the Express server running
|
|
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.
|
|
125
|
-
*/
|
|
126
|
-
export function Preview({
|
|
127
|
-
url,
|
|
128
|
-
className = "",
|
|
129
|
-
onBrowserError,
|
|
130
|
-
reloadKey = 0,
|
|
131
|
-
}: PreviewProps) {
|
|
132
|
-
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
133
|
-
const onBrowserErrorRef = useRef(onBrowserError);
|
|
134
|
-
onBrowserErrorRef.current = onBrowserError;
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Track the previous reloadKey so we only reload on actual changes,
|
|
138
|
-
* not on initial mount or unrelated re-renders.
|
|
139
|
-
*/
|
|
140
|
-
const prevReloadKeyRef = useRef(reloadKey);
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Inject the error-capture script into the iframe's contentWindow.
|
|
144
|
-
*
|
|
145
|
-
* Called on iframe `load` event. We use direct contentWindow access
|
|
146
|
-
* (same-origin) rather than srcdoc to avoid breaking the SW proxy.
|
|
147
|
-
*/
|
|
148
|
-
const injectErrorCapture = useCallback(() => {
|
|
149
|
-
const iframe = iframeRef.current;
|
|
150
|
-
if (!iframe) return;
|
|
151
|
-
|
|
152
|
-
try {
|
|
153
|
-
const iframeDoc =
|
|
154
|
-
iframe.contentDocument || iframe.contentWindow?.document;
|
|
155
|
-
if (!iframeDoc) return;
|
|
156
|
-
|
|
157
|
-
const script = iframeDoc.createElement("script");
|
|
158
|
-
script.textContent = buildErrorCaptureScript();
|
|
159
|
-
// Append to <head> (or <body> if <head> doesn't exist)
|
|
160
|
-
const target =
|
|
161
|
-
iframeDoc.head || iframeDoc.body || iframeDoc.documentElement;
|
|
162
|
-
if (target) {
|
|
163
|
-
target.appendChild(script);
|
|
164
|
-
}
|
|
165
|
-
} catch (err) {
|
|
166
|
-
// Cross-origin restriction — shouldn't happen with SW proxy but
|
|
167
|
-
// degrade gracefully if it does.
|
|
168
|
-
console.warn(
|
|
169
|
-
"[CodeSandbox:Preview] Could not inject error capture:",
|
|
170
|
-
err,
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
}, []);
|
|
174
|
-
|
|
175
|
-
// Listen for postMessage from the iframe
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
const handler = (event: MessageEvent) => {
|
|
178
|
-
if (!event.data || event.data.type !== "sandbox-error") return;
|
|
179
|
-
|
|
180
|
-
const data = event.data as IframeErrorMessage;
|
|
181
|
-
const cb = onBrowserErrorRef.current;
|
|
182
|
-
if (!cb) return;
|
|
183
|
-
|
|
184
|
-
// Parse filename to extract relative file path.
|
|
185
|
-
// The iframe's script src URLs look like:
|
|
186
|
-
// https://host/__preview__/3000/js/app.js
|
|
187
|
-
// We want just "public/js/app.js" or "js/app.js".
|
|
188
|
-
let filePath: string | undefined;
|
|
189
|
-
if (data.filename) {
|
|
190
|
-
const match = data.filename.match(
|
|
191
|
-
/\/__(?:preview|virtual)__\/\d+(\/.*)/,
|
|
192
|
-
);
|
|
193
|
-
filePath = match ? match[1].replace(/^\//, "") : undefined;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const error: SandboxError = {
|
|
197
|
-
id: `browser_${++browserErrorCounter}_${Date.now()}`,
|
|
198
|
-
category: data.category,
|
|
199
|
-
message: data.message,
|
|
200
|
-
stack: data.stack,
|
|
201
|
-
filePath,
|
|
202
|
-
line: data.lineno ?? undefined,
|
|
203
|
-
column: data.colno ?? undefined,
|
|
204
|
-
timestamp: new Date().toISOString(),
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
cb(error);
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
window.addEventListener("message", handler);
|
|
211
|
-
return () => window.removeEventListener("message", handler);
|
|
212
|
-
}, []);
|
|
213
|
-
|
|
214
|
-
// Reload iframe when URL changes
|
|
215
|
-
useEffect(() => {
|
|
216
|
-
if (iframeRef.current && url) {
|
|
217
|
-
iframeRef.current.src = url;
|
|
218
|
-
}
|
|
219
|
-
}, [url]);
|
|
220
|
-
|
|
221
|
-
// Hot reload: soft-refresh the iframe when reloadKey changes.
|
|
222
|
-
// This avoids a full server restart for static asset changes (CSS, HTML).
|
|
223
|
-
useEffect(() => {
|
|
224
|
-
if (reloadKey !== prevReloadKeyRef.current) {
|
|
225
|
-
prevReloadKeyRef.current = reloadKey;
|
|
226
|
-
const iframe = iframeRef.current;
|
|
227
|
-
if (iframe && url) {
|
|
228
|
-
try {
|
|
229
|
-
// Try same-origin reload first (preserves scroll position)
|
|
230
|
-
iframe.contentWindow?.location.reload();
|
|
231
|
-
} catch {
|
|
232
|
-
// Cross-origin fallback — re-assign src
|
|
233
|
-
iframe.src = url;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}, [reloadKey, url]);
|
|
238
|
-
|
|
239
|
-
return (
|
|
240
|
-
<div className={`h-full w-full bg-white ${className}`}>
|
|
241
|
-
{url ? (
|
|
242
|
-
<iframe
|
|
243
|
-
ref={iframeRef}
|
|
244
|
-
src={url}
|
|
245
|
-
className="w-full h-full border-none"
|
|
246
|
-
title="Preview"
|
|
247
|
-
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"
|
|
248
|
-
onLoad={injectErrorCapture}
|
|
249
|
-
/>
|
|
250
|
-
) : (
|
|
251
|
-
<div className="flex items-center justify-center h-full bg-sb-bg">
|
|
252
|
-
<div className="text-center px-4">
|
|
253
|
-
<div className="w-10 h-10 mx-auto mb-3 border-2 border-sb-accent border-t-transparent rounded-full animate-spin" />
|
|
254
|
-
<p className="text-sb-text-muted text-sm">
|
|
255
|
-
Waiting for dev server to start...
|
|
256
|
-
</p>
|
|
257
|
-
</div>
|
|
258
|
-
</div>
|
|
259
|
-
)}
|
|
260
|
-
</div>
|
|
261
|
-
);
|
|
262
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Terminal — Clean monochrome terminal output panel with minimize toggle.
|
|
3
|
-
*
|
|
4
|
-
* Uses a simple div-based approach (not xterm.js) to keep the bundle lean.
|
|
5
|
-
* Auto-scrolls to bottom as new output arrives.
|
|
6
|
-
* Minimize/expand state is controlled by the parent via props.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import React, { useEffect, useRef } from "react";
|
|
10
|
-
import type { TerminalProps } from "../types";
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Terminal component — renders process output in a clean monochrome panel.
|
|
14
|
-
*
|
|
15
|
-
* Features:
|
|
16
|
-
* - Monospace font with proper line spacing
|
|
17
|
-
* - Auto-scroll to bottom on new output
|
|
18
|
-
* - Minimize/expand toggle via chevron in header (parent-controlled)
|
|
19
|
-
* - Modern thin scrollbar matching the sandbox theme
|
|
20
|
-
*/
|
|
21
|
-
export function Terminal({
|
|
22
|
-
output,
|
|
23
|
-
className = "",
|
|
24
|
-
minimized = false,
|
|
25
|
-
onToggleMinimize,
|
|
26
|
-
}: TerminalProps) {
|
|
27
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
28
|
-
|
|
29
|
-
// Auto-scroll to bottom when new output arrives
|
|
30
|
-
useEffect(() => {
|
|
31
|
-
const el = containerRef.current;
|
|
32
|
-
if (el && !minimized) {
|
|
33
|
-
el.scrollTop = el.scrollHeight;
|
|
34
|
-
}
|
|
35
|
-
}, [output.length, minimized]);
|
|
36
|
-
|
|
37
|
-
return (
|
|
38
|
-
<div
|
|
39
|
-
className={`flex flex-col ${minimized ? "" : "h-full"} ${className}`}
|
|
40
|
-
style={{ background: "var(--sb-terminal)" }}
|
|
41
|
-
>
|
|
42
|
-
{/* Header */}
|
|
43
|
-
<div
|
|
44
|
-
className="flex items-center gap-2 px-3 py-1 border-t border-sb-border shrink-0 select-none cursor-pointer"
|
|
45
|
-
style={{ background: "var(--sb-terminal-header)" }}
|
|
46
|
-
onClick={onToggleMinimize}
|
|
47
|
-
>
|
|
48
|
-
{/* Chevron toggle */}
|
|
49
|
-
<svg
|
|
50
|
-
width="14"
|
|
51
|
-
height="14"
|
|
52
|
-
viewBox="0 0 16 16"
|
|
53
|
-
fill="none"
|
|
54
|
-
className="shrink-0 transition-transform duration-150"
|
|
55
|
-
style={{
|
|
56
|
-
transform: minimized ? "rotate(-90deg)" : "rotate(0deg)",
|
|
57
|
-
color: "var(--sb-text-muted)",
|
|
58
|
-
}}
|
|
59
|
-
>
|
|
60
|
-
<path
|
|
61
|
-
d="M4 6l4 4 4-4"
|
|
62
|
-
stroke="currentColor"
|
|
63
|
-
strokeWidth="1.5"
|
|
64
|
-
strokeLinecap="round"
|
|
65
|
-
strokeLinejoin="round"
|
|
66
|
-
/>
|
|
67
|
-
</svg>
|
|
68
|
-
<span
|
|
69
|
-
className="text-[11px] font-medium tracking-wider"
|
|
70
|
-
style={{ color: "var(--sb-text-muted)" }}
|
|
71
|
-
>
|
|
72
|
-
TERMINAL
|
|
73
|
-
</span>
|
|
74
|
-
{/* Line count badge */}
|
|
75
|
-
{output.length > 0 && (
|
|
76
|
-
<span
|
|
77
|
-
className="text-[10px] ml-auto tabular-nums"
|
|
78
|
-
style={{ color: "var(--sb-text-muted)", opacity: 0.6 }}
|
|
79
|
-
>
|
|
80
|
-
{output.length} lines
|
|
81
|
-
</span>
|
|
82
|
-
)}
|
|
83
|
-
</div>
|
|
84
|
-
|
|
85
|
-
{/* Output area — hidden when minimized */}
|
|
86
|
-
{!minimized && (
|
|
87
|
-
<div
|
|
88
|
-
ref={containerRef}
|
|
89
|
-
className="flex-1 overflow-auto px-3.5 py-2 sb-terminal-output overscroll-contain min-h-0"
|
|
90
|
-
>
|
|
91
|
-
{output.length === 0 ? (
|
|
92
|
-
<div style={{ color: "var(--sb-text-muted)" }}>
|
|
93
|
-
<span>$ </span>
|
|
94
|
-
<span className="animate-pulse">_</span>
|
|
95
|
-
</div>
|
|
96
|
-
) : (
|
|
97
|
-
output.map((line, i) => (
|
|
98
|
-
<div
|
|
99
|
-
key={i}
|
|
100
|
-
className="whitespace-pre-wrap"
|
|
101
|
-
style={{ color: "var(--sb-text)" }}
|
|
102
|
-
>
|
|
103
|
-
{line}
|
|
104
|
-
</div>
|
|
105
|
-
))
|
|
106
|
-
)}
|
|
107
|
-
</div>
|
|
108
|
-
)}
|
|
109
|
-
</div>
|
|
110
|
-
);
|
|
111
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ViewSlider — Pill-shaped toggle to switch between Code and Preview views.
|
|
3
|
-
*
|
|
4
|
-
* Inspired by bolt.diy's Slider component. Uses Framer Motion's layoutId
|
|
5
|
-
* for a smooth animated pill that slides between options.
|
|
6
|
-
*
|
|
7
|
-
* @see bolt.diy: app/components/ui/Slider.tsx
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import React, { memo } from "react";
|
|
11
|
-
import { motion } from "framer-motion";
|
|
12
|
-
|
|
13
|
-
/** The two views available in the workbench */
|
|
14
|
-
export type WorkbenchView = "code" | "preview";
|
|
15
|
-
|
|
16
|
-
interface ViewSliderProps {
|
|
17
|
-
selected: WorkbenchView;
|
|
18
|
-
onSelect: (view: WorkbenchView) => void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Cubic bezier easing — typed as a 4-tuple for Framer Motion */
|
|
22
|
-
const CUBIC_EASING: [number, number, number, number] = [0.4, 0, 0.2, 1];
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Individual button within the slider.
|
|
26
|
-
* When selected, renders an animated pill background via layoutId.
|
|
27
|
-
*/
|
|
28
|
-
const SliderButton = memo(function SliderButton({
|
|
29
|
-
selected,
|
|
30
|
-
children,
|
|
31
|
-
onClick,
|
|
32
|
-
}: {
|
|
33
|
-
selected: boolean;
|
|
34
|
-
children: React.ReactNode;
|
|
35
|
-
onClick: () => void;
|
|
36
|
-
}) {
|
|
37
|
-
return (
|
|
38
|
-
<button
|
|
39
|
-
onClick={onClick}
|
|
40
|
-
className={`relative px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
|
41
|
-
selected ? "text-white" : "text-sb-text-muted hover:text-sb-text"
|
|
42
|
-
}`}
|
|
43
|
-
>
|
|
44
|
-
<span className="relative z-10">{children}</span>
|
|
45
|
-
{selected && (
|
|
46
|
-
<motion.span
|
|
47
|
-
layoutId="view-slider-pill"
|
|
48
|
-
transition={{
|
|
49
|
-
duration: 0.2,
|
|
50
|
-
ease: CUBIC_EASING,
|
|
51
|
-
}}
|
|
52
|
-
className="absolute inset-0 z-0 bg-sb-accent rounded-full"
|
|
53
|
-
/>
|
|
54
|
-
)}
|
|
55
|
-
</button>
|
|
56
|
-
);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* ViewSlider — renders a pill-shaped toggle with animated selection indicator.
|
|
61
|
-
*
|
|
62
|
-
* Usage:
|
|
63
|
-
* ```tsx
|
|
64
|
-
* <ViewSlider selected={view} onSelect={setView} />
|
|
65
|
-
* ```
|
|
66
|
-
*/
|
|
67
|
-
export const ViewSlider = memo(function ViewSlider({
|
|
68
|
-
selected,
|
|
69
|
-
onSelect,
|
|
70
|
-
}: ViewSliderProps) {
|
|
71
|
-
return (
|
|
72
|
-
<div className="flex items-center gap-0.5 bg-sb-bg rounded-full p-0.5 border border-sb-border">
|
|
73
|
-
<SliderButton
|
|
74
|
-
selected={selected === "code"}
|
|
75
|
-
onClick={() => onSelect("code")}
|
|
76
|
-
>
|
|
77
|
-
Code
|
|
78
|
-
</SliderButton>
|
|
79
|
-
<SliderButton
|
|
80
|
-
selected={selected === "preview"}
|
|
81
|
-
onClick={() => onSelect("preview")}
|
|
82
|
-
>
|
|
83
|
-
Preview
|
|
84
|
-
</SliderButton>
|
|
85
|
-
</div>
|
|
86
|
-
);
|
|
87
|
-
});
|