/** * pyRunner.worker.ts — Pyodide Python execution in a Web Worker * * S45: micropip auto-detect — loadPackagesFromImports() + micropip via globals * S70: package cache dedup — _installedPkgs Set persiste per tutta la vita del worker * S71: estrazione globals post-run — dopo ogni esecuzione riuscita, estrae le variabili * utente dal namespace Python e le invia come { type: "vars", id, vars[] } * S448: Gap 2 — OPFS package cache persistente tra respawn + micropip preload + Pyodide 0.29.4 * - CDN aggiornato a v0.29.4 (Brotli compression migliorata, Safari 17+ supportato) * - micropip precaricato in parallelo al bootstrap (elimina round-trip CDN separato) * - _installedPkgs serializzato in OPFS: sopravvive a respawn Worker (respawn ogni 60s idle) * - Impatto iPhone: run dopo respawn con OPFS piena: 15-20s → 2-3s * * NEVER loaded on the main thread — only in this worker. */ // loadPyodide viene importato dinamicamente in getPyodide() — ESM-compatible interface PyodideInstance { runPythonAsync(code: string): Promise; loadPackage(names: string | string[]): Promise; loadPackagesFromImports(code: string): Promise; globals: { set(key: string, val: unknown): void; }; isPyProxy(obj: unknown): boolean; } // ─── S71: tipo variabile Python serializzata ────────────────────────────────── export interface PythonVarData { name: string; type: string; preview: string; value: string; size?: string; dtype?: string; } export type PyRunnerMsg = | { type: "run"; id: string; code: string; timeout?: number; packages?: string[] } | { type: "cancel"; id: string } | { type: "ping" }; export type PyRunnerResult = | { type: "stdout"; id: string; text: string } | { type: "stderr"; id: string; text: string } | { type: "done"; id: string; exitCode: number; time: number } | { type: "error"; id: string; message: string } | { type: "vars"; id: string; vars: PythonVarData[] } | { type: "loading"; progress: number } | { type: "ready" }; const PYODIDE_CDN = "https://cdn.jsdelivr.net/pyodide/v0.29.4/full/"; let pyodide: PyodideInstance | null = null; let loadPromise: Promise | null = null; const running = new Map(); /** S70 — Package cache: persiste per tutta la vita del worker singleton */ const _installedPkgs = new Set(); // ─── S448: OPFS package cache — persiste tra respawn del Worker ─────────────── // Chiave versionata per Pyodide: al cambio versione CDN la cache vecchia viene // ignorata automaticamente (file handle non trovato → primo avvio pulito). const OPFS_PKG_CACHE_KEY = "pyodide_pkg_cache_v0294"; async function loadPkgCacheFromOPFS(): Promise { try { const root = await navigator.storage.getDirectory(); const fh = await root.getFileHandle(OPFS_PKG_CACHE_KEY, { create: false }); const file = await fh.getFile(); const text = await file.text(); const cached: string[] = JSON.parse(text); for (const p of cached) _installedPkgs.add(p); } catch { // OPFS non supportato (Safari < 16.4) o file non esiste ancora — primo avvio } } function savePkgCacheToOPFS(): void { // fire-and-forget: non blocca l'esecuzione Python void (async () => { try { const root = await navigator.storage.getDirectory(); const fh = await root.getFileHandle(OPFS_PKG_CACHE_KEY, { create: true }); const writable = await fh.createWritable(); await writable.write(JSON.stringify([..._installedPkgs])); await writable.close(); } catch { // non-fatal: cache OPFS non è critica per il funzionamento } })(); } // ─── S71: Python extraction code ────────────────────────────────────────────── // Dopo ogni runPythonAsync riuscito, questo script estrae le variabili utente // dal namespace Python globale e le invia tramite __emit_var__(json_string). // Skippati: nomi che iniziano con "_", nomi iniettati dal framework, moduli built-in. const EXTRACT_GLOBALS_CODE = ` try: import json as __json__ def __serialize_var__(name, val): t = type(val).__name__ preview = "" value = "" size = None dtype = None try: # numpy ndarray — check before generic object if t not in ('bool','int','float','str','list','dict','tuple','set','complex','NoneType','bytes') and hasattr(val,'shape') and hasattr(val,'dtype') and hasattr(val,'flat'): t = "ndarray" size = "shape " + str(val.shape) dtype = str(val.dtype) flat = list(val.flat[:8]) preview = str(flat)[:80] + ("\\u2026" if val.size > 8 else "") value = str(val)[:8000] # pandas DataFrame (ha columns, non la Series) elif hasattr(val,'to_string') and hasattr(val,'columns'): t = "DataFrame" rows_n, cols_n = val.shape size = str(rows_n) + " righe \\u00d7 " + str(cols_n) + " col" cols_p = [str(c) for c in val.columns[:6]] preview = "[" + ", ".join(cols_p) + (", \\u2026" if cols_n > 6 else "") + "]" value = val.to_string(max_rows=30, max_cols=12)[:8000] # pandas Series (ha dtype e index ma non columns) elif hasattr(val,'to_string') and hasattr(val,'dtype') and hasattr(val,'index') and not hasattr(val,'columns'): t = "Series" size = str(len(val)) + " el." dtype = str(val.dtype) preview = str(val.head(4)).replace('\\n',' ')[:80] value = val.to_string(max_rows=30)[:8000] elif isinstance(val, bool): t = "bool" preview = str(val) value = str(val) elif isinstance(val, int): preview = str(val) value = str(val) elif isinstance(val, float): preview = format(val, '.6g') value = repr(val) elif isinstance(val, complex): t = "complex" preview = str(val) value = str(val) elif val is None: t = "NoneType" preview = "None" value = "None" elif isinstance(val, str): size = str(len(val)) + " char" preview = repr(val[:60]) + ("\\u2026" if len(val) > 60 else "") value = repr(val[:8000]) elif isinstance(val, bytes): t = "bytes" size = str(len(val)) + " bytes" preview = repr(val[:40]) value = repr(val[:8000]) elif isinstance(val, (list, tuple)): size = str(len(val)) + " el." preview = str(val[:5])[:80] + ("\\u2026" if len(val) > 5 else "") try: value = __json__.dumps(val, ensure_ascii=False, default=repr)[:8000] except Exception: value = str(val)[:8000] elif isinstance(val, dict): size = str(len(val)) + " ch." keys_p = list(val.keys())[:5] preview = "{" + ", ".join(repr(k) for k in keys_p) + ("\\u2026" if len(val) > 5 else "") + "}" try: value = __json__.dumps(val, ensure_ascii=False, default=repr)[:8000] except Exception: value = str(val)[:8000] elif isinstance(val, set): size = str(len(val)) + " el." preview = "{" + ", ".join(repr(v) for v in list(val)[:5]) + ("\\u2026" if len(val) > 5 else "") + "}" value = str(sorted(str(v) for v in val))[:8000] elif callable(val): t = "function" try: import inspect as _insp sig = str(_insp.signature(val)) preview = name + sig del _insp except Exception: preview = "" value = preview elif hasattr(val,'__module__') and not callable(val): t = "module" preview = "" value = preview else: value = repr(val)[:8000] preview = value[:80] except Exception as __se__: preview = "" value = preview result = {"name": name, "type": t, "preview": preview, "value": value} if size is not None: result["size"] = size if dtype is not None: result["dtype"] = dtype return result __skip_names__ = { '__builtins__','__name__','__doc__','__package__','__loader__', '__spec__','__import__','__stdout__','__stderr__','_Capture', '__is_installed__','__mark_installed__','__pip_msg__', '__code_to_analyze__','__emit_var__','__json__','__serialize_var__', '__skip_names__','sys','io', } for __vk__, __vv__ in list(globals().items()): if __vk__.startswith('_') or __vk__ in __skip_names__: continue try: __emit_var__(__json__.dumps(__serialize_var__(__vk__, __vv__), ensure_ascii=False)) except Exception: pass except Exception as _pip_err: _err_str = str(_pip_err) if 'PackageNotFoundError' in _err_str or "Can't find" in _err_str or 'not found' in _err_str.lower(): __pip_msg__(f'[pip-unavailable] {_err_str}\n') finally: try: _g = globals() for _n in ['__json__','__serialize_var__','__skip_names__','__emit_var__','__vk__','__vv__','_g','_n']: try: del _g[_n] except Exception: pass except Exception: pass `; // ─── Pyodide load ───────────────────────────────────────────────────────────── async function getPyodide(): Promise { if (pyodide) return pyodide; if (loadPromise) return loadPromise; self.postMessage({ type: "loading", progress: 0 } satisfies PyRunnerResult); loadPromise = (async () => { // S448: reidrata i package già installati da sessioni/respawn precedenti await loadPkgCacheFromOPFS(); // ESM module workers non supportano importScripts — usa dynamic import // @vite-ignore evita che Vite tenti di bundlare l'URL CDN remoto const { loadPyodide } = await import(/* @vite-ignore */ `${PYODIDE_CDN}pyodide.mjs`); self.postMessage({ type: "loading", progress: 30 } satisfies PyRunnerResult); // S448: micropip precaricato in parallelo al bootstrap — elimina round-trip CDN separato const py = await loadPyodide({ indexURL: PYODIDE_CDN, packages: ["micropip"] }); self.postMessage({ type: "loading", progress: 80 } satisfies PyRunnerResult); pyodide = py; self.postMessage({ type: "ready" } satisfies PyRunnerResult); return py; })(); return loadPromise; } // ─── S45/S70: auto-install imports con cache ────────────────────────────────── async function autoInstallImports( py: PyodideInstance, code: string, id: string, ): Promise { // Step 1 — standard Pyodide packages (Pyodide li cachea internamente) try { await py.loadPackagesFromImports(code); } catch { // non-fatal } // Step 2 — micropip per pacchetti PyPI puri non inclusi in Pyodide try { py.globals.set("__code_to_analyze__", code); py.globals.set("__is_installed__", (pkg: string) => _installedPkgs.has(pkg)); py.globals.set("__mark_installed__", (pkg: string) => { _installedPkgs.add(pkg); savePkgCacheToOPFS(); // S448: aggiorna OPFS cache dopo ogni install }); py.globals.set("__pip_msg__", (text: string) => { self.postMessage({ type: "stdout", id, text } satisfies PyRunnerResult); }); await py.runPythonAsync(` import sys as _sys try: import micropip as _micropip import ast as _ast _tree = _ast.parse(__code_to_analyze__) _missing: list[str] = [] for _node in _ast.walk(_tree): if isinstance(_node, _ast.Import): for _alias in _node.names: _pkg = _alias.name.split(".")[0] try: __import__(_pkg) except ImportError: if _pkg not in _missing: _missing.append(_pkg) elif isinstance(_node, _ast.ImportFrom): if _node.module: _pkg = _node.module.split(".")[0] try: __import__(_pkg) except ImportError: if _pkg not in _missing: _missing.append(_pkg) _installed_count = 0 _cached_count = 0 for _pkg in _missing: if __is_installed__(_pkg): __pip_msg__(f"[pip] \\u2713 {_pkg} (cache)\\n") _cached_count += 1 else: __pip_msg__(f"[pip] Installo: {_pkg}\\u2026\\n") await _micropip.install(_pkg, keep_going=True) __mark_installed__(_pkg) _installed_count += 1 if _installed_count > 0: __pip_msg__(f"[pip] \\u2713 {_installed_count} pacchett{'o' if _installed_count == 1 else 'i'} installat{'o' if _installed_count == 1 else 'i'}\\n") except Exception: pass finally: try: del __code_to_analyze__, __is_installed__, __mark_installed__, __pip_msg__ except Exception: pass `); } catch { // non-fatal } } // ─── S71: estrazione variabili globali post-run ─────────────────────────────── async function extractAndSendVars(py: PyodideInstance, id: string): Promise { const vars: PythonVarData[] = []; try { py.globals.set("__emit_var__", (varJson: string) => { try { vars.push(JSON.parse(varJson) as PythonVarData); } catch { // non-fatal: skip malformed var } }); await py.runPythonAsync(EXTRACT_GLOBALS_CODE); if (vars.length > 0) { self.postMessage({ type: "vars", id, vars } satisfies PyRunnerResult); } } catch { // non-fatal: vars extraction failure non blocca l'esecuzione } } // ─── Message handler ────────────────────────────────────────────────────────── self.onmessage = async (e: MessageEvent) => { const msg = e.data; if (msg.type === "ping") { self.postMessage({ type: "ready" } satisfies PyRunnerResult); return; } if (msg.type === "cancel") { const ctx = running.get(msg.id); if (ctx) ctx.cancelled = true; return; } if (msg.type === "run") { const { id, code, timeout = 30_000, packages = [] } = msg; const ctx = { cancelled: false }; running.set(id, ctx); const start = Date.now(); let timedOut = false; const timer = setTimeout(() => { timedOut = true; ctx.cancelled = true; self.postMessage({ type: "stderr", id, text: "[timeout] Python execution exceeded " + timeout + "ms\n" } satisfies PyRunnerResult); self.postMessage({ type: "done", id, exitCode: 124, time: Date.now() - start } satisfies PyRunnerResult); }, timeout); try { const py = await getPyodide(); if (ctx.cancelled) { clearTimeout(timer); running.delete(id); return; } // S45/S70: auto-detect e installa imports (standard Pyodide + micropip con cache) await autoInstallImports(py, code, id); if (ctx.cancelled) { clearTimeout(timer); running.delete(id); return; } // Pacchetti espliciti richiesti dal caller (retrocompatibilità) if (packages.length > 0) { const toInstall = packages.filter(p => !_installedPkgs.has(p)); const cached = packages.filter(p => _installedPkgs.has(p)); if (cached.length > 0) { self.postMessage({ type: "stdout", id, text: `[pip] ✓ ${cached.join(", ")} (cache)\n` } satisfies PyRunnerResult); } if (toInstall.length > 0) { self.postMessage({ type: "stdout", id, text: `[pip] Installo: ${toInstall.join(", ")}…\n` } satisfies PyRunnerResult); await py.loadPackage(toInstall); for (const p of toInstall) _installedPkgs.add(p); savePkgCacheToOPFS(); // S448: persiste anche i package espliciti in OPFS } } py.globals.set("__stdout__", (text: string) => { if (!ctx.cancelled) self.postMessage({ type: "stdout", id, text } satisfies PyRunnerResult); }); py.globals.set("__stderr__", (text: string) => { if (!ctx.cancelled) self.postMessage({ type: "stderr", id, text } satisfies PyRunnerResult); }); const wrapped = ` import sys, io class _Capture(io.StringIO): def __init__(self, fn): super().__init__() self._fn = fn def write(self, s): if s: self._fn(s) return len(s) def flush(self): pass sys.stdout = _Capture(__stdout__) sys.stderr = _Capture(__stderr__) ${code} `; await py.runPythonAsync(wrapped); if (!timedOut && !ctx.cancelled) { // S71: estrai variabili globali e inviale al main thread await extractAndSendVars(py, id); self.postMessage({ type: "done", id, exitCode: 0, time: Date.now() - start } satisfies PyRunnerResult); } } catch (err) { if (!timedOut) { const errMsg = err instanceof Error ? err.message : String(err); self.postMessage({ type: "stderr", id, text: errMsg + "\n" } satisfies PyRunnerResult); // S71: estrai anche in caso di errore parziale (alcune var potrebbero essere definite) try { if (pyodide) await extractAndSendVars(pyodide, id); } catch { /* non-fatal */ } self.postMessage({ type: "done", id, exitCode: 1, time: Date.now() - start } satisfies PyRunnerResult); } } finally { clearTimeout(timer); running.delete(id); } } };