Basic Usage
Create a sandbox, subscribe before rendering, await readiness, and dispose it when finished.
import { createSandbox } from '@vielzeug/sandbox';
const container = document.getElementById('preview')!;
const sandbox = createSandbox(container);
sandbox.onMessage((message) => {
if (message.type === 'error') console.error(message.message);
if (message.type === 'resize') container.style.height = `${message.height}px`;
});
try {
await sandbox.render('<main><h1>Preview</h1></main>');
} finally {
sandbox.dispose();
}The iframe is created by the first render() call. Each render creates a fresh document and resolves after its generated bridge reports ready.
Rendering HTML
Use render() when scripts, styles, globals, and DOM state should reset together.
await sandbox.render(`
<style>body { font-family: system-ui; }</style>
<button id="save">Save</button>
<script>
document.querySelector('#save').addEventListener('click', () => {
window.__sandbox__.emit('save');
});
</script>
`);A newer render supersedes a pending one. Messages from the old document are ignored through the per-render generation marker.
Pushing Typed State
Pass a state interface to createSandbox() and send partial updates with one object.
interface PreviewState {
locale: string;
theme: 'dark' | 'light';
user: { name: string };
}
const sandbox = createSandbox<PreviewState>(container);
await sandbox.render('<main id="app"></main>');
sandbox.setState({
locale: 'en',
theme: 'dark',
user: { name: 'Ada' },
});Subscribe inside the iframe through the typed bridge.
interface PreviewState {
locale: string;
theme: 'dark' | 'light';
}
declare interface Window {
__sandbox__: import('@vielzeug/sandbox').SandboxBridge<PreviewState>;
}
window.__sandbox__.onState('theme', (theme) => {
document.documentElement.dataset.theme = theme;
});setState() is safe to call at any point in the lifecycle. Updates made before render() resolves are accumulated and delivered in one message when the bridge reports ready, so an early call never loses state.
Receiving Sandbox Messages
Subscribe with onMessage() before rendering. The unsubscribe function detaches only that handler.
const unsubscribe = sandbox.onMessage((message) => {
if (message.type === 'custom' && message.event === 'saved') {
if (isSavedDetail(message.detail)) persist(message.detail.id);
}
});
function isSavedDetail(value: unknown): value is { id: string } {
return typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string';
}Custom event details remain unknown on the host. The iframe is isolated, but its code still controls message payloads.
Use SandboxBridge<State, Events> only to check authored sandbox-side calls.
interface Events {
saved: { id: string };
}
declare interface Window {
__sandbox__: import('@vielzeug/sandbox').SandboxBridge<Record<string, never>, Events>;
}
window.__sandbox__.emit('saved', { id: '42' });Updating Live Content
Replace the body
Use replaceBody() when streamed HTML should preserve head scripts, styles, and global listeners.
await sandbox.render('<main>Waiting…</main>');
let html = '';
for await (const chunk of stream) {
html += chunk;
sandbox.replaceBody(html);
}Replacement uses document.body.innerHTML. Scripts in replacement HTML do not execute, and body descendant state and listeners are discarded.
Update a named style
Declare styles at construction and patch them without replacing the document.
const sandbox = createSandbox(container, {
styles: { theme: 'body { color-scheme: light; }' },
});
await sandbox.render('<main>Preview</main>');
sandbox.updateStyle('theme', 'body { color-scheme: dark; }');Calling updateStyle() before the first render updates the render baseline. Unknown IDs emit a development warning.
Injecting Scripts and Configuring CSP
List absolute external scripts in scripts. Their origins are added to script-src automatically.
const sandbox = createSandbox(container, {
allowedFontOrigins: ['https://fonts.gstatic.com'],
allowedStyleOrigins: ['https://fonts.googleapis.com'],
scripts: ['https://cdn.example.com/widget.js'],
});Network requests remain blocked by connect-src 'none'. The package does not expose arbitrary CSP directives.
A nonce authorizes the generated bridge while preventing un-nonced inline user scripts in CSP Level 3 browsers.
const sandbox = createSandbox(container, {
nonce: crypto.randomUUID(),
});Handling Errors and Timeouts
render() rejects if the document does not become ready before readyTimeout.
import { SandboxError, SandboxTimeoutError } from '@vielzeug/sandbox';
try {
await sandbox.render(html);
} catch (error) {
if (error instanceof SandboxTimeoutError) {
showError('A script blocked preview initialization.');
} else if (error instanceof SandboxError) {
showError(error.message);
}
}Uncaught iframe errors and unhandled rejections arrive through onMessage() as untrusted error messages.
Disposal
Use dispose() or a using declaration to remove the iframe and host listeners.
using sandbox = createSandbox(container);
await sandbox.render('<p>Temporary preview</p>');Tie other asynchronous work to disposalSignal.
const response = await fetch('/preview-data', {
signal: sandbox.disposalSignal,
});Testing
Use the testing subpath to complete render() and simulate messages in jsdom.
import { createSandbox } from '@vielzeug/sandbox';
import { createSandboxTestHelpers } from '@vielzeug/sandbox/testing';
import { expect, it } from 'vitest';
it('receives resize messages', async () => {
const container = document.createElement('div');
const sandbox = createSandbox(container);
const helpers = createSandboxTestHelpers(container);
const heights: number[] = [];
sandbox.onMessage((message) => {
if (message.type === 'resize') heights.push(message.height);
});
const render = sandbox.render('<p>Preview</p>');
helpers.fireReady();
await render;
helpers.fireResize(240);
expect(heights).toEqual([240]);
sandbox.dispose();
});Framework Integration
Own one sandbox per mounted component and dispose it during unmount.
import { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';
import { useEffect, useRef } from 'react';
export function Preview({ html }: { html: string }) {
const container = useRef<HTMLDivElement>(null);
const sandbox = useRef<SandboxHandle>();
useEffect(() => {
sandbox.current = createSandbox(container.current!);
return () => sandbox.current?.dispose();
}, []);
useEffect(() => {
void sandbox.current?.render(html).catch(console.error);
}, [html]);
return <div ref={container} />;
}<script setup lang="ts">
import { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';
import { onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps<{ html: string }>();
const container = ref<HTMLElement>();
let sandbox: SandboxHandle | undefined;
onMounted(() => {
sandbox = createSandbox(container.value!);
void sandbox.render(props.html).catch(console.error);
});
watch(() => props.html, (html) => void sandbox?.render(html).catch(console.error));
onUnmounted(() => sandbox?.dispose());
</script>
<template><div ref="container" /></template><script lang="ts">
import { createSandbox, type SandboxHandle } from '@vielzeug/sandbox';
import { onMount } from 'svelte';
export let html: string;
let container: HTMLElement;
let sandbox: SandboxHandle | undefined;
onMount(() => {
sandbox = createSandbox(container);
return () => sandbox?.dispose();
});
$: if (sandbox) void sandbox.render(html).catch(console.error);
</script>
<div bind:this={container}></div>Working with Other Vielzeug Libraries
Use Sandbox to isolate generated markup that loads browser bundles of DOM-output packages such as Refine or Ore. Use Rune in the host to record validated message details; do not pass untrusted payloads directly to structured logs.
Best Practices
- Await
render()before sending state or replacing body content. - Narrow every custom message detail at the host boundary.
- Prefer
setState()for data changes andupdateStyle()for CSS changes. - Use
render()when scripts or global state must reset. - Handle
SandboxTimeoutErrorat every non-test render boundary. - Dispose one sandbox with each owning component.
- Tie related asynchronous work to
disposalSignal. - Keep network access blocked unless the application requires a different isolation model.