se;
constructor(targetId: string) {
this.container = document.getElementById(targetId) as HTMLIFrameElement;
if (!this.container) throw new Error('Preview container not found');
this.setupConsoleBridge();
}
public scheduleUpdate(config: PreviewConfig): void {
this.updateQueue = config;
if (!this.isUpdating) {
this.isUpdating = true;
requestAnimationFrame(() => this.flushUpdate());
}
}
private flushUpdate(): void {
if (!this.updateQueue) {
this.isUpdating = false;
return;
}
const { html, css, js, sandboxFlags = ['allow-scripts', 'allow-modals'] } = this.updateQueue;
this.container.sandbox = sandboxFlags.join(' ') as any;
const documentBundle = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>${css}</style>
</head>
<body>
${html}
<script>
${this.generateConsoleInterceptor()}
${js}
<\/script>
</body>
</html>
`;
this.container.srcdoc = documentBundle;
this.updateQueue = null;
this.isUpdating = false;
}
}
**Rationale:** Using `requestAnimationFrame` batches rapid keystrokes into a single render cycle, preventing layout thrashing and reducing CPU overhead. The `sandbox` attribute is applied dynamically to match the execution context with the injected payload.
### Step 2: Console Telemetry Bridge
User code running inside the iframe operates in a unique origin. Standard `console` output routes to the browser's DevTools, which breaks custom IDE interfaces. To capture telemetry, we inject a script that overrides native console methods and forwards payloads to the parent window via `postMessage`.
```typescript
private generateConsoleInterceptor(): string {
return `
(function() {
const originalConsole = {
log: console.log,
warn: console.warn,
error: console.error,
info: console.info
};
const broadcast = (level, args) => {
try {
window.parent.postMessage({
source: 'preview-engine',
type: 'console',
level,
payload: args.map(arg => String(arg)).join(' ')
}, '*');
} catch (e) {
originalConsole.error('Telemetry bridge failed', e);
}
};
console.log = (...args) => { broadcast('log', args); originalConsole.log.apply(console, args); };
console.warn = (...args) => { broadcast('warn', args); originalConsole.warn.apply(console, args); };
console.error = (...args) => { broadcast('error', args); originalConsole.error.apply(console, args); };
console.info = (...args) => { broadcast('info', args); originalConsole.info.apply(console, args); };
})();
`;
}
Rationale: Preserving original console methods ensures fallback behavior if postMessage fails. Mapping all arguments to strings prevents serialization errors with complex objects. The IIFE wrapper prevents global scope pollution inside the iframe.
Step 3: Parent Window Message Handler
The host application must listen for telemetry and route it to the appropriate UI component.
private setupConsoleBridge(): void {
window.addEventListener('message', (event: MessageEvent) => {
if (event.data?.source !== 'preview-engine') return;
if (event.data.type === 'console') {
this.dispatchConsoleEvent(event.data.level, event.data.payload);
}
});
}
private dispatchConsoleEvent(level: string, message: string): void {
const customEvent = new CustomEvent('preview:console', {
detail: { level, message, timestamp: Date.now() }
});
window.dispatchEvent(customEvent);
}
Rationale: Dispatching a custom DOM event decouples the engine from specific UI frameworks. Consumers can attach listeners without modifying the core engine. The source check prevents processing messages from unrelated iframes or extensions.
Step 4: Security Isolation
The sandbox attribute enforces origin isolation. By default, it disables scripts, forms, popups, and same-origin access. Explicitly granting allow-scripts and allow-modals permits execution while maintaining strict boundaries. Crucially, omitting allow-same-origin ensures the iframe cannot access document.cookie, localStorage, or the parent's DOM.
Rationale: Browser security models treat sandboxed iframes as unique origins. This cryptographic separation matches the isolation guarantees of containerized environments while eliminating network exposure.
Pitfall Guide
1. Unvalidated postMessage Origins
Explanation: Accepting messages without verifying the sender allows malicious extensions or third-party iframes to inject fake console data or trigger UI updates.
Fix: Always check event.origin or implement a strict source identifier payload. Reject messages that don't match expected patterns.
Explanation: Updating srcdoc on every keystroke forces the browser to re-parse the entire document, causing CPU spikes and input lag.
Fix: Batch updates using requestAnimationFrame or debounce with a 50–100ms window. Only flush when the user pauses typing.
3. Memory Leaks from Orphaned Blob URLs
Explanation: Developers often use URL.createObjectURL(new Blob([html])) instead of srcdoc. Forgetting to call URL.revokeObjectURL() causes memory accumulation.
Fix: Prefer srcdoc for synchronous injection. If using Blob URLs, always pair creation with revocation in a cleanup routine.
4. Missing Content Security Policy (CSP)
Explanation: Injected scripts can load external resources, potentially exfiltrating data or executing supply-chain attacks.
Fix: Inject a <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';"> tag inside the srcdoc bundle to restrict network access.
5. Console Method Override Side Effects
Explanation: Overriding console.log without preserving the original reference breaks debugging tools and third-party libraries that rely on native console behavior.
Fix: Store original methods in a closure, apply them after broadcasting, and handle edge cases like console.table or async loggers.
6. iframe Layout Thrashing & Resize Loops
Explanation: Dynamic content inside the iframe can trigger continuous resize events, causing parent layout recalculations and performance degradation.
Fix: Set explicit dimensions on the iframe container. Use ResizeObserver on the iframe's contentDocument if dynamic sizing is required, and throttle resize callbacks.
7. Ignoring Cross-Origin Script Injection Risks
Explanation: If user code includes <script src="..."> tags, the browser may fetch and execute remote code, bypassing sandbox restrictions if allow-same-origin is accidentally enabled.
Fix: Strip external script tags during assembly, or enforce CSP script-src 'unsafe-inline' to block network-fetched JavaScript.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Real-time code playground | Client-side srcdoc | Sub-millisecond feedback, no backend provisioning | Zero infrastructure cost |
| Offline-capable editor | Client-side srcdoc | Execution requires no network, works in PWA mode | Eliminates CDN/edge compute |
| Enterprise compliance audit | Remote container execution | Requires centralized logging, network isolation, and audit trails | High compliance overhead |
| High-security financial tool | Remote container execution | Zero-trust architecture mandates server-side validation | Significant infrastructure investment |
| Educational coding platform | Client-side srcdoc | Instant feedback improves learning retention, scales infinitely | Minimal operational cost |
Configuration Template
// preview-engine.ts
export interface PreviewPayload {
html: string;
css: string;
js: string;
meta?: Record<string, string>;
}
export class PreviewEngine {
private iframe: HTMLIFrameElement;
private pending: PreviewPayload | null = null;
private rendering = false;
constructor(selector: string) {
const el = document.querySelector(selector);
if (!(el instanceof HTMLIFrameElement)) {
throw new TypeError('Target must be an iframe element');
}
this.iframe = el;
this.iframe.sandbox = 'allow-scripts allow-modals';
this.iframe.style.border = 'none';
this.iframe.style.width = '100%';
this.iframe.style.height = '100%';
this.attachTelemetryListener();
}
public queue(payload: PreviewPayload): void {
this.pending = payload;
if (!this.rendering) {
this.rendering = true;
requestAnimationFrame(() => this.commit());
}
}
private commit(): void {
if (!this.pending) {
this.rendering = false;
return;
}
const { html, css, js, meta = {} } = this.pending;
const metaTags = Object.entries(meta)
.map(([k, v]) => `<meta name="${k}" content="${v}">`)
.join('\n');
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';">`;
const bundle = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
${csp}
${metaTags}
<style>${css}</style>
</head>
<body>
${html}
<script>
${this.buildConsoleProxy()}
${js}
<\/script>
</body>
</html>
`;
this.iframe.srcdoc = bundle;
this.pending = null;
this.rendering = false;
}
private buildConsoleProxy(): string {
return `
(() => {
const _c = console;
const _orig = { log: _c.log, warn: _c.warn, error: _c.error, info: _c.info };
const _send = (lvl, ...args) => {
try {
window.parent.postMessage({
__preview__: true,
channel: 'console',
level: lvl,
data: args.map(String).join(' ')
}, '*');
} catch(e) { _orig.error(e); }
};
_c.log = (...a) => { _send('log', ...a); _orig.log.apply(_c, a); };
_c.warn = (...a) => { _send('warn', ...a); _orig.warn.apply(_c, a); };
_c.error = (...a) => { _send('error', ...a); _orig.error.apply(_c, a); };
_c.info = (...a) => { _send('info', ...a); _orig.info.apply(_c, a); };
})();
`;
}
private attachTelemetryListener(): void {
window.addEventListener('message', (evt: MessageEvent) => {
if (evt.data?.__preview__ && evt.data.channel === 'console') {
window.dispatchEvent(new CustomEvent('preview:output', {
detail: { level: evt.data.level, text: evt.data.data, ts: Date.now() }
}));
}
});
}
}
Quick Start Guide
- Create the iframe container: Add an
<iframe id="preview-target"></iframe> to your layout. Ensure it has explicit dimensions or is wrapped in a flex/grid container.
- Initialize the engine: Import
PreviewEngine, pass the CSS selector, and call queue() with your HTML/CSS/JS payload.
- Listen for telemetry: Attach a
preview:output event listener to your console UI component. Route detail.level and detail.text to your log renderer.
- Bind to your editor: Connect your code editor's
onChange or onInput handler to engine.queue(). Apply a 50ms debounce if your editor emits events per keystroke.
- Validate isolation: Open browser DevTools, inspect the iframe's origin, and verify that
document.cookie and localStorage return empty or throw security errors. Test with a payload attempting window.parent.document to confirm access denial.