Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 11 additions & 8 deletions docs/webapp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script>` tag is
required. The version loaded matches the `pyodide` npm package version used at
build time. Running `bun run build` writes a bundled ESM file to
`docs/_static/scripts/repo-review-app.min.js`, which the Live Demo imports as a
module.
When bundling the app for the web, Pyodide is loaded inside a dedicated web
worker from the official CDN. The UI thread only talks to that worker via a
promise-based message API, so React never touches Pyodide objects directly.
Running `bun run build` writes the bundled ESM entrypoint to
`docs/_static/scripts/repo-review-app.min.js` and emits the worker module
alongside it.

## Custom app

To embed the app, simply import the ESM bundle and call `mountApp()`:
If you prefer to write a custom integration, import the ESM bundle and mount
the app. Pyodide will initialize lazily inside the worker when the app starts.
For example:

```html
<script type="module">
Expand All @@ -73,4 +75,5 @@ To embed the app, simply import the ESM bundle and call `mountApp()`:
</script>
```

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.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
182 changes: 58 additions & 124 deletions src/repo-review-app/repo-review-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -65,14 +59,12 @@ interface Option {
interface AppProps {
deps: string[];
header?: boolean;
pyodideBaseUrl?: string;
}

interface AppState {
show: string;
results: Record<string, CheckItem[]> | CheckItem[];
pyFamilies: PyProxy | null;
pyChecks: PyProxy | null;
currentRunToken: string | null;
snackbarOpen: boolean;
snackbarMsg: string;
snackbarSeverity: "info" | "error" | "warning" | "success";
Expand Down Expand Up @@ -101,7 +93,9 @@ interface AppState {
}

class App extends React.Component<AppProps, AppState> {
pyodide_promise: Promise<PyodideInterface> | null;
pyodideClient: PyodideClient | null;

pyodide_promise: Promise<void> | null;

constructor(props: AppProps) {
super(props);
Expand All @@ -113,8 +107,7 @@ class App extends React.Component<AppProps, AppState> {
this.state = {
show: params.get("show") || "all",
results: [],
pyFamilies: null,
pyChecks: null,
currentRunToken: null,
snackbarOpen: false,
snackbarMsg: "",
snackbarSeverity: "info",
Expand All @@ -141,22 +134,12 @@ class App extends React.Component<AppProps, AppState> {
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) {
Expand All @@ -168,20 +151,15 @@ class App extends React.Component<AppProps, AppState> {
}

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() {
Expand Down Expand Up @@ -210,74 +188,34 @@ class App extends React.Component<AppProps, AppState> {
"",
`${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<string, CheckItem[]> = {};
const families: Record<string, { name: string; description?: string }> =
{};
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({
Expand All @@ -290,10 +228,6 @@ class App extends React.Component<AppProps, AppState> {
err_msg: `<pre><code>${emsg}</code></pre>`,
});
}
} finally {
// prefetch and collected are run-scoped only; release them after processing
this.destroyProxy(collected);
this.destroyProxy(pyPackage);
}
}

Expand All @@ -308,24 +242,9 @@ class App extends React.Component<AppProps, AppState> {
}

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:
Expand All @@ -335,6 +254,11 @@ class App extends React.Component<AppProps, AppState> {
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);
Expand Down Expand Up @@ -363,11 +287,11 @@ class App extends React.Component<AppProps, AppState> {
}

async loadKnownChecks() {
const pyodide = await this.pyodide_promise!;
await this.pyodide_promise!;
let data: { families?: Record<string, { name: string }>; results?: any[] } =
{};
try {
data = load_known_checks(pyodide);
data = await this.pyodideClient!.loadKnownChecks();
} catch (e) {
console.error("Error loading known checks:", e);
return;
Expand Down Expand Up @@ -402,9 +326,9 @@ class App extends React.Component<AppProps, AppState> {
}

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,
Expand All @@ -419,7 +343,17 @@ class App extends React.Component<AppProps, AppState> {
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",
});
});
}
}

Expand Down
Loading
Loading