Skip to content

Commit d01b849

Browse files
committed
fix(runtime): boot WebContainer on main thread via BroadcastChannel bridge
Web extensions run in a Worker without DOM, so WebContainer could not boot from the extension host. Add workbench wc-bridge.js, route the shell through BroadcastChannel, and surface download/boot with notifications and status bar.
1 parent b1a76ee commit d01b849

7 files changed

Lines changed: 687 additions & 176 deletions

File tree

‎PLAN.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,5 +456,6 @@ pnpm smoke # lighter checks
456456
| 2026-07-27 | **B8c**: multi-project browser repos — first-run clone dialog, Browser Projects view, manage/switch/delete, unique workspace id per clone, last project restore via localStorage + OPFS/IDB, `navigator.storage.persist()` |
457457
| 2026-07-27 | **WB7**: browser integrated terminal via Pseudoterminal — WebContainer `jsh` + Pyodide REPL; terminal profiles; browser `terminal: true`; Open Browser Shell command |
458458
| 2026-07-27 | **WB8**: shell startup UX — WC prefetch + status bar + progress + auto-open terminal; Pyodide warm status |
459+
| 2026-07-27 | **WB8 fix**: main-thread `wc-bridge.js` + BroadcastChannel (web EH has no DOM); notification + bridge-based shell |
459460

460461
**When you complete work:** set the package **Status** to `done`, add a one-line **Last note** (commit SHA or PR), and append a row to §10.

‎apps/workbench/scripts/build.mjs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,13 +326,23 @@ const indexHtml = `<!DOCTYPE html>
326326
<script>
327327
window.product = ${JSON.stringify(defaultProduct)};
328328
</script>
329+
<!-- Main-thread WebContainer bridge (extensions run in a Worker — no DOM) -->
330+
<script src="./wc-bridge.js"></script>
329331
<script src="./bootstrap.js"></script>
330332
</body>
331333
</html>
332334
`;
333335

334336
writeFileSync(join(dist, 'index.html'), indexHtml);
335337

338+
// Copy main-thread WebContainer bridge (BroadcastChannel ↔ extensions)
339+
const wcBridgeSrc = join(root, 'scripts/wc-bridge.js');
340+
if (existsSync(wcBridgeSrc)) {
341+
cpSync(wcBridgeSrc, join(dist, 'wc-bridge.js'));
342+
} else {
343+
console.warn('apps/workbench: missing scripts/wc-bridge.js');
344+
}
345+
336346
const bootstrap = `/* ZCode workbench bootstrap — load VS Code Web + inject extension URIs */
337347
(async function () {
338348
const splash = document.getElementById('monaco-parts-splash');
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* Main-thread WebContainer bridge for ZCode.
3+
*
4+
* VS Code web extensions run in a Worker (no DOM). WebContainers need the
5+
* window/document to boot. This script runs on the workbench page and talks
6+
* to extensions via BroadcastChannel('zcode-webcontainer-v1').
7+
*/
8+
(function () {
9+
const CHANNEL = 'zcode-webcontainer-v1';
10+
const DEFAULT_CDN =
11+
'https://cdn.jsdelivr.net/npm/@webcontainer/api@1.6.1/+esm';
12+
13+
/** @type {BroadcastChannel} */
14+
const bc = new BroadcastChannel(CHANNEL);
15+
16+
/** @type {import('@webcontainer/api').WebContainer | null} */
17+
let wc = null;
18+
/** @type {Promise<void> | null} */
19+
let bootPromise = null;
20+
/** @type {Map<string, { proc: any, writer: WritableStreamDefaultWriter<string> }>} */
21+
const shells = new Map();
22+
23+
function emitStatus(phase, message) {
24+
bc.postMessage({ type: 'status', phase, message });
25+
try {
26+
console.info('[zcode-wc]', phase, message || '');
27+
} catch (_) {
28+
/* ignore */
29+
}
30+
}
31+
32+
function reply(msg) {
33+
bc.postMessage(msg);
34+
}
35+
36+
async function loadApi(cdnUrl) {
37+
emitStatus('downloading', 'Downloading WebContainer API (CDN)…');
38+
const url = cdnUrl || DEFAULT_CDN;
39+
const mod = await import(/* @vite-ignore */ url);
40+
const WC =
41+
mod.WebContainer ||
42+
(mod.default && mod.default.boot ? mod.default : null) ||
43+
(mod.default && mod.default.WebContainer) ||
44+
null;
45+
if (!WC || typeof WC.boot !== 'function') {
46+
throw new Error('WebContainer API failed to load from ' + url);
47+
}
48+
return WC;
49+
}
50+
51+
async function ensureBoot(cdnUrl) {
52+
if (wc) {
53+
emitStatus('ready', 'WebContainer ready');
54+
return wc;
55+
}
56+
if (!bootPromise) {
57+
bootPromise = (async () => {
58+
const WC = await loadApi(cdnUrl);
59+
emitStatus(
60+
'booting',
61+
'Starting browser Node environment (first boot can take 10–30s)…',
62+
);
63+
const isolated = !!(globalThis.crossOriginIsolated);
64+
const coep = isolated ? 'require-corp' : 'none';
65+
wc = await WC.boot({ coep });
66+
emitStatus(
67+
'ready',
68+
isolated
69+
? 'WebContainer ready (cross-origin isolated)'
70+
: 'WebContainer ready (COI off — some features may be limited)',
71+
);
72+
})().catch((err) => {
73+
bootPromise = null;
74+
const message = err && err.message ? err.message : String(err);
75+
emitStatus('error', message);
76+
throw err;
77+
});
78+
} else {
79+
emitStatus('booting', 'WebContainer boot already in progress…');
80+
}
81+
await bootPromise;
82+
return wc;
83+
}
84+
85+
async function spawnShell(sessionId, cols, rows) {
86+
const container = await ensureBoot();
87+
emitStatus('mounting', 'Spawning interactive shell (jsh)…');
88+
const proc = await container.spawn('jsh', {
89+
terminal: { cols: cols || 80, rows: rows || 24 },
90+
});
91+
const writer = proc.input.getWriter();
92+
shells.set(sessionId, { proc, writer });
93+
94+
// Pump output to extension
95+
(async () => {
96+
const reader = proc.output.getReader();
97+
try {
98+
while (true) {
99+
const { done, value } = await reader.read();
100+
if (done) break;
101+
if (value) {
102+
reply({ type: 'output', sessionId, data: value });
103+
}
104+
}
105+
} catch (_) {
106+
/* closed */
107+
} finally {
108+
try {
109+
reader.releaseLock();
110+
} catch (_) {
111+
/* ignore */
112+
}
113+
}
114+
let code = 0;
115+
try {
116+
code = await proc.exit;
117+
} catch (_) {
118+
code = 1;
119+
}
120+
shells.delete(sessionId);
121+
reply({ type: 'exit', sessionId, code });
122+
})();
123+
124+
emitStatus('ready', 'WebContainer shell ready');
125+
}
126+
127+
async function mountTree(tree) {
128+
const container = await ensureBoot();
129+
emitStatus('mounting', 'Mounting workspace into WebContainer…');
130+
await container.mount(tree || {});
131+
emitStatus('ready', 'Workspace mounted');
132+
}
133+
134+
bc.onmessage = (ev) => {
135+
const msg = ev.data || {};
136+
const id = msg.id;
137+
138+
(async () => {
139+
try {
140+
switch (msg.type) {
141+
case 'ping':
142+
reply({
143+
type: 'pong',
144+
id,
145+
ready: !!wc,
146+
isolated: !!globalThis.crossOriginIsolated,
147+
});
148+
break;
149+
150+
case 'prefetch':
151+
case 'boot':
152+
await ensureBoot(msg.cdnUrl);
153+
reply({ type: 'boot-result', id, ok: true, ready: true });
154+
break;
155+
156+
case 'mount':
157+
await mountTree(msg.tree);
158+
reply({ type: 'mount-result', id, ok: true });
159+
break;
160+
161+
case 'spawn-shell': {
162+
const sessionId = msg.sessionId || id || 'shell-' + Date.now();
163+
await spawnShell(sessionId, msg.cols, msg.rows);
164+
reply({ type: 'spawn-result', id, ok: true, sessionId });
165+
break;
166+
}
167+
168+
case 'input': {
169+
const s = shells.get(msg.sessionId);
170+
if (s && msg.data != null) {
171+
await s.writer.write(String(msg.data));
172+
}
173+
break;
174+
}
175+
176+
case 'resize': {
177+
const s = shells.get(msg.sessionId);
178+
if (s && s.proc.resize) {
179+
s.proc.resize({
180+
cols: msg.cols || 80,
181+
rows: msg.rows || 24,
182+
});
183+
}
184+
break;
185+
}
186+
187+
case 'kill': {
188+
const s = shells.get(msg.sessionId);
189+
if (s) {
190+
try {
191+
s.proc.kill();
192+
} catch (_) {
193+
/* ignore */
194+
}
195+
try {
196+
await s.writer.close();
197+
} catch (_) {
198+
/* ignore */
199+
}
200+
shells.delete(msg.sessionId);
201+
}
202+
break;
203+
}
204+
205+
default:
206+
break;
207+
}
208+
} catch (err) {
209+
const message = err && err.message ? err.message : String(err);
210+
emitStatus('error', message);
211+
if (msg.type === 'boot' || msg.type === 'prefetch') {
212+
reply({ type: 'boot-result', id, ok: false, error: message });
213+
} else if (msg.type === 'spawn-shell') {
214+
reply({ type: 'spawn-result', id, ok: false, error: message });
215+
} else if (msg.type === 'mount') {
216+
reply({ type: 'mount-result', id, ok: false, error: message });
217+
}
218+
}
219+
})();
220+
};
221+
222+
// Early status so extensions know the bridge is alive
223+
emitStatus('idle', 'WebContainer bridge ready (main thread)');
224+
225+
// Auto-prefetch shortly after paint so CDN download starts before the user opens a shell
226+
setTimeout(() => {
227+
ensureBoot().catch(() => {
228+
/* status already emitted */
229+
});
230+
}, 800);
231+
232+
// Expose for debugging in DevTools
233+
globalThis.__zcodeWcBridge = {
234+
channel: CHANNEL,
235+
isReady: () => !!wc,
236+
boot: () => ensureBoot(),
237+
};
238+
})();

‎docs/webcontainers-node.md‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,21 +56,36 @@ Browser mode uses VS Code **extension terminals**, not REH `node-pty`:
5656

5757
Requires WebContainers (not worker fallback). Prefer `ZCODE_COI=1` for SharedArrayBuffer.
5858

59+
### Main-thread bridge (required)
60+
61+
VS Code web extensions run in a **Worker** (no DOM). WebContainers need the real window, so the workbench page loads `wc-bridge.js` and talks to the extension over **`BroadcastChannel('zcode-webcontainer-v1')`**.
62+
63+
- Script: `apps/workbench/dist/wc-bridge.js` (built from `apps/workbench/scripts/wc-bridge.js`)
64+
- Loaded from workbench `index.html` before `bootstrap.js`
65+
- Auto-prefetches ~800ms after page load; extension also prefetches + auto-opens shell
66+
5967
### Startup feedback
6068

6169
After the workbench loads (browser mode only):
6270

63-
1. **Status bar** — `Shell: downloading…` → `starting…` → `mounting…` → `ready` (or `offline`)
64-
2. **Progress** — With auto-open (default): feedback lives in the Terminal panel + status bar. With auto-open off + prefetch on: Notification on first cold boot, then Window progress
65-
3. **Auto-open shell** (default) — Terminal panel opens immediately with download logs so it is never empty
66-
4. **Output** channel **ZCode Shell** — phase log for debugging
71+
1. **Notification** — “ZCode: browser shell” while CDN download / boot runs
72+
2. **Status bar** — `Shell: downloading…` → `starting…` → `ready` (or `offline`)
73+
3. **Auto-open shell** (default) — Terminal shows bridge + download logs, then `jsh`
74+
4. **Output** channel **ZCode Shell** — phase log
6775

6876
| Setting | Default | Meaning |
6977
| --- | --- | --- |
70-
| `zcode.execution.prefetchWebContainer` | `true` | Boot WebContainer in the background after load |
78+
| `zcode.execution.prefetchWebContainer` | `true` | Boot WebContainer after load (via bridge) |
7179
| `zcode.execution.autoOpenShell` | `true` | Open WebContainer terminal on startup |
7280
| `zcode.execution.prefetchPyodide` | `true` | Warm Pyodide WASM (status bar only; no auto REPL) |
7381

82+
If the shell says “bridge not loaded”, rebuild workbench and hard-refresh:
83+
84+
```bash
85+
pnpm --filter @zcode/workbench build
86+
# then hard-refresh the IDE tab
87+
```
88+
7489
## Commands
7590

7691
| Command | Action |

0 commit comments

Comments
 (0)