diff --git a/.gitignore b/.gitignore index 7db6e55..a78ddbd 100644 --- a/.gitignore +++ b/.gitignore @@ -141,6 +141,7 @@ node_modules/ /.vscode uv.lock /docs/_static/scripts/repo-review-app.* +/docs/_static/scripts/utils/pyodide-worker.min.js* # Demo page /out diff --git a/docs/webapp.md b/docs/webapp.md index bc6bdea..e97f767 100644 --- a/docs/webapp.md +++ b/docs/webapp.md @@ -55,16 +55,18 @@ And then after that, call the script with whatever dependencies you want: ### Bundler notes -The webapp loads Pyodide automatically from the jsDelivr CDN -(`https://cdn.jsdelivr.net/pyodide/`) at runtime; no extra ` ``` -The webapp loads Pyodide and uses `micropip` to install any requested Python packages. +The worker uses `micropip` to install any requested Python packages and keeps +the expensive Python state off the main thread. diff --git a/package.json b/package.json index 19341d2..86fe87f 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "version": "1.0.3", "type": "module", "scripts": { - "build": "bunx esbuild src/repo-review-app/repo-review-app.tsx --bundle --minify --sourcemap --outfile=docs/_static/scripts/repo-review-app.min.js --platform=browser --format=esm", - "build-html": "bun build src/repo-review-app/index.html --outdir=out --minify", + "build": "bunx esbuild src/repo-review-app/repo-review-app.tsx src/repo-review-app/utils/pyodide-worker.ts --bundle --minify --sourcemap --outdir=docs/_static/scripts --outbase=src/repo-review-app --entry-names=[dir]/[name].min --platform=browser --format=esm", + "build-html": "bun build src/repo-review-app/index.html --outdir=out --minify && bunx esbuild src/repo-review-app/utils/pyodide-worker.ts --bundle --minify --sourcemap --outdir=out/utils --outbase=src/repo-review-app --platform=browser --entry-names=[dir]/[name].min --format=esm", "format": "prettier --write .", "lint": "eslint . --ext .ts,.tsx,.js,.jsx", "serve": "bun src/repo-review-app/index.html", diff --git a/src/repo-review-app/repo-review-app.tsx b/src/repo-review-app/repo-review-app.tsx index 242356d..486f273 100644 --- a/src/repo-review-app/repo-review-app.tsx +++ b/src/repo-review-app/repo-review-app.tsx @@ -26,29 +26,23 @@ import Heading from "./components/Heading"; import Results from "./components/Results"; import MyThemeProvider from "./components/MyThemeProvider"; import { fetchRepoRefs } from "./utils/github"; -import { sanitizePackageDir, parseRefType } from "./utils/url"; -import { - prepare_pyodide, - run_process, - load_known_checks, - generate_html, - prefetch, - collect_checks, -} from "./utils/pyodide"; -import type { PyodideInterface } from "pyodide"; -import type { PyProxy } from "pyodide/ffi"; +import { createPyodideClient, type PyodideClient } from "./utils/pyodide"; import type { SelectChangeEvent } from "@mui/material"; +import type { CheckItem } from "./utils/pyodide-common"; const DEFAULT_MSG = "Enter a GitHub repo and branch/tag to review. Runs Python entirely in your browser using WebAssembly. Built with React, MaterialUI, and Pyodide."; -interface CheckItem { - name: string; - description?: string; - state?: boolean | null | undefined; - err_msg?: string; - url?: string; - skip_reason?: string; +// Sanitize a package subdirectory path: strip leading slashes, reject any +// segment that is ".." (path traversal). Returns empty string for invalid input. +function sanitizePackageDir(raw: string): string { + const trimmed = raw.trim().replace(/^\/+/, ""); + if (trimmed.split("/").some((seg) => seg === "..")) return ""; + return trimmed; +} + +function parseRefType(value: string | null): "branch" | "tag" { + return value === "tag" ? "tag" : "branch"; } interface Refs { @@ -65,14 +59,12 @@ interface Option { interface AppProps { deps: string[]; header?: boolean; - pyodideBaseUrl?: string; } interface AppState { show: string; results: Record | CheckItem[]; - pyFamilies: PyProxy | null; - pyChecks: PyProxy | null; + currentRunToken: string | null; snackbarOpen: boolean; snackbarMsg: string; snackbarSeverity: "info" | "error" | "warning" | "success"; @@ -101,7 +93,9 @@ interface AppState { } class App extends React.Component { - pyodide_promise: Promise | null; + pyodideClient: PyodideClient | null; + + pyodide_promise: Promise | null; constructor(props: AppProps) { super(props); @@ -113,8 +107,7 @@ class App extends React.Component { this.state = { show: params.get("show") || "all", results: [], - pyFamilies: null, - pyChecks: null, + currentRunToken: null, snackbarOpen: false, snackbarMsg: "", snackbarSeverity: "info", @@ -141,22 +134,12 @@ class App extends React.Component { completedRef: "", completedRefType: "branch", }; + this.pyodideClient = null; this.pyodide_promise = null; } - destroyProxy(proxy: PyProxy | null): void { - if (proxy) { - try { - proxy.destroy(); - } catch (e) { - // ignore destroy errors - } - } - } - componentWillUnmount() { - this.destroyProxy(this.state.pyFamilies); - this.destroyProxy(this.state.pyChecks); + this.pyodideClient?.dispose(); } async fetchRepoReferences(repo: string) { @@ -168,20 +151,15 @@ class App extends React.Component { } handleRepoChange(repo: string) { - this.destroyProxy(this.state.pyFamilies); - this.destroyProxy(this.state.pyChecks); this.setState({ repo, refs: { branches: [], tags: [] }, - pyFamilies: null, - pyChecks: null, + currentRunToken: null, }); } handleRefChange(ref: string, refType: "branch" | "tag") { - this.destroyProxy(this.state.pyFamilies); - this.destroyProxy(this.state.pyChecks); - this.setState({ ref, refType, pyFamilies: null, pyChecks: null }); + this.setState({ ref, refType, currentRunToken: null }); } async handleCompute() { @@ -210,74 +188,34 @@ class App extends React.Component { "", `${window.location.pathname}?${local_params}`, ); - this.setState({ results: [], progress: true, infoOpen: false }); + this.setState({ + results: [], + progress: true, + infoOpen: false, + currentRunToken: null, + }); const state = this.state; - let pyPackage: PyProxy | null = null; - let collected: PyProxy | null = null; - let families_dict: any = null; - let results_list: any = null; try { - const pyodide = await this.pyodide_promise!; - pyPackage = await prefetch(pyodide, state.repo, state.ref, packageDir); - collected = collect_checks(pyodide, pyPackage, packageDir); - results_list = run_process( - pyodide, - pyPackage, - collected, + await this.pyodide_promise!; + const { token, results, families } = await this.pyodideClient!.runReview( + state.repo, + state.ref, packageDir, - ) as any; - families_dict = (collected as any).families.copy(); - - const results: Record = {}; - const families: Record = - {}; - for (const val of families_dict) { - const descr = families_dict.get(val).get("description"); - results[val] = []; - families[val] = { - name: families_dict.get(val).get("name").toString(), - description: descr && descr.toString(), - }; - } - for (const val of results_list) { - results[val.family].push({ - name: val.name.toString(), - description: val.description.toString(), - state: val.result, - err_msg: val.err_msg.toString(), - url: val.url.toString(), - skip_reason: val.skip_reason.toString(), - }); - } - - // Destroy any previously stored PyProxies for a different run - if (this.state.pyFamilies && this.state.pyFamilies !== families_dict) { - this.destroyProxy(this.state.pyFamilies); - } - if (this.state.pyChecks && this.state.pyChecks !== results_list) { - this.destroyProxy(this.state.pyChecks); - } + ); this.setState({ - results: results, - families: families, + results, + families, progress: false, err_msg: "", url: "", infoOpen: false, - pyFamilies: families_dict, - pyChecks: results_list, + currentRunToken: token, completedRepo: state.repo, completedRef: state.ref, completedRefType: state.refType, }); - // Proxies are now owned by state; clear locals to avoid destroying them in catch - families_dict = null; - results_list = null; } catch (e: unknown) { - // Destroy any proxies from this run that were not saved to state - this.destroyProxy(families_dict); - this.destroyProxy(results_list); const emsg = (e as Error)?.message || String(e); if (emsg.includes("KeyError: 'tree'")) { this.setState({ @@ -290,10 +228,6 @@ class App extends React.Component { err_msg: `
${emsg}
`, }); } - } finally { - // prefetch and collected are run-scoped only; release them after processing - this.destroyProxy(collected); - this.destroyProxy(pyPackage); } } @@ -308,24 +242,9 @@ class App extends React.Component { } try { - const pyodide = await this.pyodide_promise!; - - let htmlOut: string; - - // Reuse previously stored PyProxy results if they correspond to the - // same repo/ref to avoid rerunning the expensive `process(package)`. - if (this.state.pyFamilies && this.state.pyChecks) { - // Use stored pyFamilies/pyChecks; they are cleared when repo/ref change - htmlOut = await generate_html( - pyodide, - this.state.pyFamilies, - this.state.pyChecks, - this.state.show || "all", - ); - } else { - // Shouldn't be possible: if we have a copy button, we should have - // a stored run result for that repo/ref. Show an error instead of - // rerunning the expensive process. + await this.pyodide_promise!; + + if (!this.state.currentRunToken) { this.setState({ snackbarOpen: true, snackbarMsg: @@ -335,6 +254,11 @@ class App extends React.Component { return; } + const htmlOut = await this.pyodideClient!.generateHtml( + this.state.currentRunToken, + this.state.show || "all", + ); + const htmlStr = htmlOut.toString ? htmlOut.toString() : htmlOut; if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(htmlStr); @@ -363,11 +287,11 @@ class App extends React.Component { } async loadKnownChecks() { - const pyodide = await this.pyodide_promise!; + await this.pyodide_promise!; let data: { families?: Record; results?: any[] } = {}; try { - data = load_known_checks(pyodide); + data = await this.pyodideClient!.loadKnownChecks(); } catch (e) { console.error("Error loading known checks:", e); return; @@ -402,9 +326,9 @@ class App extends React.Component { } componentDidMount() { - this.pyodide_promise = prepare_pyodide( + this.pyodideClient = createPyodideClient(); + this.pyodide_promise = this.pyodideClient.prepare( this.props.deps, - this.props.pyodideBaseUrl, (p: number, m?: string) => this.setState({ pyodideProgress: p, @@ -419,7 +343,17 @@ class App extends React.Component { if (params.get("repo") && params.get("ref")) { this.handleCompute(); } else { - this.pyodide_promise.then(() => this.loadKnownChecks()); + this.pyodide_promise + .then(() => this.loadKnownChecks()) + .catch((e: unknown) => { + const emsg = e instanceof Error ? e.message : String(e); + this.setState({ + pyodideLoading: false, + snackbarOpen: true, + snackbarMsg: "Failed to initialize Pyodide: " + emsg, + snackbarSeverity: "error", + }); + }); } } diff --git a/src/repo-review-app/utils/pyodide-common.ts b/src/repo-review-app/utils/pyodide-common.ts new file mode 100644 index 0000000..7025ade --- /dev/null +++ b/src/repo-review-app/utils/pyodide-common.ts @@ -0,0 +1,42 @@ +export interface CheckItem { + name: string; + description?: string; + state?: boolean | null; + err_msg?: string; + url?: string; + skip_reason?: string; +} + +export interface FamilyInfo { + name: string; + description?: string; +} + +export interface KnownCheckDefinition { + name: string; + family: string; + description: string; + url: string; +} + +export interface KnownChecksData { + families: Record; + results: KnownCheckDefinition[]; +} + +export interface ReviewRunData { + token: string; + families: Record; + results: Record; +} + +export type WorkerRequest = + | { id: number; type: "prepare"; deps: string[] } + | { id: number; type: "loadKnownChecks" } + | { id: number; type: "runReview"; repo: string; ref: string; subdir: string } + | { id: number; type: "generateHtml"; token: string; show: string }; + +export type WorkerResponse = + | { id: number; type: "progress"; progress: number; message?: string } + | { id: number; type: "success"; payload?: unknown } + | { id: number; type: "error"; error: string }; diff --git a/src/repo-review-app/utils/pyodide-worker.ts b/src/repo-review-app/utils/pyodide-worker.ts new file mode 100644 index 0000000..da23d88 --- /dev/null +++ b/src/repo-review-app/utils/pyodide-worker.ts @@ -0,0 +1,278 @@ +/// + +import type { PyodideInterface } from "pyodide"; +import type { + KnownChecksData, + ReviewRunData, + WorkerRequest, + WorkerResponse, +} from "./pyodide-common"; + +const PYODIDE_CDN_URL = + "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.mjs"; + +const workerScope = self as DedicatedWorkerGlobalScope; + +let pyodidePromise: Promise | null = null; +let operationQueue: Promise = Promise.resolve(); +let micropipReady = false; +const installedDeps = new Set(); +let runCounter = 0; + +function postMessage(message: WorkerResponse): void { + workerScope.postMessage(message); +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : String(value); +} + +async function getPyodide( + report?: (progress: number, message?: string) => void, +) { + if (!pyodidePromise) { + pyodidePromise = (async () => { + report?.(5, "Initializing Pyodide runtime"); + const pyodideModule = (await import(PYODIDE_CDN_URL)) as { + loadPyodide: () => Promise; + }; + const pyodide = await pyodideModule.loadPyodide(); + report?.(50, "Core Pyodide loaded"); + return pyodide; + })(); + } + + return pyodidePromise; +} + +async function preparePyodide( + deps: string[], + report?: (progress: number, message?: string) => void, +): Promise { + const requestedDeps = [...deps]; + const task = operationQueue.then(async () => { + const pyodide = await getPyodide(report); + + if (!micropipReady) { + report?.(65, "Loading micropip"); + await pyodide.loadPackage("micropip"); + micropipReady = true; + } + + const missingDeps = requestedDeps.filter((dep) => !installedDeps.has(dep)); + + if (missingDeps.length > 0) { + report?.(80, "Installing Python packages"); + pyodide.globals.set("deps_for_install", missingDeps); + try { + await pyodide.runPythonAsync(` + import micropip + await micropip.install(list(deps_for_install)) + `); + } finally { + pyodide.globals.delete("deps_for_install"); + } + for (const dep of missingDeps) { + installedDeps.add(dep); + } + } + + report?.(100, "Ready"); + }); + + operationQueue = task.catch(() => undefined); + return task; +} + +async function loadKnownChecks(): Promise { + const task = operationQueue.then(async () => { + const pyodide = await getPyodide(); + const data = pyodide.runPython(` + import json + from repo_review.processor import collect_all + from repo_review.checks import get_check_url + + collected = collect_all() + families_out = {k: {"name": v.get("name", k)} for k, v in collected.families.items()} + results_out = [] + for name, check in collected.checks.items(): + desc = (check.__doc__ or "").strip() + results_out.append({ + "name": name, + "family": check.family, + "description": desc, + "url": get_check_url(name, check), + }) + json.dumps({"families": families_out, "results": results_out}) + `); + + return JSON.parse(asString(data)) as KnownChecksData; + }); + + operationQueue = task.catch(() => undefined); + return task; +} + +async function runReview( + repo: string, + ref: string, + subdir: string, +): Promise { + const task = operationQueue.then(async () => { + const pyodide = await getPyodide(); + const token = `run-${Date.now()}-${++runCounter}`; + const repoLiteral = JSON.stringify(repo); + const refLiteral = JSON.stringify(ref); + const subdirLiteral = JSON.stringify(subdir); + const tokenLiteral = JSON.stringify(token); + + const output = await pyodide.runPythonAsync(` + import json + from repo_review.files import collect_prefetch_files, process_prefetch_files + from repo_review.ghpath import GHPath + from repo_review.processor import collect_all, process, md_as_html + + repo_name = ${repoLiteral} + repo_ref = ${refLiteral} + repo_subdir = ${subdirLiteral} + run_token = ${tokenLiteral} + + package = await GHPath.async_from_repo(repo_name, repo_ref) + prefetch_files = collect_prefetch_files() + await process_prefetch_files(package, prefetch_files, subdir=repo_subdir) + collected = collect_all(package, subdir=repo_subdir) + families, checks = process(package, collected=collected, subdir=repo_subdir) + + for family_data in families.values(): + if family_data.get("description"): + family_data["description"] = md_as_html(family_data["description"]) + + def _stringify(value): + return "" if value is None else str(value) + + families_out = {} + results_out = {} + + for family_key, family_data in families.items(): + families_out[family_key] = { + "name": _stringify(family_data.get("name", family_key)), + "description": ( + _stringify(family_data.get("description")) + if family_data.get("description") + else None + ), + } + results_out[family_key] = [] + + for result in checks: + results_out.setdefault(result.family, []).append({ + "name": _stringify(result.name), + "description": _stringify(result.description), + "state": result.result, + "err_msg": _stringify(result.err_msg), + "url": _stringify(result.url), + "skip_reason": _stringify(result.skip_reason), + }) + + _repo_review_last_token = run_token + _repo_review_last_families = families + _repo_review_last_checks = checks + + json.dumps({ + "token": run_token, + "families": families_out, + "results": results_out, + }) + `); + + return JSON.parse(asString(output)) as ReviewRunData; + }); + + operationQueue = task.catch(() => undefined); + return task; +} + +async function generateHtml(token: string, show: string): Promise { + const task = operationQueue.then(async () => { + const pyodide = await getPyodide(); + const tokenLiteral = JSON.stringify(token); + const showLiteral = JSON.stringify(show || "all"); + + const output = await pyodide.runPythonAsync(` + from repo_review.html import to_html + + html_run_token = ${tokenLiteral} + show_for_html = ${showLiteral} + + if globals().get("_repo_review_last_token") != html_run_token: + raise RuntimeError( + "Stored results do not match current repo/ref - please run the checks first" + ) + + match show_for_html: + case "all": + filtered = _repo_review_last_checks + case "err": + filtered = [c for c in _repo_review_last_checks if c.result is False] + case "errskip": + filtered = [c for c in _repo_review_last_checks if c.result is not True] + case _: + filtered = _repo_review_last_checks + + to_html(_repo_review_last_families, filtered) + `); + + return asString(output); + }); + + operationQueue = task.catch(() => undefined); + return task; +} + +async function handleRequest(request: WorkerRequest): Promise { + const reportProgress = (progress: number, message?: string) => { + postMessage({ id: request.id, type: "progress", progress, message }); + }; + + try { + switch (request.type) { + case "prepare": + await preparePyodide(request.deps, reportProgress); + postMessage({ id: request.id, type: "success" }); + return; + case "loadKnownChecks": + postMessage({ + id: request.id, + type: "success", + payload: await loadKnownChecks(), + }); + return; + case "runReview": + postMessage({ + id: request.id, + type: "success", + payload: await runReview(request.repo, request.ref, request.subdir), + }); + return; + case "generateHtml": + postMessage({ + id: request.id, + type: "success", + payload: await generateHtml(request.token, request.show), + }); + return; + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + postMessage({ id: request.id, type: "error", error: message }); + } +} + +workerScope.addEventListener( + "message", + (event: MessageEvent) => { + void handleRequest(event.data); + }, +); + +export {}; diff --git a/src/repo-review-app/utils/pyodide.ts b/src/repo-review-app/utils/pyodide.ts index 3c8c123..06f1ff1 100644 --- a/src/repo-review-app/utils/pyodide.ts +++ b/src/repo-review-app/utils/pyodide.ts @@ -1,156 +1,153 @@ -import type { PyodideInterface } from "pyodide"; -import type { PyProxy } from "pyodide/ffi"; -import pyodidePackage from "pyodide/package.json"; - -// Version resolved from the npm package at build time; runtime files loaded from CDN -const DEFAULT_PYODIDE_BASE_URL = `https://cdn.jsdelivr.net/pyodide/v${pyodidePackage.version}/full`; - -export async function prepare_pyodide( - deps: string[], - pyodideBaseUrl?: string, - onProgress?: (p: number, m?: string) => void, -): Promise { - try { - if (onProgress) onProgress(5, "Initializing Pyodide runtime"); - const baseUrl = pyodideBaseUrl ?? DEFAULT_PYODIDE_BASE_URL; - const { loadPyodide } = (await import(`${baseUrl}/pyodide.mjs`)) as { - loadPyodide: () => Promise; - }; - const pyodide: PyodideInterface = await loadPyodide(); - if (onProgress) onProgress(50, "Core Pyodide loaded"); - - if (onProgress) onProgress(65, "Loading micropip"); - await pyodide.loadPackage("micropip"); - if (onProgress) onProgress(80, "Installing Python packages"); - - // Pass deps via globals instead of string interpolation for safety - pyodide.globals.set("_rr_deps_to_install", deps); - await pyodide.runPythonAsync(` - import micropip - await micropip.install(list(_rr_deps_to_install)) - `); - pyodide.globals.delete("_rr_deps_to_install"); - - if (onProgress) onProgress(100, "Ready"); - return pyodide; - } catch (e) { - if (onProgress) onProgress(100, "Error during load"); - throw e; - } +import type { + KnownChecksData, + ReviewRunData, + WorkerRequest, + WorkerResponse, +} from "./pyodide-common"; + +type ProgressHandler = (progress: number, message?: string) => void; + +type WorkerRequestPayload = + | { type: "prepare"; deps: string[] } + | { type: "loadKnownChecks" } + | { type: "runReview"; repo: string; ref: string; subdir: string } + | { type: "generateHtml"; token: string; show: string }; + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; + onProgress?: ProgressHandler; } -export async function prefetch( - pyodide: PyodideInterface, - repo: string, - branch: string, - subdir: string = "", -): Promise { - pyodide.globals.set("repo", repo); - pyodide.globals.set("branch", branch); - pyodide.globals.set("subdir", subdir); - const packagePy = await pyodide.runPythonAsync(` - from repo_review.files import collect_prefetch_files, process_prefetch_files - from repo_review.ghpath import GHPath - - package = await GHPath.async_from_repo(repo, branch) - prefetch_files = collect_prefetch_files() - await process_prefetch_files(package, prefetch_files, subdir=subdir) - package - `); - - // package can be None in Python land -> map to null - return packagePy === undefined ? null : (packagePy as PyProxy | null); +export interface PyodideClient { + prepare(deps: string[], onProgress?: ProgressHandler): Promise; + loadKnownChecks(): Promise; + runReview(repo: string, ref: string, subdir?: string): Promise; + generateHtml(token: string, show?: string): Promise; + dispose(): void; } -// Package can be None -export function collect_checks( - pyodide: PyodideInterface, - pyPackage: PyProxy | null, - subdir: string = "", -): PyProxy { - pyodide.globals.set("package", pyPackage); - pyodide.globals.set("subdir", subdir); - const collected = pyodide.runPython(` - from repo_review.processor import collect_all - - collect_all(package, subdir=subdir) - `); - return collected as PyProxy; -} +class WorkerPyodideClient implements PyodideClient { + private readonly worker: Worker; -export function run_process( - pyodide: PyodideInterface, - pyPackage: PyProxy | null, - collected: PyProxy, - subdir: string = "", -): PyProxy { - pyodide.globals.set("package", pyPackage); - pyodide.globals.set("collected", collected); - pyodide.globals.set("subdir", subdir); - const checks = pyodide.runPython(` - from repo_review.processor import process, md_as_html - - families, checks = process(package, collected=collected, subdir=subdir) - - for v in families.values(): - if v.get("description"): - v["description"] = md_as_html(v["description"]) - [res.md_as_html() for res in checks] - `); - return checks; -} + private readonly pending = new Map(); + + private nextRequestId = 1; + + constructor() { + const workerUrl = this.getWorkerUrl(); + this.worker = new Worker(workerUrl, { type: "module" }); + this.worker.addEventListener("message", this.handleMessage); + this.worker.addEventListener("error", this.handleError); + } + + private getWorkerUrl(): URL { + const currentUrl = new URL(import.meta.url); + const isSourceModule = + currentUrl.protocol === "file:" || + currentUrl.pathname.includes("/src/repo-review-app/"); + + if (isSourceModule) { + if (currentUrl.protocol === "file:") { + // Bun's dev server sets import.meta.url to a file:// path even though + // it serves files over HTTP with the HTML as the web root. + // The worker is at utils/pyodide-worker.ts relative to index.html. + return new URL("./utils/pyodide-worker.ts", window.location.href); + } + return new URL("./pyodide-worker.ts", currentUrl); + } + + return new URL("./utils/pyodide-worker.min.js", import.meta.url); + } + + private handleMessage = (event: MessageEvent): void => { + const response = event.data; + const pending = this.pending.get(response.id); + if (!pending) { + return; + } + + if (response.type === "progress") { + pending.onProgress?.(response.progress, response.message); + return; + } + + this.pending.delete(response.id); + + if (response.type === "error") { + pending.reject(new Error(response.error)); + return; + } + + pending.resolve(response.payload); + }; + + private handleError = (event: ErrorEvent): void => { + const parts: string[] = []; + if (event.message) parts.push(event.message); + if (event.filename) parts.push(`(${event.filename}:${event.lineno})`); + const message = + parts.length > 0 ? parts.join(" ") : "Pyodide worker failed to start"; + const error = new Error(message); + for (const [, pending] of this.pending) { + pending.reject(error); + } + this.pending.clear(); + }; + + private request( + request: WorkerRequestPayload, + onProgress?: ProgressHandler, + ): Promise { + const id = this.nextRequestId++; + + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: (value: unknown) => resolve(value as T), + reject, + onProgress, + }); + const message: WorkerRequest = { id, ...request }; + this.worker.postMessage(message); + }); + } -export function load_known_checks( - pyodide: PyodideInterface, -): Record { - const dataStr = pyodide.runPython(` - import json - from repo_review.processor import collect_all - from repo_review.checks import get_check_url - - collected = collect_all() - families_out = {k: {"name": v.get("name", k)} for k, v in collected.families.items()} - results_out = [] - for name, check in collected.checks.items(): - desc = (check.__doc__ or "").strip() - results_out.append({ - "name": name, - "family": check.family, - "description": desc, - "url": get_check_url(name, check), - }) - json.dumps({"families": families_out, "results": results_out}) - `); - - // pyodide may return a PyProxy; ensure string - return JSON.parse(dataStr.toString ? dataStr.toString() : dataStr); + prepare(deps: string[], onProgress?: ProgressHandler): Promise { + return this.request({ type: "prepare", deps }, onProgress); + } + + loadKnownChecks(): Promise { + return this.request({ type: "loadKnownChecks" }); + } + + runReview( + repo: string, + ref: string, + subdir: string = "", + ): Promise { + return this.request({ + type: "runReview", + repo, + ref, + subdir, + }); + } + + generateHtml(token: string, show: string = "all"): Promise { + return this.request({ type: "generateHtml", token, show }); + } + + dispose(): void { + this.worker.removeEventListener("message", this.handleMessage); + this.worker.removeEventListener("error", this.handleError); + for (const [, pending] of this.pending) { + pending.reject(new Error("Pyodide worker disposed")); + } + this.pending.clear(); + this.worker.terminate(); + } } -export async function generate_html( - pyodide: PyodideInterface, - familiesPy: PyProxy | Record, - checksPy: PyProxy | unknown[], - show: string = "all", -): Promise { - pyodide.globals.set("families_for_html", familiesPy); - pyodide.globals.set("checks_for_html", checksPy); - pyodide.globals.set("show_for_html", show || "all"); - - const htmlOut = await pyodide.runPythonAsync(` - from repo_review.html import to_html - - match show_for_html: - case "all": - filtered = checks_for_html - case "err": - filtered = [c for c in checks_for_html if c.result is False] - case "errskip": - filtered = [c for c in checks_for_html if c.result is not True] - case _: - filtered = checks_for_html - - to_html(families_for_html, filtered) - `); - - return htmlOut.toString ? htmlOut.toString() : htmlOut; +export function createPyodideClient(): PyodideClient { + return new WorkerPyodideClient(); }