Skip to content

Commit 413d188

Browse files
committed
fix: use sandboxed iframe for WASM isolation
Browser extensions (React DevTools, etc.) can interfere with ruby.wasm by overriding FinalizationRegistry and other APIs. This change isolates the WASM execution in a sandboxed iframe: - Add static/wasm-worker.html for isolated WASM execution - Rewrite ruby-wasm.ts to use iframe + postMessage - Update playground.tsx to handle async compile results
1 parent 13a800d commit 413d188

File tree

3 files changed

+302
-152
lines changed

3 files changed

+302
-152
lines changed

src/lib/ruby-wasm.ts

Lines changed: 144 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
/**
22
* T-Ruby WASM Loader for Playground
33
*
4-
* Handles lazy loading and initialization of Ruby WASM with T-Ruby compiler
4+
* Uses a sandboxed iframe to isolate WASM execution from browser extensions
5+
* that might interfere with FinalizationRegistry and other APIs.
56
*/
67

7-
// CDN URLs
8-
const RUBY_WASM_CDN = 'https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2.7.0/dist/browser/+esm';
9-
const RUBY_WASM_BINARY = 'https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2.7.0/dist/ruby+stdlib.wasm';
10-
const T_RUBY_LIB_CDN = 'https://cdn.jsdelivr.net/npm/@t-ruby/wasm/dist/lib/';
11-
128
// Types
139
export interface CompileResult {
1410
success: boolean;
@@ -18,7 +14,7 @@ export interface CompileResult {
1814
}
1915

2016
export interface TRubyCompiler {
21-
compile(code: string): CompileResult;
17+
compile(code: string): Promise<CompileResult>;
2218
healthCheck(): { loaded: boolean; version: string; ruby_version: string };
2319
getVersion(): { t_ruby: string; ruby: string };
2420
}
@@ -32,68 +28,25 @@ export interface LoadingProgress {
3228
}
3329

3430
// Singleton state
35-
let rubyVM: any = null;
3631
let compiler: TRubyCompiler | null = null;
3732
let loadingPromise: Promise<TRubyCompiler> | null = null;
33+
let workerIframe: HTMLIFrameElement | null = null;
34+
let healthData: { loaded: boolean; version: string; ruby_version: string } | null = null;
35+
36+
// Pending compile requests
37+
const pendingRequests = new Map<string, {
38+
resolve: (result: CompileResult) => void;
39+
reject: (error: Error) => void;
40+
}>();
3841

39-
// Bootstrap code for T-Ruby compiler
40-
const BOOTSTRAP_CODE = `
41-
require "json"
42-
43-
# Global compiler instance
44-
$trb_compiler = nil
45-
46-
def get_compiler
47-
$trb_compiler ||= TRuby::Compiler.new
48-
end
49-
50-
def __trb_compile__(code)
51-
compiler = get_compiler
52-
53-
begin
54-
result = compiler.compile_string(code)
55-
56-
{
57-
success: result[:errors].empty?,
58-
ruby: result[:ruby] || "",
59-
rbs: result[:rbs] || "",
60-
errors: result[:errors] || []
61-
}.to_json
62-
rescue TRuby::ParseError => e
63-
{
64-
success: false,
65-
ruby: "",
66-
rbs: "",
67-
errors: [e.message]
68-
}.to_json
69-
rescue StandardError => e
70-
{
71-
success: false,
72-
ruby: "",
73-
rbs: "",
74-
errors: ["Compilation error: " + e.message]
75-
}.to_json
76-
end
77-
end
78-
79-
def __trb_health_check__
80-
{
81-
loaded: defined?(TRuby) == "constant",
82-
version: defined?(TRuby::VERSION) ? TRuby::VERSION : "unknown",
83-
ruby_version: RUBY_VERSION
84-
}.to_json
85-
end
86-
87-
def __trb_version__
88-
{
89-
t_ruby: defined?(TRuby::VERSION) ? TRuby::VERSION : "unknown",
90-
ruby: RUBY_VERSION
91-
}.to_json
92-
end
93-
`;
42+
let requestIdCounter = 0;
43+
44+
function generateRequestId(): string {
45+
return `req_${++requestIdCounter}_${Date.now()}`;
46+
}
9447

9548
/**
96-
* Load the T-Ruby WASM compiler
49+
* Load the T-Ruby WASM compiler using sandboxed iframe
9750
* Returns cached instance if already loaded
9851
*/
9952
export async function loadTRubyCompiler(
@@ -125,101 +78,142 @@ export async function loadTRubyCompiler(
12578
async function doLoadCompiler(
12679
onProgress?: (progress: LoadingProgress) => void
12780
): Promise<TRubyCompiler> {
128-
try {
129-
// Step 1: Load Ruby WASM module
130-
console.log('[T-Ruby] Step 1: Loading Ruby WASM module from', RUBY_WASM_CDN);
81+
return new Promise((resolve, reject) => {
82+
console.log('[T-Ruby] Creating sandboxed iframe for WASM execution...');
13183
onProgress?.({
13284
state: 'loading',
133-
message: 'Loading Ruby runtime...',
134-
progress: 10
85+
message: 'Initializing compiler sandbox...',
86+
progress: 5
13587
});
13688

137-
const { DefaultRubyVM } = await import(/* webpackIgnore: true */ RUBY_WASM_CDN);
138-
console.log('[T-Ruby] Step 1 complete: DefaultRubyVM loaded');
139-
140-
onProgress?.({
141-
state: 'loading',
142-
message: 'Downloading Ruby WASM binary...',
143-
progress: 30
144-
});
145-
146-
// Step 2: Fetch and compile WASM binary
147-
console.log('[T-Ruby] Step 2: Fetching WASM binary from', RUBY_WASM_BINARY);
148-
const response = await fetch(RUBY_WASM_BINARY);
149-
console.log('[T-Ruby] Step 2: WASM fetch response status:', response.status);
150-
const wasmModule = await WebAssembly.compileStreaming(response);
151-
console.log('[T-Ruby] Step 2 complete: WASM module compiled');
152-
153-
onProgress?.({
154-
state: 'loading',
155-
message: 'Initializing Ruby VM...',
156-
progress: 60
157-
});
158-
159-
// Step 3: Initialize Ruby VM
160-
console.log('[T-Ruby] Step 3: Initializing Ruby VM...');
161-
const { vm } = await DefaultRubyVM(wasmModule);
162-
rubyVM = vm;
163-
console.log('[T-Ruby] Step 3 complete: Ruby VM initialized');
164-
165-
onProgress?.({
166-
state: 'loading',
167-
message: 'Loading T-Ruby compiler...',
168-
progress: 80
169-
});
170-
171-
// Step 4: Load T-Ruby library
172-
console.log('[T-Ruby] Step 4: Loading T-Ruby compiler bootstrap...');
173-
174-
// Initialize the compiler bootstrap
175-
vm.eval(BOOTSTRAP_CODE);
176-
console.log('[T-Ruby] Step 4 complete: Bootstrap code evaluated');
89+
// Create sandboxed iframe
90+
const iframe = document.createElement('iframe');
91+
iframe.style.display = 'none';
92+
iframe.sandbox.add('allow-scripts');
93+
iframe.src = '/wasm-worker.html';
94+
95+
// Message handler
96+
const messageHandler = (event: MessageEvent) => {
97+
// Only accept messages from our iframe
98+
if (event.source !== iframe.contentWindow) return;
99+
100+
const { type, data, requestId } = event.data || {};
101+
console.log('[T-Ruby] Received message from worker:', type, data);
102+
103+
switch (type) {
104+
case 'loaded':
105+
console.log('[T-Ruby] Worker iframe loaded, sending init command...');
106+
iframe.contentWindow?.postMessage({ type: 'init' }, '*');
107+
break;
108+
109+
case 'progress':
110+
onProgress?.({
111+
state: 'loading',
112+
message: data.message,
113+
progress: data.progress
114+
});
115+
break;
116+
117+
case 'ready':
118+
console.log('[T-Ruby] Worker ready, health:', data.health);
119+
healthData = data.health;
120+
onProgress?.({
121+
state: 'ready',
122+
message: 'Compiler ready',
123+
progress: 100
124+
});
125+
126+
// Create compiler interface
127+
const compilerInstance: TRubyCompiler = {
128+
async compile(code: string): Promise<CompileResult> {
129+
return new Promise((res, rej) => {
130+
const reqId = generateRequestId();
131+
pendingRequests.set(reqId, { resolve: res, reject: rej });
132+
133+
console.log('[T-Ruby] Sending compile request:', reqId);
134+
iframe.contentWindow?.postMessage({
135+
type: 'compile',
136+
data: { code },
137+
requestId: reqId
138+
}, '*');
139+
140+
// Timeout after 30 seconds
141+
setTimeout(() => {
142+
if (pendingRequests.has(reqId)) {
143+
pendingRequests.delete(reqId);
144+
rej(new Error('Compile timeout'));
145+
}
146+
}, 30000);
147+
});
148+
},
149+
150+
healthCheck() {
151+
return healthData || { loaded: false, version: 'unknown', ruby_version: 'unknown' };
152+
},
153+
154+
getVersion() {
155+
return {
156+
t_ruby: healthData?.version || 'unknown',
157+
ruby: healthData?.ruby_version || 'unknown'
158+
};
159+
}
160+
};
161+
162+
workerIframe = iframe;
163+
resolve(compilerInstance);
164+
break;
165+
166+
case 'compile-result':
167+
console.log('[T-Ruby] Compile result received for:', requestId);
168+
const pending = pendingRequests.get(requestId);
169+
if (pending) {
170+
pendingRequests.delete(requestId);
171+
pending.resolve(data);
172+
}
173+
break;
174+
175+
case 'error':
176+
console.error('[T-Ruby] Worker error:', data.message);
177+
onProgress?.({
178+
state: 'error',
179+
message: data.message
180+
});
181+
window.removeEventListener('message', messageHandler);
182+
document.body.removeChild(iframe);
183+
reject(new Error(data.message));
184+
break;
185+
}
186+
};
177187

178-
// Health check
179-
const healthResult = vm.eval('__trb_health_check__');
180-
console.log('[T-Ruby] Health check:', healthResult.toString());
188+
window.addEventListener('message', messageHandler);
181189

182-
onProgress?.({
183-
state: 'ready',
184-
message: 'Compiler ready',
185-
progress: 100
186-
});
190+
// Error handling for iframe load failure
191+
iframe.onerror = () => {
192+
console.error('[T-Ruby] Failed to load worker iframe');
193+
window.removeEventListener('message', messageHandler);
194+
reject(new Error('Failed to load WASM worker'));
195+
};
187196

188-
// Return compiler interface
189-
return {
190-
compile(code: string): CompileResult {
191-
console.log('[T-Ruby] Compiling code:', code.substring(0, 100) + (code.length > 100 ? '...' : ''));
192-
try {
193-
const resultJson = vm.eval(`__trb_compile__(${JSON.stringify(code)})`);
194-
const resultStr = resultJson.toString();
195-
console.log('[T-Ruby] Raw compile result:', resultStr);
196-
const result = JSON.parse(resultStr);
197-
console.log('[T-Ruby] Parsed compile result:', result);
198-
return result;
199-
} catch (e) {
200-
console.error('[T-Ruby] Compile error:', e);
201-
throw e;
202-
}
203-
},
204-
205-
healthCheck() {
206-
const resultJson = vm.eval('__trb_health_check__');
207-
return JSON.parse(resultJson.toString());
208-
},
209-
210-
getVersion() {
211-
const resultJson = vm.eval('__trb_version__');
212-
return JSON.parse(resultJson.toString());
197+
// Timeout for initial load
198+
const loadTimeout = setTimeout(() => {
199+
if (!compiler) {
200+
console.error('[T-Ruby] Worker initialization timeout');
201+
window.removeEventListener('message', messageHandler);
202+
document.body.removeChild(iframe);
203+
reject(new Error('WASM worker initialization timeout'));
213204
}
205+
}, 60000); // 60 second timeout for initial load
206+
207+
// Clear timeout when ready
208+
const originalResolve = resolve;
209+
resolve = (value) => {
210+
clearTimeout(loadTimeout);
211+
originalResolve(value);
214212
};
215-
} catch (error) {
216-
console.error('[T-Ruby] Loading error:', error);
217-
onProgress?.({
218-
state: 'error',
219-
message: error instanceof Error ? error.message : 'Unknown error'
220-
});
221-
throw error;
222-
}
213+
214+
// Add iframe to document
215+
document.body.appendChild(iframe);
216+
});
223217
}
224218

225219
/**

src/pages/playground.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ interface CompileResult {
152152
}
153153

154154
interface TRubyCompiler {
155-
compile(code: string): CompileResult;
155+
compile(code: string): CompileResult | Promise<CompileResult>;
156156
}
157157

158158
// Fallback mock compiler (used when WASM fails to load)
@@ -249,7 +249,7 @@ function PlaygroundContent(): JSX.Element {
249249
}
250250

251251
// Compile the code
252-
const result = currentCompiler.compile(code);
252+
const result = await currentCompiler.compile(code);
253253

254254
setOutput({
255255
ruby: result.ruby,

0 commit comments

Comments
 (0)