osw-studio / public /workers /script-worker.js
otst's picture
es module support, theming, compaction and stability improvements
716b726
/**
* Script Worker β€” Executes Python (Pyodide) and Lua (wasmoon) scripts
* in a Web Worker to avoid blocking the UI thread.
*
* Receives: { type: 'execute', payload: { runtime, entryPoint, files } }
* { type: 'abort' }
* Posts: { type: 'stdout'|'stderr'|'status'|'error'|'complete'|'output-file', ... }
*/
/* global self, importScripts, postMessage */
let pyodide = null;
let luaFactory = null;
/**
* Post a message back to the main thread.
*/
function send(type, data) {
self.postMessage({ type, data });
}
function sendFile(path, content) {
self.postMessage({ type: 'output-file', path, content });
}
// ─── Python (Pyodide) ──────────────────────────────────────────────
async function ensurePyodide() {
if (pyodide) return pyodide;
send('status', 'Loading Python runtime...');
try {
importScripts('https://cdn.jsdelivr.net/pyodide/v0.27.4/full/pyodide.js');
} catch (err) {
send('error', 'Failed to load Pyodide from CDN: ' + String(err));
throw err;
}
try {
pyodide = await self.loadPyodide({
stdout: (msg) => send('stdout', msg),
stderr: (msg) => send('stderr', msg),
});
} catch (err) {
send('error', 'Failed to initialize Pyodide: ' + String(err));
throw err;
}
// Pre-load micropip so users can install packages
await pyodide.loadPackage('micropip');
send('status', 'Python runtime ready');
return pyodide;
}
async function executePython(entryPoint, files) {
const py = await ensurePyodide();
// Mount VFS files into Pyodide's filesystem
// Create /output/ directory for visual output
try { py.FS.mkdir('/output'); } catch (_e) { /* exists */ }
for (const [path, content] of Object.entries(files)) {
// Skip dotfiles
if (path.startsWith('/.')) continue;
const dir = path.substring(0, path.lastIndexOf('/')) || '/';
// Ensure parent directories exist
const parts = dir.split('/').filter(Boolean);
let current = '';
for (const part of parts) {
current += '/' + part;
try { py.FS.mkdir(current); } catch (_e) { /* exists */ }
}
py.FS.writeFile(path, content);
}
// Run the entry point script
const code = files[entryPoint];
if (!code) {
send('error', `Entry point not found: ${entryPoint}`);
return { exitCode: 1 };
}
// Set up Python environment so module imports work:
const entryDir = entryPoint.substring(0, entryPoint.lastIndexOf('/')) || '/';
try {
await py.runPythonAsync(`
import sys, os
os.chdir(${JSON.stringify(entryDir)})
_ep_dir = ${JSON.stringify(entryDir)}
if _ep_dir not in sys.path:
sys.path.insert(0, _ep_dir)
if '/' not in sys.path:
sys.path.insert(0, '/')
__file__ = ${JSON.stringify(entryPoint)}
del _ep_dir
`);
} catch (_e) { /* best effort */ }
try {
await py.runPythonAsync(code);
} catch (err) {
send('stderr', String(err));
return { exitCode: 1 };
}
// Scan /output/ for new files and send them back
try {
const outputFiles = py.FS.readdir('/output').filter(f => f !== '.' && f !== '..');
for (const filename of outputFiles) {
const filePath = '/output/' + filename;
try {
const data = py.FS.readFile(filePath);
const ext = filename.split('.').pop().toLowerCase();
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(ext)) {
let binary = '';
for (let i = 0; i < data.length; i++) {
binary += String.fromCharCode(data[i]);
}
const base64 = btoa(binary);
sendFile(filePath, base64);
} else {
const decoder = new TextDecoder();
sendFile(filePath, decoder.decode(data));
}
} catch (_e) { /* skip unreadable files */ }
}
} catch (_e) { /* /output/ may not have new files */ }
return { exitCode: 0 };
}
// ─── Lua (wasmoon) ──────────────────────────────────────────────────
async function ensureLuaFactory() {
if (luaFactory) return luaFactory;
send('status', 'Loading Lua runtime...');
try {
// Dynamic import of wasmoon from CDN
const wasmoon = await import('https://esm.sh/wasmoon@1');
luaFactory = new wasmoon.LuaFactory();
} catch (err) {
send('error', 'Failed to load Lua runtime: ' + String(err));
throw err;
}
send('status', 'Lua runtime ready');
return luaFactory;
}
async function executeLua(entryPoint, files) {
const factory = await ensureLuaFactory();
const engine = await factory.createEngine();
try {
// Override print to capture stdout
engine.global.set('print', function (...args) {
send('stdout', args.map(String).join('\t'));
});
// Pre-load module files so require() works
const moduleFiles = {};
for (const [path, content] of Object.entries(files)) {
if (path.endsWith('.lua') && path !== entryPoint) {
const modName = path
.replace(/^\//, '')
.replace(/\.lua$/, '')
.replace(/\//g, '.');
moduleFiles[modName] = content;
}
}
// Register custom searcher for VFS modules
engine.global.set('__vfs_modules', JSON.stringify(moduleFiles));
await engine.doString(`
local vfs_modules = {}
local json_str = __vfs_modules
-- Simple JSON parse for module map (keys and string values only)
for key, value in json_str:gmatch('"([^"]+)":"(.-[^\\\\])"') do
-- Unescape basic sequences
value = value:gsub('\\\\n', '\\n'):gsub('\\\\t', '\\t'):gsub('\\\\"', '"'):gsub('\\\\\\\\', '\\\\')
vfs_modules[key] = value
end
__vfs_modules = nil
table.insert(package.searchers, 2, function(modname)
local source = vfs_modules[modname]
if source then
local fn, err = load(source, "@" .. modname .. ".lua")
if fn then return fn
else return "\\n\\tload error: " .. err end
end
return "\\n\\tno VFS module '" .. modname .. "'"
end)
`);
// Run the entry point
const code = files[entryPoint];
if (!code) {
send('error', 'Entry point not found: ' + entryPoint);
engine.global.close();
return { exitCode: 1 };
}
await engine.doString(code);
engine.global.close();
return { exitCode: 0 };
} catch (err) {
send('stderr', String(err));
try { engine.global.close(); } catch (_e) { /* best effort */ }
return { exitCode: 1 };
}
}
// ─── Message handler ────────────────────────────────────────────────
self.onmessage = async function (event) {
const msg = event.data;
if (msg.type === 'execute') {
const { runtime, entryPoint, files } = msg.payload;
try {
let result;
if (runtime === 'python') {
result = await executePython(entryPoint, files);
} else if (runtime === 'lua') {
result = await executeLua(entryPoint, files);
} else {
send('error', 'Unknown runtime: ' + runtime);
self.postMessage({ type: 'complete', exitCode: 1 });
return;
}
self.postMessage({ type: 'complete', exitCode: result.exitCode });
} catch (err) {
send('error', String(err));
self.postMessage({ type: 'complete', exitCode: 1 });
}
}
};