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();
}