diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f37135e..78d3254 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,62 +5,61 @@ on: branches: [main, oss-release-prep] pull_request: branches: [main] + schedule: + # Nightly 07:00 UTC: the full toolchain matrix + the full manifest + deep generative fuzzing + # with a rotating seed. This is what catches OTP/Elixir-version codegen drift over time — every + # bug in the 1.20/OTP29 port (map-literal miscompile, gen:call timers, fnegate, …) was a toolchain + # delta, and the fuzzer with a fresh seed each night explores 100 new programs across every cell. + - cron: "0 7 * * *" permissions: contents: read jobs: - # Fast gate: formatting, linting, and the package's own ExUnit suite. Pure-Elixir, - # no exotic toolchain — this is the gate that must always be green. + # ── Fast gate: format, lint, ExUnit, dialyzer on the primary toolchain. Must always be green. ── lint-test: - name: format · credo · mix test + name: format · credo · test · dialyzer runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - - name: Set up Erlang/Elixir - uses: erlef/setup-beam@v1 + - uses: erlef/setup-beam@v1 with: - otp-version: "27.0" - elixir-version: "1.17.1-otp-27" - - - name: Cache deps & build - uses: actions/cache@v4 + otp-version: "29.0.2" + elixir-version: "1.20.2-otp-29" + - uses: actions/cache@v4 with: path: | deps _build - key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }} - restore-keys: ${{ runner.os }}-mix- - + key: ${{ runner.os }}-otp29-mix-${{ hashFiles('mix.lock') }} + restore-keys: ${{ runner.os }}-otp29-mix- - run: mix deps.get - run: mix format --check-formatted - run: mix credo - run: mix test - # Dialyzer: PLT is built into _build (cached above); first cold run ~1 min. - run: mix dialyzer - # Differential gate: the real proof — every suite run bit-exact against the Elixir VM. - # `verify.exs fast` runs the 6 suites that need only Node 24 + Binaryen (conformance, - # fuzz, gaps, genfuzz, regexdiff, effects); the workerd-backed suites (scoreboard, - # markdown) are gated separately. See verify.exs and BUILD.md. + # ── PR/push gate: the differential manifest (fast subset) on the two toolchain corners. The + # compiler consumes BEAM bytecode, which changes most across OTP majors — so we prove the + # oldest supported (1.17/OTP27) and the newest (1.20/OTP29) on every change. ~2 min each. ── verify: - name: verify.exs (differential vs the VM) + name: verify (fast) · ${{ matrix.elixir }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { elixir: "1.17.1-otp-27", otp: "27.0" } + - { elixir: "1.20.2-otp-29", otp: "29.0.2" } steps: - uses: actions/checkout@v4 - - - name: Set up Erlang/Elixir - uses: erlef/setup-beam@v1 + - uses: erlef/setup-beam@v1 with: - otp-version: "27.0" - elixir-version: "1.17.1-otp-27" - - - name: Set up Node.js 24 (JSPI + WasmGC) - uses: actions/setup-node@v4 + otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} + - uses: actions/setup-node@v4 with: node-version: "24.16.0" - - name: Install Binaryen (wasm-as) version_130 run: | set -euo pipefail @@ -69,12 +68,46 @@ jobs: tar xzf binaryen.tar.gz echo "WASM_AS=$GITHUB_WORKSPACE/binaryen-version_130/bin/wasm-as" >> "$GITHUB_ENV" echo "NODE=$(command -v node)" >> "$GITHUB_ENV" + - run: mix deps.get + - name: Differential manifest (fast) vs the Elixir VM + run: elixir verify.exs fast - - name: Verify toolchain + # ── Nightly only: the FULL supported {elixir, otp} diagonal × the FULL manifest (adds the + # workerd-scale suites: scoreboard, markdown, calc-parser) + deep generative fuzzing + # (PROGS=100, seed rotates per run so each night explores fresh programs; failures print the + # GENSEED for a one-line repro). fail-fast:false so one cell's break doesn't hide the others. ── + verify-matrix: + name: verify (full) · ${{ matrix.elixir }} + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { elixir: "1.17.1-otp-27", otp: "27.0" } + - { elixir: "1.18.3-otp-27", otp: "27.3" } + - { elixir: "1.19.5-otp-28", otp: "28.1" } + - { elixir: "1.20.2-otp-29", otp: "29.0.2" } + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} + - uses: actions/setup-node@v4 + with: + node-version: "24.16.0" + - name: Install Binaryen (wasm-as) version_130 run: | - node --version - "$WASM_AS" --version - elixir --version - - - name: Run the differential manifest - run: elixir verify.exs fast + set -euo pipefail + curl -fsSL -o binaryen.tar.gz \ + https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz + tar xzf binaryen.tar.gz + echo "WASM_AS=$GITHUB_WORKSPACE/binaryen-version_130/bin/wasm-as" >> "$GITHUB_ENV" + echo "NODE=$(command -v node)" >> "$GITHUB_ENV" + - run: mix deps.get + - name: Full differential manifest + deep fuzz (PROGS=100, rotating seed) + env: + PROGS: "100" + GENSEED: ${{ github.run_number }} + run: elixir verify.exs diff --git a/.tool-versions b/.tool-versions index b2a3b6c..d1b688b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -erlang 27.0 -elixir 1.17.1-otp-27 +erlang 29.0.2 +elixir 1.20.2-otp-29 nodejs 24.16.0 diff --git a/bench/gaps/GAPS_FOUND.txt b/bench/gaps/GAPS_FOUND.txt index 6ad7f8d..90e231f 100644 --- a/bench/gaps/GAPS_FOUND.txt +++ b/bench/gaps/GAPS_FOUND.txt @@ -1,10 +1,11 @@ Elixir.Protocol.UndefinedError.exception/1 20 -Elixir.ArgumentError.exception/1 19 -Elixir.Kernel.inspect/1 19 elixir_erl_pass.no_parens_remote/2 18 erlang.phash/2 18 +Elixir.ArgumentError.exception/1 17 +Elixir.Kernel.inspect/1 17 Elixir.Enum.EmptyError.exception/1 14 Dict.update/4 6 +Elixir.IO.warn_once/3 6 Elixir.List.-inlined-last/2-/2 3 Elixir.UnicodeConversionError.exception/1 2 Elixir.KeyError.exception/1 1 diff --git a/bench/scoreboard/SCOREBOARD.md b/bench/scoreboard/SCOREBOARD.md index dac2664..4c14a37 100644 --- a/bench/scoreboard/SCOREBOARD.md +++ b/bench/scoreboard/SCOREBOARD.md @@ -5,24 +5,24 @@ concrete call is generated from a typed input pool (VM-validated); the SAME call on WasmGC and on the Elixir VM, results folded through an identical checksum and diffed. `nogen` = no candidate input matched yet (a harness gap, not a runtime failure). -**TOTAL: 487/487 bit-exact (100.0%) · 79 not yet generated · 566 public functions** +**TOTAL: 496/496 bit-exact (100.0%) · 82 not yet generated · 578 public functions** | Module | bit-exact | nogen | public fns | failing | |--------|-----------|-------|------------|---------| -| Enum | 109/109 | 3 | 112 | | -| List | 42/42 | 0 | 42 | | +| Enum | 112/112 | 4 | 116 | | +| List | 45/45 | 0 | 45 | | | Map | 42/42 | 0 | 42 | | -| Keyword | 50/50 | 0 | 50 | | +| Keyword | 49/49 | 1 | 50 | | | Tuple | 7/7 | 0 | 7 | | -| Integer | 17/17 | 0 | 17 | | -| String | 76/76 | 0 | 76 | | +| Integer | 19/19 | 0 | 19 | | +| String | 77/77 | 0 | 77 | | | Range | 10/10 | 0 | 10 | | | MapSet | 20/20 | 0 | 20 | | | Float | 16/16 | 0 | 16 | | | Atom | 3/3 | 0 | 3 | | | Bitwise | 12/12 | 0 | 12 | | -| Access | 13/13 | 3 | 16 | | +| Access | 14/14 | 3 | 17 | | | Function | 1/1 | 3 | 4 | | | Date | 30/30 | 20 | 50 | | -| Time | 19/19 | 21 | 40 | | +| Time | 19/19 | 22 | 41 | | | NaiveDateTime | 20/20 | 29 | 49 | | diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..3663c8b --- /dev/null +++ b/cli/README.md @@ -0,0 +1,71 @@ +# `pyex` — run general Python on WasmGC, like a real `python` binary + +``` +cli/pyex script.py # run a file +cli/pyex -c "print(1+1)" # run a snippet +echo "print('hi')" | cli/pyex # run stdin +REBUILD=1 cli/pyex ... # force recompiling the interpreter +``` + +Runs the real [ivarvong/pyex](https://github.com/ivarvong/pyex) (a Python-3 interpreter in Elixir, +~146 modules) compiled to WasmGC and prints what `print()` emitted — byte-identical to CPython on the +programs tested (functions, classes/dunders, comprehensions, generators, slicing, f-strings, dicts, +exceptions, exact bignums). + +The interpreter is built by **`mix wasm.build`** run inside pyex — configured declaratively in pyex's +`mix.exs` (`:wasm` key: entry module, exports, and the dependency sandbox boundary). `cli/pyex` just +ensures `pyex/wasm/pyex.wasm` exists (building it once) and runs your program through it. Both projects +use Elixir 1.20/OTP29. Point `PYEX_DIR` at your pyex checkout. + +For agent loops, use the **`PyexSandbox`** class in `cli/sandbox.mjs`: compile once, `box.run(code)` per +snippet (~0.3 ms), isolated + sandboxed + step-bounded. See its header for the API. + +--- + +# `elw` — compile & run pure Elixir on WasmGC as a CLI + +``` +cli/elw [entry] [int-arg ...] +``` + +Runs the whole pipeline for you: `elixirc` → `beam2wasm.exs` (EXPORTS/STUB/BIGNUM) → +`wasm-as` → a Node runner that instantiates the WasmGC module, calls `entry`, and prints +its return value as JSON (walked out of the heap by `termToJs`). Host effects (IO, File, +`:math`, `:crypto`, exact bignums) are wired from `runtime/imports.mjs`. + +- `entry` defaults to `main`; integer args cross the boundary as f64 (exact to 2^53). +- The return value is printed as JSON. Exact integers larger than 2^53 come back as strings. +- `IO.puts`/`IO.write` reach the real stdout before the return value is printed. + +### Pulling in stdlib / deps + +The user file is compiled alone by default. To include stdlib or dependency modules, +list them in `DEPS` (resolved to their `.beam` via `:code.which`): + +``` +DEPS="Enum,String" cli/elw myprog.ex main +``` + +### Examples + +``` +cli/elw prog.ex add 40 2 #=> 42 +cli/elw prog.ex fact 20 #=> "2432902008176640000" (exact) +DEPS="Enum" cli/elw prog.ex main #=> {":list":[1,4,9], ...} +``` + +### Env knobs + +- `NODE` / `WASM_AS` — override toolchain binaries (defaults: pinned nvm 24.x, `wasm-as` on PATH). +- `KEEP=1` — keep `cli/_work/out.{wat,wasm}` build artifacts for inspection. +- `DEPS="Mod,Mod"` — extra modules to compile alongside the entry file. + +### Notes / limits + +- `STUB=1` is on: constructs the compiler can't lower yet become traps so the module still + builds; if the entry hits one you'll see a Wasm trap. Fix at the root per the project rule. +- Entry return type is `term`, so any value walks back. Args are integers only for now + (extend the runner's marshalling for lists/binaries if needed). +- The CLI sets `ATOMNAMES=1` so atom keys print by name (`:greeting`, not `:idx12`); this is + an env-gated compiler flag, off by default, so it doesn't affect the verify suites. +``` diff --git a/cli/elw b/cli/elw new file mode 100755 index 0000000..119cb19 --- /dev/null +++ b/cli/elw @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# elw — compile a pure-Elixir file to WasmGC and run one of its functions, end to end. +# +# cli/elw [entry] [int-arg ...] +# +# Defaults: entry = "main", no args. Integer args are passed across the boundary as f64 +# (exact to 2^53). The entry's return value is printed as JSON (walked out of the WasmGC +# heap). Pure programs with no external deps just work; to pull in stdlib/deps modules, +# set DEPS to a comma/space list of module names, resolved via :code.which: +# +# DEPS="Enum,String" cli/elw myprog.ex main +# +# Env: NODE / WASM_AS override the toolchain binaries. KEEP=1 keeps the build artifacts. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" + +file="${1:?usage: elw [entry] [int-arg ...]}"; shift +entry="main"; if [[ $# -gt 0 && "$1" != -* && ! "$1" =~ ^-?[0-9]+$ ]]; then entry="$1"; shift; fi +args=("$@") + +# toolchain: prefer $NODE, else the project-pinned 24.x line, else PATH. +node="${NODE:-}" +[[ -z "$node" ]] && node="$(ls "$HOME"/.nvm/versions/node/v24.*/bin/node 2>/dev/null | tail -1 || true)" +[[ -z "$node" ]] && node="$(command -v node)" +wasmas="${WASM_AS:-$(command -v wasm-as)}" + +build="$root/cli/_work"; mkdir -p "$build" + +# 1. Elixir source -> BEAM bytecode. +elixirc -o "$build" "$file" +primary="$(basename "$(ls -t "$build"/Elixir.*.beam | head -1)")" +beams=("$build/$primary") + +# extra dependency modules, resolved to their .beam on disk via the running VM. +if [[ -n "${DEPS:-}" ]]; then + for m in ${DEPS//,/ }; do + p="$(elixir -e "IO.write(:code.which(Elixir.$m))")" + beams+=("$p") + done +fi + +# 2. build the EXPORTS signature: numeric args are `int`, everything else `bin` (a string). +# Return type is `term`, so any value walks back out of the heap. +sig="" +for a in ${args[@]+"${args[@]}"}; do + if [[ "$a" =~ ^-?[0-9]+$ ]]; then sig+="int,"; else sig+="bin,"; fi +done +sig="${sig%,}" +exports="$entry:$sig->term" + +# 3. BEAM -> WAT -> WASM. STUB=1 lets unlowered paths become traps so the module still builds; +# BIGNUM=1 gives exact integers (and the int_val bridge termToJs decodes). +wat="$build/out.wat"; wasm="$build/out.wasm" +STUB=1 BIGNUM=1 ATOMNAMES=1 EXPORTS="$exports" elixir "$root/beam2wasm.exs" "${beams[@]}" > "$wat" +"$wasmas" "$wat" -o "$wasm" -all + +# 4. instantiate + call + print. +"$node" "$root/cli/runcli.mjs" "$wasm" "$wat" "$entry" ${args[@]+"${args[@]}"} + +[[ -n "${KEEP:-}" ]] || rm -f "$wat" "$wasm" "$build"/Elixir.*.beam diff --git a/cli/pyex b/cli/pyex new file mode 100755 index 0000000..44041e8 --- /dev/null +++ b/cli/pyex @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# pyex — run general Python on WasmGC, like a real `python` binary. +# +# cli/pyex script.py # run a file +# cli/pyex -c "print(1+1)" # run a snippet +# echo "print('hi')" | cli/pyex # run stdin +# REBUILD=1 cli/pyex ... # force recompiling the interpreter (mix wasm.build) +# +# The interpreter is the real ivarvong/pyex compiled to WasmGC by `mix wasm.build` — configured +# declaratively in pyex's mix.exs (module, exports, dependency sandbox boundary). No hand-rolled +# build here: this just ensures the wasm exists, then runs your program through it. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +pyex="${PYEX_DIR:-/Users/ivar/code/pyex}" +wasm="$pyex/wasm/pyex.wasm" + +# ---- resolve the Python source: -c | | stdin ---- +if [[ "${1:-}" == "-c" ]]; then src="${2:?-c needs a code string}" +elif [[ -n "${1:-}" && "$1" != "-" ]]; then src="$(cat "$1")" +else src="$(cat)"; fi + +node="${NODE:-$(ls "$HOME"/.nvm/versions/node/v24.*/bin/node 2>/dev/null | tail -1)}" +[[ -z "$node" ]] && node="$(command -v node)" + +# ---- build the interpreter once (cached), via the declarative mix task ---- +if [[ -n "${REBUILD:-}" || ! -f "$wasm" ]]; then + echo "pyex: compiling the interpreter to WasmGC via 'mix wasm.build' (first run)…" >&2 + ( cd "$pyex" && mix wasm.build --out wasm >/dev/null ) +fi + +exec "$node" "$root/cli/pyex-run.mjs" "$wasm" "$src" diff --git a/cli/pyex-run.mjs b/cli/pyex-run.mjs new file mode 100644 index 0000000..f6a8a35 --- /dev/null +++ b/cli/pyex-run.mjs @@ -0,0 +1,18 @@ +// pyex-run.mjs — run a Python program through the compiled pyex interpreter and +// print what print() emitted, like `python`. Thin CLI over PyexSandbox; a runtime error goes to +// stderr with exit 1. Reads the program from argv[3], or from stdin when it is "-". +import fs from "node:fs"; +import { PyexSandbox } from "./sandbox.mjs"; + +const [wasmPath, srcArg] = process.argv.slice(2); +const source = srcArg === "-" || srcArg === undefined ? fs.readFileSync(0, "utf8") : srcArg; + +// 0 = pyex's default step budget (a CLI run isn't a hostile agent snippet). +const box = new PyexSandbox({ wasmPath, maxSteps: 0 }); +const { ok, stdout, error } = box.run(source); + +if (ok) process.stdout.write(stdout); +else { + process.stderr.write("Traceback (pyex):\n" + error + "\n"); + process.exit(1); +} diff --git a/cli/runcli.mjs b/cli/runcli.mjs new file mode 100644 index 0000000..8a63c93 --- /dev/null +++ b/cli/runcli.mjs @@ -0,0 +1,66 @@ +// runcli.mjs [intArg...] — instantiate a compiled-Elixir WasmGC +// module, call with the given integer args, and print its return value as JSON +// (walked out of the WasmGC heap by termToJs). Host effects (IO/File/:math/:crypto/bignum) +// are wired from the shared runtime imports, so IO.puts etc. reach the real stdout. +import fs from "node:fs"; +import path from "node:path"; +import nodeCrypto from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { makeBig, makeMath, makeStr, makeFs, makeIo, makeCrypto, makeProcStubs, memFsBacking, termToJs } + from "../runtime/imports.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const [wasmPath, watPath, entry, ...rawArgs] = process.argv.slice(2); + +const big = makeBig(); +const math = makeMath(); +let e; // exports (needed by the str/fs/io factories) +const str = makeStr(() => e); +const { proc, sched } = makeProcStubs(); // Map-backed process dict (Decimal.Context) + no-op scheduler +const imports = { + big, math, str, proc, sched, + crypto: makeCrypto(() => e, nodeCrypto), // :crypto.hash/2 etc. -> node:crypto + fs: makeFs(() => e, memFsBacking()), // in-memory VFS; swap for real fs if desired + io: makeIo(() => e), // IO.puts/write -> host stdout +}; + +const bytes = fs.readFileSync(path.resolve(wasmPath)); +e = new WebAssembly.Instance(new WebAssembly.Module(bytes), imports).exports; + +// marshal a JS arg into the value the export expects. Integers cross as f64 (exact to 2^53); +// anything else is built into a WasmGC $binary (UTF-8) via the bin_alloc/bin_put bridge, so an +// arg typed `bin`/`term` receives a real Elixir binary. Prefix "@" forces a string ("@42"). +const enc = new TextEncoder(); +const toBin = (s) => { const u = enc.encode(s); const b = e.bin_alloc(u.length); + for (let i = 0; i < u.length; i++) e.bin_put(b, i, u[i]); return b; }; +const marshal = (raw) => { + if (/^-?[0-9]+$/.test(raw)) return Number(raw); + return toBin(raw.startsWith("@") ? raw.slice(1) : raw); +}; +const args = rawArgs.map(marshal); + +const fn = e[entry]; +if (typeof fn !== "function") { + console.error(`no exported entry "${entry}". exports: ${Object.keys(e).filter(k => typeof e[k] === "function").join(", ")}`); + process.exit(2); +} + +let ret; +try { + ret = fn(...args); +} catch (err) { + // decode a thrown Elixir exception ($exc tag, exported as "exc"; 3 term args) into readable form + if (e.exc && err instanceof WebAssembly.Exception && err.is(e.exc)) { + const parts = [0, 1, 2].map((i) => { try { return termToJs(e, err.getArg(e.exc, i)); } catch { return "?"; } }); + console.error("Elixir exception: " + JSON.stringify(parts)); + } else { + console.error(err.stack || String(err)); + } + process.exit(1); +} +// int-returning entries in BIGNUM mode hand back a JS BigInt (externref); term entries +// hand back a WasmGC ref that termToJs walks into a plain JS value. +const out = typeof ret === "bigint" ? ret.toString() + : typeof ret === "number" ? ret + : termToJs(e, ret); +process.stdout.write(JSON.stringify(out) + "\n"); diff --git a/cli/sandbox.mjs b/cli/sandbox.mjs new file mode 100644 index 0000000..7101d3f --- /dev/null +++ b/cli/sandbox.mjs @@ -0,0 +1,67 @@ +// sandbox.mjs — a persistent, sandboxed Python runtime for AGENT LOOPS. +// +// Compile the WasmGC interpreter ONCE, then run many LLM-generated snippets at ~sub-ms each. Every +// run() is ISOLATED (fresh Python globals) and SANDBOXED (no host filesystem/network unless you wire +// it) and BOUNDED (a deterministic step limit — a runaway `while True` fails instead of hanging). +// +// import { PyexSandbox } from "./sandbox.mjs"; +// const box = new PyexSandbox(); // ~30 ms one-time warmup +// const { ok, stdout, error } = box.run("print(sum(range(10)))"); +// // ok === true, stdout === "45\n" +// +// Options: new PyexSandbox({ wasmPath, maxSteps }). maxSteps caps execution (default 5_000_000, +// ~a few hundred ms worst case; set 0 for pyex's default 10M). Pass 0 to disable the cap. +import fs from "node:fs"; +import path from "node:path"; +import nodeCrypto from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { makeBig, makeMath, makeStr, makeFs, makeIo, makeCrypto, makeProcStubs, makeSys, memFsBacking, termToJs } + from "../runtime/imports.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_WASM = path.join(HERE, "..", "..", "pyex", "wasm", "pyex.wasm"); + +export class PyexSandbox { + constructor({ wasmPath = DEFAULT_WASM, maxSteps = 5_000_000 } = {}) { + if (!fs.existsSync(wasmPath)) { + throw new Error(`pyex wasm not found at ${wasmPath}. Build it once with: cli/pyex -c "pass"`); + } + this.maxSteps = maxSteps; + this.enc = new TextEncoder(); + const big = makeBig(), math = makeMath(); + let e; + const str = makeStr(() => e); + const { proc, sched } = makeProcStubs(); + // No real fs/network is wired: makeFs is an in-memory VFS, http is absent. Guest code that + // reaches for a real effect gets an honest error, never host access. + e = new WebAssembly.Instance(new WebAssembly.Module(fs.readFileSync(wasmPath)), { + big, math, str, proc, sched, + crypto: makeCrypto(() => e, nodeCrypto), + sys: makeSys(), + fs: makeFs(() => e, memFsBacking()), + io: makeIo(() => e), + }).exports; + this.e = e; + } + + // run(code[, {maxSteps}]) -> { ok, stdout, error }. Never throws for guest errors; isolated per call. + run(code, { maxSteps = this.maxSteps } = {}) { + const e = this.e; + const u = this.enc.encode(code); + const b = e.bin_alloc(u.length); + for (let i = 0; i < u.length; i++) e.bin_put(b, i, u[i]); + let out; + try { + out = termToJs(e, e.pyrun(b, maxSteps)); + } catch (err) { + const msg = (e.exc && err instanceof WebAssembly.Exception && err.is(e.exc)) + ? "uncaught: " + JSON.stringify((() => { try { return termToJs(e, err.getArg(e.exc, 1)); } catch { return "?"; } })()) + : String(err.stack || err); + return { ok: false, stdout: "", error: msg }; + } + if (Array.isArray(out) && out[0] === ":ok") return { ok: true, stdout: out[1] ?? "", error: null }; + return { ok: false, stdout: "", error: Array.isArray(out) ? out[1] : String(out) }; + } +} + +export default PyexSandbox; diff --git a/demo/calc-parser/run.exs b/demo/calc-parser/run.exs index 5ca35ed..4211075 100644 --- a/demo/calc-parser/run.exs +++ b/demo/calc-parser/run.exs @@ -96,8 +96,11 @@ defmodule CalcDemo do end defp vm_parse(expr) do + # --no-compile: ensure_compiled!/0 already built the app; without it, a cold `mix run` prints a + # "==> " compile banner to stdout (the app depends on :beam2wasm) that pollutes the captured + # parse output and makes every case mismatch. {out, 0} = - System.cmd("mix", ["run", "-e", "IO.write(Calc.parse(hd(System.argv())))", expr], + System.cmd("mix", ["run", "--no-compile", "-e", "IO.write(Calc.parse(hd(System.argv())))", expr], cd: @app, env: [{"MIX_ENV", "dev"}]) out diff --git a/demo/markdown/run.exs b/demo/markdown/run.exs index 0da15f9..6b22b1b 100644 --- a/demo/markdown/run.exs +++ b/demo/markdown/run.exs @@ -51,7 +51,7 @@ defmodule MdDemo do # Include protocol IMPL modules (Enumerable.List etc.) so the dynamic apply dispatch in the consolidated # protocols has real targets to call. [Kernel, Exception, Enum, String, String.Break, String.Chars, List, Map, MapSet, Keyword, Integer, Float, - Tuple, Range, Stream, Enumerable, Collectable, Inspect, Inspect.Algebra, Access, + Tuple, Range, Stream, Stream.Reducers, Enumerable, Collectable, Inspect, Inspect.Algebra, Access, ArgumentError, RuntimeError, KeyError, :lists, :maps, :sets, :ordsets, :gb_sets, :erl_scan, :erl_anno, :proplists, :orddict, :string, :io_lib, :io_lib_format, :io_lib_pretty, Enumerable.List, Enumerable.Map, Enumerable.Range, Enumerable.MapSet, Enumerable.Function, Enumerable.Stream, Collectable.List, Collectable.Map, Collectable.MapSet, Collectable.BitString, @@ -95,7 +95,9 @@ defmodule MdDemo do end defp vm_render(seed) do - {out, 0} = System.cmd("mix", ["run", "-e", "IO.write(Blog.render(#{seed}))"], cd: @app, env: [{"MIX_ENV", "dev"}]) + # --no-compile: the app is already built above; without it a cold `mix run` prints a "==> " + # compile banner (the app depends on :beam2wasm) into the captured HTML, failing every diff. + {out, 0} = System.cmd("mix", ["run", "--no-compile", "-e", "IO.write(Blog.render(#{seed}))"], cd: @app, env: [{"MIX_ENV", "dev"}]) out end diff --git a/demo/pyex-web/.gitignore b/demo/pyex-web/.gitignore deleted file mode 100644 index d9e14d5..0000000 --- a/demo/pyex-web/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -app/dist/ -app/scripts/shots/ -app/tsconfig.tsbuildinfo diff --git a/demo/pyex-web/README.md b/demo/pyex-web/README.md deleted file mode 100644 index c6cc719..0000000 --- a/demo/pyex-web/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# pyex-web — the Python-on-WasmGC playground + HTTP API - -Live at **https://pyex.dev**: [pyex](https://github.com/ivarvong/pyex) -(a Python 3 interpreter written in Elixir) compiled to WebAssembly GC, running -two ways from one Cloudflare Worker: - -- **In your browser** — the playground UI. The Worker streams `pyex.wasm` from R2; - Python executes client-side in a Web Worker, with a Files panel, stdout, and an - OpenTelemetry trace waterfall emitted by the guest program itself. -- **At the edge** — `POST /api/run`. The same wasm, bundled into the Worker as a - precompiled module binding, runs Python server-side in the isolate: - - ```bash - curl -s https://pyex.dev/api/run \ - -H 'content-type: application/json' \ - -d '{"code": "print(sum(range(10)))", "files": {"/in.json": "[1,2,3]"}, "max_steps": 5000000}' - # -> {ok, ms, stdout, files, footprint, spans} | {ok: false, ms, error} - ``` - - `text/plain` bodies are raw Python. Every run gets a fresh interpreter Ctx and - in-memory VFS; runaways die on their step budget (default 150k, cap 300k) with - a clean Python `LimitError`. The cap sits deliberately below the isolate's - memory death line: V8 can't collect WasmGC garbage during a synchronous wasm - call, so per-step allocations accumulate for the whole run — past ~400k steps - that hits the 128 MB isolate ceiling and the platform (not the sandbox) kills - the request. The response carries the guest's OTel spans and the resource - footprint — the same observability the browser UI renders. - -## Layout - -- `app/` — Vite + React + Tailwind playground (`src/pyex.worker.ts` is the browser - glue; `src/imports.mjs` the wasm host imports, shared by the Worker API). -- `worker/` — the Cloudflare Worker: static assets, R2-streamed wasm, `/api/run`. - -## Dev loop - -```bash -cd app -curl --compressed -o public/pyex.wasm https://pyex.dev/pyex.wasm # or a fresh build -npm run dev -- --port 5199 -npm run check:mobile # closed-loop mobile-UX check: headless Chrome at iPhone - # geometry; boots wasm, runs an example, walks every tab, - # screenshots to scripts/shots/, fails on overflow/console - # errors/React warnings/small tap targets. -node scripts/lru-check.mjs # proves the lru example (chained assignment) runs -cd ../worker && npx wrangler dev --port 8799 # local /api/run -``` - -Both checks take `PYEX_URL=...` to target production instead. They need a browser -with wasm `exnref` support (Chrome 137+; the scripts point at system Chrome). - -## Deploy - -```bash -./deploy.sh [path/to/pyex.wasm] # default: ../../../pyex/wasm/pyex.wasm -``` - -The wasm lives in TWO places that must stay in sync — R2 (browser path) and -`worker/pyex.wasm` (API path; workerd forbids runtime `WebAssembly.compile`). -`deploy.sh` updates both, cache-busts the browser URL by content hash -(`VITE_WASM_V`), deploys, and then validates production end-to-end. - -To build a fresh interpreter: `mix wasm.build` in the pyex repo (needs the -`wasm:` config in its mix.exs pointing at this repo's beam2wasm). diff --git a/demo/pyex-web/app/index.html b/demo/pyex-web/app/index.html deleted file mode 100644 index 7d48f9c..0000000 --- a/demo/pyex-web/app/index.html +++ /dev/null @@ -1,446 +0,0 @@ - - - - - - - - pyex — run agent-written Python as a function call - - - - - - - - - - - - - -
- - - -
-
- this page runs it live — Python 3 · Elixir · WasmGC -

Run agent-written Python as a function call

-

The sandbox is a function call.

-

- pyex is a Python 3 interpreter written in Elixir, built as an execution substrate for - agent loops. Sandboxed code never touches a Python runtime, a process, or your filesystem — - it reaches an interpreter that sees only the capabilities you pass in. - Tenant boot: microseconds. Container cold start: seconds. -

- - -
# the whole pitch: the model emits Python, your tools are host functions,
-# isolation is deny-by-default — and it's all one function call.
-tools = %{
-  "search" => {:builtin, fn [q] -> MyApp.Search.run(q) end},
-  "fetch"  => {:builtin, fn [url] -> MyApp.HTTP.get(url) end}
-}
-
-{:ok, result, ctx} = Pyex.run(code_the_model_wrote,
-  modules: %{"agent" => %{"tools" => tools}},
-  filesystem: %{"notes.md" => scratchpad},
-  limits: [timeout: 5_000, max_memory_bytes: 50_000_000])
- -
-
- - live — this very interpreter, compiled to WasmGC, in this page's Worker - ⌘↵ -
- -

-        
- - fresh sandbox per request · deterministic step budget -
-
-
- -
-

why code mode needs a different shape

-

Ten tool calls become one program.
Now who runs the program?

-

- Tool-calling agents pay one model round-trip per action. Code mode collapses ten tool calls into - one program — but now you're executing untrusted, model-written code on every step. The industry - answer is a VM per step, which reintroduces the latency you were removing and puts an RPC boundary - between the agent's code and every tool it calls. pyex's answer: interpret the Python yourself, - in-process — a step costs microseconds, and a tool call is a host function dispatch. No IPC, no - marshalling, no path from Python source to an OS process. -

-
- - - - - -
Sandbox service / microVMpyex
Start a runseconds cold, or a warm pool to manage~200 µs, no pool
Call a toolHTTP/RPC round-tripElixir function dispatch
Keep agent stateserialize + shipa value on your heap
Per-tenant costa VMa struct
-
- -
-

the trust boundary is a diff you can read

-

Everything the program can see
is an argument.

-
- open() writes to the map you passed in. requests.get hits your allowlist. - There is no os.exec because it doesn't exist. And a static analyzer walks the compiled - BEAM code on every CI run and fails the build if anything under lib/pyex - references File, Port, System.cmd, spawn, or the - host environment. The sandbox guarantee is a CI gate, not a code-review promise. -
-
# Deny by default. Every effect is a capability you chose to hand in.
-Pyex.run(source,
-  filesystem: %{"data.json" => json},           # open() sees only this
-  network: [%{allowed_url_prefix: "https://api.example.com/"}],
-  env: %{"API_KEY" => key},                     # injected, never in source
-  limits: [timeout: 5_000, max_memory_bytes: 50_000_000])
-

- And every run returns an unforgeable capability ledger — an OpenTelemetry span tree of every - file, URL, and store the program touched, even when it crashed. Preview effects before they - happen: copy-on-write overlays stage open(...).write and store.put for - review, then commit/1 applies exactly the run you approved — deterministic under a - seed, so there is no time-of-check/time-of-use gap. -

-
- -
-

the loop itself is sandboxed

-

Most sandboxes run the tool code.
pyex runs the controller.

-
# The model wrote this. It runs 10 steps without a single
-# network hop between the code and the tools.
-import json
-from agent import call_model, tools
-
-state = {"steps": []}
-for _ in range(10):
-    decision = call_model(state)
-    if decision["action"] == "stop":
-        break
-    result = tools[decision["tool"]](*decision["args"])
-    state["steps"].append({"tool": decision["tool"], "result": result})
-print(json.dumps(state))
-

- Generators are continuations, so a step can pause and resume without owning a process. - asyncio.gather interleaves like CPython. Retries, planners, eval harnesses — the loop - logic the model emits just runs. - See examples/research_agent.py - for the runnable proof. -

-
- -
-

numbers, reproducibly

-

The command is the marketing.

-
- - - - - - -
Workloadp50p99
FizzBuzz (100 iterations)182 µs238 µs
Algorithms suite (~150 LOC: sieve + sort + fib + stats)1.67 ms2.04 ms
FastAPI cold boot221 µs302 µs
FastAPI route — list + Jinja2 render108 µs166 µs
FastAPI route — 4049 µs19 µs
-
mix run bench/readme_bench.exs
-

- The honest tradeoff: 10–100× slower than CPython for pure CPU work — and it doesn't matter, - because agent steps are dominated by tool I/O, JSON shaping, and routing. Compute budgets exclude - I/O time: an agent waiting on a slow tool isn't killed for it; an infinite loop is. -

-
- -
-

multi-tenancy

-

A tenant is a value.

-

- A booted app is a struct on your heap. 100,000 tenants is a benchmark file - (bench/multitenant_scaling_bench.exs), not a capacity-planning meeting. Storage - multitenancy is an object boundary, not a tenant_id filter someone forgets. -

-
{:ok, app}       = Pyex.Lambda.boot(model_generated_fastapi_source)
-{:ok, resp, app} = Pyex.Lambda.handle(app, %{method: "GET", path: "/hello/world"})
-# boot once, handle many; state threads through; tenants serialize like any value
-
- -
-

trust, itemized

-

How we know it works.

-
    -
  • Differentially fuzzed against CPython — outputs and exception types must match.
  • -
  • Byte-for-byte repr conformance suite, plus fixture programs checked against CPython ground truth.
  • -
  • 5,073 IBM dectest vectors pass for decimal.
  • -
  • Property tests assert malformed input never crashes the host — it returns a Python error.
  • -
  • Dialyzer-clean, with @spec on the public surface; the banned-call tracer fails CI if the sandbox boundary regresses.
  • -
  • Real workloads as end-to-end tests: a webhook handler, a DCF model, an SSR blog, a research agent.
  • -
-
- -
-

defense in depth

-

Three layers, each named.

-

- pyex stops the 99% cooperatively — step, memory, and output budgets with clean Python errors. - The BEAM stops the rest unconditionally — run each guest in a monitored process with a - GC-enforced max_heap_size and a wall-clock kill - (examples/sandbox_server.exs - is the copy-paste). A microVM around the whole node stops the adversary. - One ops property worth quoting: the guest can't move your 5xx rate — verdicts - (ok / error / timeout / OOM) are body fields; HTTP status describes only your service. -

-
-

What it isn't

-

- pyex is a hardened library, not a microVM. Against a sophisticated adversary it composes - with stronger isolation rather than replacing it. It runs the Python agents actually write — - json, re, asyncio, pydantic, requests, - fastapi, partial pandas — not all of CPython. And it's an interpreter: - pure CPU work runs 10–100× slower than CPython, which agent workloads don't notice. Naming our own - boundary is the point. -

-
-
- -
-

Your agent writes Python.
Run it on your heap.

- {:pyex, "~> 0.1"} - -
- - -
- - - - diff --git a/demo/pyex-web/app/package-lock.json b/demo/pyex-web/app/package-lock.json deleted file mode 100644 index 21993c1..0000000 --- a/demo/pyex-web/app/package-lock.json +++ /dev/null @@ -1,2809 +0,0 @@ -{ - "name": "pyex-playground", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "pyex-playground", - "version": "0.1.0", - "dependencies": { - "@codemirror/lang-python": "^6.1.6", - "@uiw/codemirror-theme-github": "^4.23.6", - "@uiw/react-codemirror": "^4.23.6", - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.0.0", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", - "playwright-core": "^1.61.1", - "tailwindcss": "^4.0.0", - "typescript": "^5.6.3", - "vite": "^6.0.3" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@codemirror/autocomplete": { - "version": "6.20.3", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", - "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@codemirror/commands": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", - "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.7.0", - "@codemirror/view": "^6.27.0", - "@lezer/common": "^1.1.0" - } - }, - "node_modules/@codemirror/lang-python": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", - "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.3.2", - "@codemirror/language": "^6.8.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.2.1", - "@lezer/python": "^1.1.4" - } - }, - "node_modules/@codemirror/language": { - "version": "6.12.4", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", - "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.5.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0", - "style-mod": "^4.0.0" - } - }, - "node_modules/@codemirror/lint": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", - "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.42.0", - "crelt": "^1.0.5" - } - }, - "node_modules/@codemirror/search": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", - "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.37.0", - "crelt": "^1.0.5" - } - }, - "node_modules/@codemirror/state": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.0.tgz", - "integrity": "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==", - "license": "MIT", - "dependencies": { - "@marijn/find-cluster-break": "^1.0.0" - } - }, - "node_modules/@codemirror/theme-one-dark": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", - "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/highlight": "^1.0.0" - } - }, - "node_modules/@codemirror/view": { - "version": "6.43.4", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.4.tgz", - "integrity": "sha512-YImu23iyKfncJzT7sRy+rEqEhSc8RhOHqDxwy4WzXRKJwYm6iwf/9OJk5ctCAdZ6yi2ZqaGEvmf55fSVqMDrgg==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.7.0", - "crelt": "^1.0.6", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@lezer/common": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", - "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", - "license": "MIT" - }, - "node_modules/@lezer/highlight": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", - "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.3.0" - } - }, - "node_modules/@lezer/lr": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", - "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@lezer/python": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", - "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@marijn/find-cluster-break": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", - "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", - "license": "MIT" - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.2" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", - "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-x64": "4.3.2", - "@tailwindcss/oxide-freebsd-x64": "4.3.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-x64-musl": "4.3.2", - "@tailwindcss/oxide-wasm32-wasi": "4.3.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", - "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", - "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", - "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.11.1", - "@emnapi/runtime": "^1.11.1", - "@emnapi/wasi-threads": "^1.2.2", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", - "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", - "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", - "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.2", - "@tailwindcss/oxide": "4.3.2", - "tailwindcss": "4.3.2" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@uiw/codemirror-extensions-basic-setup": { - "version": "4.25.10", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.10.tgz", - "integrity": "sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/commands": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/search": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@codemirror/autocomplete": ">=6.0.0", - "@codemirror/commands": ">=6.0.0", - "@codemirror/language": ">=6.0.0", - "@codemirror/lint": ">=6.0.0", - "@codemirror/search": ">=6.0.0", - "@codemirror/state": ">=6.0.0", - "@codemirror/view": ">=6.0.0" - } - }, - "node_modules/@uiw/codemirror-theme-github": { - "version": "4.25.10", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-github/-/codemirror-theme-github-4.25.10.tgz", - "integrity": "sha512-iMM2QT4FaebJMO4W7lXmxNkRPIjKzgY26wL0QG0Ugy0gzsnxoNz4zgNeFIblPA8rvrN3vOIhNNh4nk9UOlFKxA==", - "license": "MIT", - "dependencies": { - "@uiw/codemirror-themes": "4.25.10" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - } - }, - "node_modules/@uiw/codemirror-themes": { - "version": "4.25.10", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.25.10.tgz", - "integrity": "sha512-Fqiz1HIuDlDftcL+/O53V333UOH6MqQ84VbiQB5egn6u+uDwAqACp1FrdAoi4wgpR3b3TGW4Gr0wIYcrJSSz1A==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@codemirror/language": ">=6.0.0", - "@codemirror/state": ">=6.0.0", - "@codemirror/view": ">=6.0.0" - } - }, - "node_modules/@uiw/react-codemirror": { - "version": "4.25.10", - "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.10.tgz", - "integrity": "sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.6", - "@codemirror/commands": "^6.1.0", - "@codemirror/state": "^6.1.1", - "@codemirror/theme-one-dark": "^6.0.0", - "@uiw/codemirror-extensions-basic-setup": "4.25.10", - "codemirror": "^6.0.0" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@babel/runtime": ">=7.11.0", - "@codemirror/state": ">=6.0.0", - "@codemirror/theme-one-dark": ">=6.0.0", - "@codemirror/view": ">=6.0.0", - "codemirror": ">=6.0.0", - "react": ">=17.0.0", - "react-dom": ">=17.0.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/codemirror": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", - "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/commands": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/search": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/crelt": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", - "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.383", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz", - "integrity": "sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==", - "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/style-mod": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", - "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", - "license": "MIT" - }, - "node_modules/tailwindcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", - "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - } - } -} diff --git a/demo/pyex-web/app/package.json b/demo/pyex-web/app/package.json deleted file mode 100644 index 7725213..0000000 --- a/demo/pyex-web/app/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "pyex-playground", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build && rm -f dist/pyex.wasm", - "preview": "vite preview", - "check:mobile": "node scripts/mobile-check.mjs" - }, - "dependencies": { - "@codemirror/lang-python": "^6.1.6", - "@uiw/codemirror-theme-github": "^4.23.6", - "@uiw/react-codemirror": "^4.23.6", - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.0.0", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", - "playwright-core": "^1.61.1", - "tailwindcss": "^4.0.0", - "typescript": "^5.6.3", - "vite": "^6.0.3" - } -} diff --git a/demo/pyex-web/app/play/index.html b/demo/pyex-web/app/play/index.html deleted file mode 100644 index 07782cb..0000000 --- a/demo/pyex-web/app/play/index.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - pyex playground — Python on WebAssembly GC - - - - - - - - - - - - -
- - - diff --git a/demo/pyex-web/app/public/og.png b/demo/pyex-web/app/public/og.png deleted file mode 100644 index 7c640a2..0000000 Binary files a/demo/pyex-web/app/public/og.png and /dev/null differ diff --git a/demo/pyex-web/app/scripts/calibrate.mjs b/demo/pyex-web/app/scripts/calibrate.mjs deleted file mode 100644 index 9b5a45d..0000000 --- a/demo/pyex-web/app/scripts/calibrate.mjs +++ /dev/null @@ -1,130 +0,0 @@ -// Calibrate /api/run's step + CPU budgets against the WasmGC memory death line. -// -// node scripts/calibrate.mjs deaths # LOCAL: find the isolate-death step count per -// # allocation shape (spawns its own wrangler dev, -// # restarts it after every kill) -// node scripts/calibrate.mjs timing # PROD (or PYEX_URL): wall-time vs steps at safe -// # budgets -> ms/step on real metal, from outside -// # (Workers freeze time internally; reported ms is 0) -// -// Why two halves: memory-per-step is deterministic, so death lines found locally transfer -// to production (same 128 MB isolate cap). CPU-per-step does NOT transfer across hardware, -// so it's measured against production differentially: slope of wall time over step budget, -// with the RTT baseline as intercept. -import { spawn } from "node:child_process"; -import { setTimeout as sleep } from "node:timers/promises"; - -const MODE = process.argv[2] || "timing"; -const PORT = 8901; -const LOCAL = `http://localhost:${PORT}`; -const PROD = (process.env.PYEX_URL || "https://pyex.dev").replace(/\/$/, ""); - -// Allocation shapes: memory cost per step differs wildly, so the safe cap is the -// MINIMUM death line across shapes, not the int-loop's flattering number. -const SHAPES = { - int_loop: "i = 0\nwhile True:\n i += 1", - bignum_fib: "a = b = 1\nwhile True:\n a, b = b, a + b", - str_concat: 's = ""\nwhile True:\n s += "xxxxxxxxxxxxxxxx"', - list_append: "xs = []\nwhile True:\n xs.append(len(xs))", - dict_grow: "d = {}\nn = 0\nwhile True:\n d[n] = n\n n += 1", - nested_data: 'xs = []\nwhile True:\n xs.append({"k": [1, 2, 3], "s": "abc"})', - gen_drain: "def f():\n n = 0\n while True:\n yield n\n n += 1\ng = f()\nprint(next(g))", - fstr_churn: 's = ""\nn = 0\nwhile True:\n s = f"value {n}"\n n += 1', -}; - -async function run(base, code, steps, timeoutMs = 60_000) { - const t0 = performance.now(); - try { - const res = await fetch(`${base}/api/run`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ code, max_steps: steps }), - signal: AbortSignal.timeout(timeoutMs), - }); - const wall = performance.now() - t0; - const text = await res.text(); - try { - const j = JSON.parse(text); - return { outcome: j.ok ? "ok" : /LimitError/.test(j.error) ? "limit" : "pyerr", wall, detail: j.error }; - } catch { - return { outcome: "DEAD", wall, detail: `${res.status} ${text.slice(0, 60)}` }; - } - } catch (e) { - return { outcome: "DEAD", wall: performance.now() - t0, detail: String(e).slice(0, 80) }; - } -} - -// ── deaths (local) ─────────────────────────────────────────────────────── -let dev = null; -async function startDev() { - dev?.kill("SIGKILL"); - dev = spawn("npx", ["wrangler", "dev", "--port", String(PORT), "--var", "MAX_STEPS_OVERRIDE:50000000"], { - cwd: new URL("../../worker", import.meta.url).pathname, - stdio: "ignore", - env: { ...process.env, ASDF_NODEJS_VERSION: "22.15.0" }, - }); - for (let i = 0; i < 60; i++) { - await sleep(1000); - try { - const r = await fetch(`${LOCAL}/api/health`, { signal: AbortSignal.timeout(2000) }); - if (r.ok) return; - } catch { /* not up yet */ } - } - throw new Error("wrangler dev did not become healthy"); -} - -async function deaths() { - await startDev(); - const LADDER = [100_000, 200_000, 300_000, 400_000, 600_000, 900_000, 1_400_000, 2_000_000, 3_000_000]; - const results = {}; - for (const [name, code] of Object.entries(SHAPES)) { - let lastSafe = 0, died = null; - for (const steps of LADDER) { - const r = await run(LOCAL, code, steps); - process.stdout.write(`${name} @ ${steps / 1000}k: ${r.outcome} (${Math.round(r.wall)}ms)\n`); - if (r.outcome === "DEAD") { died = steps; await startDev(); break; } - lastSafe = steps; - } - results[name] = { lastSafe, died }; - } - dev?.kill("SIGKILL"); - console.log("\nshape last-safe died-at"); - for (const [n, r] of Object.entries(results)) { - console.log(`${n.padEnd(14)} ${String(r.lastSafe / 1000 + "k").padStart(8)} ${r.died ? r.died / 1000 + "k" : "survived ladder"}`); - } - const floor = Math.min(...Object.values(results).map((r) => r.died ?? Infinity)); - console.log(`\nlowest death line: ${floor === Infinity ? "none hit" : floor / 1000 + "k"} — recommended cap ≈ half of that`); -} - -// ── timing (prod) ──────────────────────────────────────────────────────── -async function timing() { - // RTT baseline: trivial program, minimum of several runs - const rtts = []; - for (let i = 0; i < 6; i++) rtts.push((await run(PROD, "pass", 1000)).wall); - const rtt = Math.min(...rtts); - console.log(`baseline RTT+dispatch (min of 6): ${Math.round(rtt)}ms\n`); - - const BUDGETS = [50_000, 100_000, 150_000, 200_000, 250_000, 300_000]; - console.log("shape " + BUDGETS.map((b) => String(b / 1000 + "k").padStart(8)).join("") + " ms/step (fit)"); - let worst = 0; - for (const [name, code] of Object.entries(SHAPES)) { - const walls = []; - for (const b of BUDGETS) { - const r = await run(PROD, code, b); - walls.push(r.outcome === "DEAD" ? NaN : r.wall); - } - // least-squares slope of wall over steps - const pts = BUDGETS.map((b, i) => [b, walls[i]]).filter(([, w]) => !Number.isNaN(w)); - const n = pts.length, sx = pts.reduce((a, [x]) => a + x, 0), sy = pts.reduce((a, [, y]) => a + y, 0); - const sxx = pts.reduce((a, [x]) => a + x * x, 0), sxy = pts.reduce((a, [x, y]) => a + x * y, 0); - const slope = (n * sxy - sx * sy) / (n * sxx - sx * sx); - worst = Math.max(worst, slope); - console.log(name.padEnd(15) + walls.map((w) => (Number.isNaN(w) ? "DEAD".padStart(8) : String(Math.round(w)).padStart(8))).join("") + ` ${(slope * 1000).toFixed(2)} µs`); - } - console.log(`\nworst slope: ${(worst * 1000).toFixed(2)} µs/step`); - console.log(`=> at a 300k cap, worst-case run ≈ ${Math.round(worst * 300_000)}ms CPU`); - console.log(`=> cpu_ms budget ≈ worst-case × 2 + ~500ms boot headroom`); -} - -if (MODE === "deaths") await deaths(); -else await timing(); diff --git a/demo/pyex-web/app/scripts/lru-check.mjs b/demo/pyex-web/app/scripts/lru-check.mjs deleted file mode 100644 index 5ca4fa8..0000000 --- a/demo/pyex-web/app/scripts/lru-check.mjs +++ /dev/null @@ -1,33 +0,0 @@ -// One-off: prove the "lru cache" example (idiomatic chained assignment, -// `def __init__(self, k=None, v=None)`) runs on the wasm currently served. -import { chromium } from "playwright-core"; - -// The playground lives at /play/ (the root is the marketing landing page). -const ORIGIN = (process.env.PYEX_URL || "http://localhost:5199/").replace(/\/$/, ""); -const BASE = `${ORIGIN}/play/`; -const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; -const EXPECTED = ["get(1) -> 1", "get(2) -> -1", "get(1) -> -1", "get(3) -> 3", "get(4) -> 4"]; - -const browser = await chromium.launch({ executablePath: CHROME, headless: true }); -const page = await browser.newContext({ viewport: { width: 393, height: 852 }, isMobile: true, hasTouch: true }).then((c) => c.newPage()); -await page.goto(BASE, { waitUntil: "domcontentloaded" }); - -await page.waitForFunction(() => { - const b = [...document.querySelectorAll("button")].find((x) => x.textContent.includes("Run")); - return b && !b.disabled; -}, { timeout: 20000 }); - -await page.locator("button", { hasText: "lru cache" }).click(); -await page.waitForTimeout(300); -await page.locator("button", { hasText: "Run" }).click(); -await page.waitForFunction(() => /get\(4\)|Error|Traceback/.test(document.body.innerText), { timeout: 15000 }); - -const text = await page.evaluate(() => document.body.innerText); -await browser.close(); - -const missing = EXPECTED.filter((e) => !text.includes(e)); -if (missing.length || /Traceback|SyntaxError/.test(text)) { - console.error("LRU FAIL — missing:", missing, "\n", text.slice(0, 500)); - process.exit(1); -} -console.log("LRU OK — idiomatic chained-assignment example runs on the served wasm"); diff --git a/demo/pyex-web/app/scripts/mobile-check.mjs b/demo/pyex-web/app/scripts/mobile-check.mjs deleted file mode 100644 index ea946da..0000000 --- a/demo/pyex-web/app/scripts/mobile-check.mjs +++ /dev/null @@ -1,164 +0,0 @@ -// Closed-loop mobile UX check for the pyex playground. -// -// 1. `npm run dev` (or let this script assume it's already up on :5199) -// 2. `npm run check:mobile` -// -// Drives headless Chrome at iPhone geometry against the local dev server, -// exercises boot → run → every mobile tab, saves a screenshot per state to -// scripts/shots/, and exits nonzero if any assertion fails — so UX changes -// can be iterated without deploying: edit, re-run, diff the shots. -// -// Requires the wasm at app/public/pyex.wasm (grab the production one): -// curl --compressed -o public/pyex.wasm https://pyex.dev/pyex.wasm - -import { chromium } from "playwright-core"; -import { mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -// The playground lives at /play/ (the root is the marketing landing page). -const ORIGIN = (process.env.PYEX_URL || "http://localhost:5199/").replace(/\/$/, ""); -const BASE = `${ORIGIN}/play/`; -const SHOTS = join(dirname(fileURLToPath(import.meta.url)), "shots"); -const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; - -// iPhone 15-ish. DPR 3 keeps the shots crisp enough to eyeball type sizes. -const PHONE = { - viewport: { width: 393, height: 852 }, - deviceScaleFactor: 3, - isMobile: true, - hasTouch: true, - userAgent: - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", -}; - -const failures = []; -const warnings = []; -const fail = (msg) => { failures.push(msg); console.error(` ✗ ${msg}`); }; -const ok = (msg) => console.log(` ✓ ${msg}`); -const warn = (msg) => { warnings.push(msg); console.log(` ~ ${msg}`); }; - -mkdirSync(SHOTS, { recursive: true }); - -const browser = await chromium.launch({ executablePath: CHROME, headless: true }); -const page = await browser.newContext(PHONE).then((c) => c.newPage()); - -const consoleErrors = []; -page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); -page.on("pageerror", (e) => consoleErrors.push(`pageerror: ${e.message}`)); - -const shot = async (name) => { - await page.screenshot({ path: join(SHOTS, `${name}.png`) }); - console.log(` 📸 ${name}.png`); -}; - -const noOverflow = async (where) => { - const o = await page.evaluate(() => ({ - doc: document.documentElement.scrollWidth, - win: window.innerWidth, - })); - if (o.doc > o.win) fail(`${where}: horizontal overflow (${o.doc}px doc in ${o.win}px viewport)`); - else ok(`${where}: no horizontal overflow`); -}; - -const clickTab = async (name) => { - await page.locator("nav button", { hasText: name }).last().click(); - await page.waitForTimeout(150); -}; - -console.log(`\npyex mobile check — ${BASE} @ ${PHONE.viewport.width}x${PHONE.viewport.height}\n`); - -// ── boot ──────────────────────────────────────────────────────────────── -console.log("boot:"); -await page.goto(BASE, { waitUntil: "domcontentloaded" }); -const runBtn = page.locator("button", { hasText: "Run" }); -try { - await page.waitForFunction( - () => { - const b = [...document.querySelectorAll("button")].find((x) => x.textContent.includes("Run")); - return b && !b.disabled; - }, - { timeout: 20000 }, - ); - ok("wasm booted, Run enabled"); -} catch { - fail("wasm did not boot within 20s (Run still disabled)"); - await shot("boot-failed"); - await browser.close(); - report(); -} -await shot("01-code"); -await noOverflow("code tab"); - -// ── run the default example ───────────────────────────────────────────── -console.log("run:"); -await runBtn.click(); -try { - await page.waitForFunction( - () => /orders|→|Error|Traceback/.test(document.body.innerText), - { timeout: 15000 }, - ); - ok("run produced output"); -} catch { - fail("no output appeared within 15s of Run"); -} -await shot("02-output"); -await noOverflow("output tab"); - -// ── every mobile tab renders ──────────────────────────────────────────── -console.log("tabs:"); -for (const tab of ["files", "code", "output", "trace"]) { - await clickTab(tab); - const text = await page.evaluate(() => document.body.innerText); - if (text.trim().length < 20) fail(`${tab} tab renders nearly empty`); - else ok(`${tab} tab renders`); -} -await shot("03-trace"); -await noOverflow("trace tab"); - -// trace rows should match the span count badge in the tab bar -const spans = await page.evaluate(() => { - const badge = [...document.querySelectorAll("nav button")] - .find((b) => b.textContent.includes("trace")) - ?.textContent.replace(/\D/g, ""); - return badge ? Number(badge) : null; -}); -if (spans != null && spans > 0) ok(`trace reports ${spans} spans`); -else fail("trace tab has no span count after a successful run"); - -await clickTab("files"); -await shot("04-files"); - -// ── hygiene ───────────────────────────────────────────────────────────── -console.log("hygiene:"); -const reactIssues = consoleErrors.filter((e) => /same key|Warning:/.test(e)); -const hardErrors = consoleErrors.filter((e) => !/same key|Warning:/.test(e)); -if (hardErrors.length) fail(`console errors: ${hardErrors.slice(0, 3).join(" | ").slice(0, 300)}`); -else ok("no console errors"); -if (reactIssues.length) fail(`React warnings: ${reactIssues[0].slice(0, 160)}`); -else ok("no React warnings"); - -// tap targets: interactive controls should be ≥ 40px tall on touch (44 per HIG) -const smallTargets = await page.evaluate(() => - [...document.querySelectorAll("button, a")] - .filter((e) => e.offsetParent !== null) - .map((e) => ({ t: e.textContent.trim().slice(0, 20) || e.tagName, h: e.getBoundingClientRect().height })) - .filter((x) => x.h > 0 && x.h < 40), -); -if (smallTargets.length) - fail(`tap targets under 40px: ${smallTargets.map((x) => `${x.t} (${Math.round(x.h)}px)`).join(", ").slice(0, 200)}`); -else ok("all tap targets ≥ 40px"); - -await browser.close(); -report(); - -function report() { - console.log(`\n${"─".repeat(50)}`); - if (warnings.length) console.log(`${warnings.length} warning(s) — see above`); - if (failures.length) { - console.error(`FAIL — ${failures.length} problem(s). Shots in ${SHOTS}`); - process.exit(1); - } - console.log(`PASS — shots in ${SHOTS}`); - process.exit(0); -} diff --git a/demo/pyex-web/app/src/App.tsx b/demo/pyex-web/app/src/App.tsx deleted file mode 100644 index f72d3c4..0000000 --- a/demo/pyex-web/app/src/App.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Pyex, type RunResult, type Files } from "./lib/pyex"; -import { EXAMPLES } from "./examples"; -import { Editor } from "./components/Editor"; -import { FileExplorer } from "./components/FileExplorer"; -import { Output } from "./components/Output"; -import { TraceViewer } from "./components/TraceViewer"; - -const GH = ({ repo }: { repo: string }) => ( - - - ivarvong/{repo} - -); - -type Tab = "files" | "code" | "output" | "trace"; - -// The same interpreter runs server-side on this Worker: POST /api/run. The panel -// renders a curl for WHATEVER is in the editor, so the two paths stay one story. -function APIPanel({ code, files, onClose }: { code: string; files: Files; onClose: () => void }) { - const [copied, setCopied] = useState(false); - const body = JSON.stringify(Object.keys(files).length ? { code, files } : { code }); - const curl = `curl -s https://pyex.dev/api/run \\\n -H 'content-type: application/json' \\\n -d '${body.replace(/'/g, `'\\''`)}'`; - const copy = () => { - navigator.clipboard.writeText(curl).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1600); }); - }; - return ( -
-
e.stopPropagation()}> -
- HTTP API — the same interpreter, server-side - -
-
-

- POST /api/run executes Python in this Worker's isolate at the edge — - the exact wasm your browser is running now. Fresh sandbox per request, step-budget bounded, - no host filesystem or network. The reply carries stdout, - the mutated files, the resource footprint, - and the program's own OpenTelemetry spans. -

-

This curl runs the code currently in your editor:

-
{curl}
-
- - text/plain body = raw Python · GET /api/health -
-
-
-
- ); -} - -export function App() { - const [code, setCode] = useState(EXAMPLES[0].code); - const [files, setFiles] = useState(EXAMPLES[0].files ?? {}); - const [active, setActive] = useState("program.py"); - const [result, setResult] = useState(null); - const [running, setRunning] = useState(false); - const [wallMs, setWallMs] = useState(null); - const [written, setWritten] = useState>(new Set()); - const [status, setStatus] = useState("booting…"); - const [ready, setReady] = useState(false); - const [exampleName, setExampleName] = useState(EXAMPLES[0].name); - const [mobileTab, setMobileTab] = useState("code"); - const [showAPI, setShowAPI] = useState(false); - const [rev, setRev] = useState(1); - - const codeRef = useRef(code); - const filesRef = useRef(files); - const pyex = useRef(null); - const debounce = useRef(undefined); - - // boot the worker once - useEffect(() => { - pyex.current = new Pyex({ - onReady: ({ sizeMB, ms }) => { setReady(true); setStatus(`interpreter ${sizeMB.toFixed(1)} MB · ${ms.toFixed(0)}ms`); setRev((r) => r + 1); }, - onBootError: () => setStatus("needs a WasmGC browser (Chrome/Edge 119+, Firefox 120+)"), - onResult: (_id, r) => { - setRunning(false); setResult(r); setWallMs(r.ms); - if (r.ok) { - const before = filesRef.current; - const w = new Set(Object.keys(r.files).filter((k) => r.files[k] !== before[k])); - setWritten(w); - filesRef.current = r.files; setFiles(r.files); // evolve the workspace — no re-run (rev unchanged) - } - // on phones, jump to the result so the run is visible without hunting for a tab - if (self.matchMedia?.("(max-width: 767px)").matches) setMobileTab("output"); - setStatus("ready"); - }, - }); - }, []); - - // live compile — runs on user intent only (rev bumps), never on result-driven file updates - useEffect(() => { - if (!ready) return; - clearTimeout(debounce.current); - debounce.current = self.setTimeout(runNow, 450); - return () => clearTimeout(debounce.current); - }, [rev, ready]); - - // ⌘↵ / Ctrl↵ runs from anywhere - useEffect(() => { - const h = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); runNow(); } }; - window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); - }, [ready]); - - function runNow() { - if (!pyex.current?.isReady) return; - setRunning(true); setStatus("running…"); - pyex.current.run(codeRef.current, filesRef.current); - } - - // user-intent mutations bump rev - const editActive = (v: string) => { - if (active === "program.py") { codeRef.current = v; setCode(v); } - else { const f = { ...filesRef.current, [active]: v }; filesRef.current = f; setFiles(f); } - setRev((r) => r + 1); - }; - const loadExample = (name: string) => { - const ex = EXAMPLES.find((e) => e.name === name)!; - codeRef.current = ex.code; filesRef.current = ex.files ?? {}; - setCode(ex.code); setFiles(ex.files ?? {}); setActive("program.py"); - setExampleName(name); setWritten(new Set()); setRev((r) => r + 1); - }; - const addFile = () => { - const path = prompt("New file path", "/workspace/notes.txt"); - if (!path) return; - const f = { ...filesRef.current, [path]: "" }; filesRef.current = f; setFiles(f); - setActive(path); setMobileTab("code"); setRev((r) => r + 1); - }; - const deleteFile = (path: string) => { - const f = { ...filesRef.current }; delete f[path]; filesRef.current = f; setFiles(f); - if (active === path) setActive("program.py"); - setRev((r) => r + 1); - }; - - const activeValue = active === "program.py" ? code : (files[active] ?? ""); - const dot = running ? "bg-accent animate-pulse shadow-[0_0_0_3px_rgba(124,116,255,.18)]" - : result ? (result.ok ? "bg-grn" : "bg-red") : "bg-faint"; - - const explorer = ( - { setActive(p); setMobileTab("code"); }} onAdd={addFile} onDelete={deleteFile} /> - ); - const editor = ( -
-
- {active} - {active === "program.py" ? `${code.split("\n").length} lines` : "vfs file"} -
-
-
- ); - const output = ; - const trace = ; - - const Card = ({ children, className = "" }: { children: React.ReactNode; className?: string }) => ( -
{children}
- ); - - return ( -
- {/* nav */} - - {showAPI && setShowAPI(false)} />} - - {/* toolbar */} -
-
- {EXAMPLES.map((e) => ( - - ))} -
-
- - - {status} - - -
- - {/* desktop: files | editor | (output over trace — both always visible) */} -
- {explorer} - {editor} -
- -
stdout
-
{output}
-
- -
- trace · OpenTelemetry spans -
-
{trace}
-
-
-
- - {/* mobile: one panel + bottom tab bar */} -
- - {mobileTab === "files" ? explorer : mobileTab === "code" ? editor : mobileTab === "output" ? output : trace} - -
- -
- ); -} diff --git a/demo/pyex-web/app/src/components/Editor.tsx b/demo/pyex-web/app/src/components/Editor.tsx deleted file mode 100644 index a5b843c..0000000 --- a/demo/pyex-web/app/src/components/Editor.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import CodeMirror from "@uiw/react-codemirror"; -import { python } from "@codemirror/lang-python"; -import { githubDark } from "@uiw/codemirror-theme-github"; -import { EditorView } from "@codemirror/view"; - -// On phones, wrapped lines break mid-identifier and destroy the code's visual -// structure — scroll horizontally instead, like GitHub mobile. Checked once: -// a viewport class change mid-session is not worth a listener. -const isPhone = typeof window !== "undefined" && window.matchMedia("(max-width: 767px)").matches; - -export function Editor({ value, onChange, readOnly = false }: - { value: string; onChange?: (v: string) => void; readOnly?: boolean }) { - return ( - - ); -} diff --git a/demo/pyex-web/app/src/components/FileExplorer.tsx b/demo/pyex-web/app/src/components/FileExplorer.tsx deleted file mode 100644 index 0be4c5e..0000000 --- a/demo/pyex-web/app/src/components/FileExplorer.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { Files } from "../lib/pyex"; - -const FileIcon = () => ( - - - -); -const PyIcon = () => ( - - - -); - -export function FileExplorer({ - files, active, written, onSelect, onAdd, onDelete, -}: { - files: Files; - active: string; - written: Set; - onSelect: (path: string) => void; - onAdd: () => void; - onDelete: (path: string) => void; -}) { - const paths = Object.keys(files).sort(); - const row = (path: string, label: string, icon: React.ReactNode, deletable: boolean) => { - const on = active === path; - return ( -
onSelect(path)} - className={`group flex items-center gap-2 pl-3 pr-2 py-2.5 md:py-[5px] cursor-pointer text-[12.5px] font-mono rounded-md mx-1 - ${on ? "bg-accent/15 text-fg" : "text-muted hover:bg-surface2 hover:text-fg"}`} - > - {icon} - {label} - {written.has(path) && } - {deletable && ( - - )} -
- ); - }; - - return ( -
-
- EXPLORER - -
-
-
program
- {row("program.py", "program.py", , false)} -
workspace /
- {paths.length === 0 &&
no files — press +
} - {paths.map((p) => row(p, p, , true))} -
-
- ); -} diff --git a/demo/pyex-web/app/src/components/Output.tsx b/demo/pyex-web/app/src/components/Output.tsx deleted file mode 100644 index 920574c..0000000 --- a/demo/pyex-web/app/src/components/Output.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { RunResult } from "../lib/pyex"; - -export function Output({ result, running }: { result: RunResult | null; running: boolean }) { - return ( -
-      {running && !result && running…}
-      {!result && !running && Run to see output.}
-      {result && (result.ok
-        ? (result.stdout
-            ? {result.stdout}
-            : (no output))
-        : {result.error})}
-    
- ); -} diff --git a/demo/pyex-web/app/src/components/TraceViewer.tsx b/demo/pyex-web/app/src/components/TraceViewer.tsx deleted file mode 100644 index a5fb4b6..0000000 --- a/demo/pyex-web/app/src/components/TraceViewer.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useMemo, useState } from "react"; -import type { RunResult, Span } from "../lib/pyex"; - -const bytes = (n?: number) => n == null ? "—" : n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`; - -// A span is either RUNTIME-emitted (scope "pyex": the sandbox instrumenting its own file/db I/O) or -// APP-emitted (scope = a tracer name: the guest Python called `tracer.start_as_current_span(...)`). -// The app spans are the show — vivid violet — so you can SEE that the trace came from the program. -const isApp = (s: Span) => s.scope !== "pyex" && s.scope !== ""; - -// Runtime spans and app spans carry ids from two independent counters, so a -// bare `s.id` collides across the families — qualify by family for React keys, -// selection, and parent lookups. -const skey = (s: Span) => `${isApp(s) ? "a" : "r"}${s.id}`; -function hue(s: Span): string { - if (isApp(s)) return "#a78bfa"; // guest code — the star - if (s.name.startsWith("db")) return "#5ce08a"; - if (s.name.startsWith("file")) return "#5b9dff"; - return "#79c4ff"; -} - -function Chip({ k, v }: { k: string; v: string | number }) { - return ( - {k} {v} - ); -} - -export function TraceViewer({ result, wallMs }: { result: RunResult | null; wallMs: number | null }) { - const [sel, setSel] = useState(null); - - const spans: Span[] = result && result.ok ? result.spans : []; - const fp = result && result.ok ? result.footprint : {}; - - const { maxSeq, depthOf } = useMemo(() => { - const byId = new Map(spans.map((s) => [skey(s), s])); - const depthOf = (s: Span) => { let d = 0; const fam = isApp(s) ? "a" : "r"; let p = s.parent_id; while (p != null) { d++; p = byId.get(`${fam}${p}`)?.parent_id ?? null; } return d; }; - const maxSeq = Math.max(1, ...spans.map((s) => s.end_seq ?? s.start_seq + 1)); - return { maxSeq, depthOf }; - }, [spans]); - - const selected = spans.find((s) => skey(s) === sel) || null; - - return ( -
- {/* resource footprint — the turn's OTel attributes */} -
- - - pyex.run - - - - - - - - your code - runtime - - -
- - {/* span waterfall */} -
- {spans.length === 0 ? ( -
- No spans this run — this program emitted none.
- Try data pipeline: its Python calls - tracer.start_as_current_span(…) and every span shows up here. -
- ) : ( -
- {spans.map((s) => { - const end = s.end_seq ?? maxSeq; - const left = (s.start_seq / maxSeq) * 100; - const width = Math.max(2, ((end - s.start_seq) / maxSeq) * 100); - const c = hue(s); - const on = skey(s) === sel; - return ( -
setSel(on ? null : skey(s))} - className={`grid grid-cols-[minmax(120px,220px)_1fr] items-center gap-3 px-4 py-[3px] cursor-pointer ${on ? "bg-surface2" : "hover:bg-surface2/60"}`} - > -
- - {s.name} - {isApp(s) && {s.scope}} -
-
-
- {end - s.start_seq}u -
-
- ); - })} -
- )} -
- - {/* span detail */} - {selected && ( -
-
- - {selected.name} - · {isApp(selected) ? "your code" : "runtime"} · {selected.kind} · scope={selected.scope} - seq {selected.start_seq}→{selected.end_seq ?? "…"} -
- {Object.entries(selected.attributes).length === 0 - ?
no attributes
- : Object.entries(selected.attributes).map(([k, v]) => ( -
- {k} - {String(v)} -
- ))} -
- )} -
- ); -} diff --git a/demo/pyex-web/app/src/examples.ts b/demo/pyex-web/app/src/examples.ts deleted file mode 100644 index c38eca4..0000000 --- a/demo/pyex-web/app/src/examples.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { Files } from "./lib/pyex"; - -export type Example = { name: string; blurb: string; code: string; files?: Files }; - -// NOTE: `code` strings are JS template literals. Inside them, a literal newline for a Python string -// must be written as \\n, and never put `$` immediately before `{` (it becomes JS interpolation). -// Every example here is run through the test harness (see /tmp/test_ex.ts) before shipping. - -const ORDERS = JSON.stringify( - [ - { id: 1, region: "us-east", total: 129.0, items: 2 }, - { id: 2, region: "eu-west", total: 89.5, items: 1 }, - { id: 3, region: "us-east", total: 340.0, items: 5 }, - { id: 4, region: "apac", total: 1200.0, items: 3 }, - { id: 5, region: "eu-west", total: 45.0, items: 1 }, - { id: 6, region: "us-east", total: 76.25, items: 2 }, - { id: 7, region: "apac", total: 560.0, items: 4 }, - { id: 8, region: "eu-west", total: 210.0, items: 2 }, - { id: 9, region: "us-east", total: 999.99, items: 7 }, - { id: 10, region: "apac", total: 42.0, items: 1 }, - ], - null, - 2, -); - -const APP_LOG = - "2026-07-01 09:14:02.031 INFO req=a1f2 GET /api/users 200 12ms\n" + - "2026-07-01 09:14:03.104 INFO req=b3c8 POST /api/orders 201 45ms\n" + - "2026-07-01 09:14:04.210 WARN req=c9d1 GET /api/search 200 812ms slow\n" + - "2026-07-01 09:14:05.298 ERROR req=d4e7 POST /api/pay 500 23ms upstream timeout: payments-svc\n" + - "2026-07-01 09:14:06.401 INFO req=e5f9 GET /api/feed 200 34ms\n" + - "2026-07-01 09:14:07.510 INFO req=fa02 GET /api/users 200 9ms\n" + - "2026-07-01 09:14:08.622 WARN req=0b13 GET /api/search 200 1104ms slow\n" + - "2026-07-01 09:14:09.733 ERROR req=1c24 POST /api/orders 500 41ms deadlock on orders_pkey\n" + - "2026-07-01 09:14:10.844 INFO req=2d35 DELETE /api/session 204 6ms\n" + - "2026-07-01 09:14:11.955 INFO req=3e46 POST /api/orders 201 52ms\n" + - "2026-07-01 09:14:13.066 ERROR req=4f57 POST /api/pay 500 19ms upstream timeout: payments-svc\n" + - "2026-07-01 09:14:14.177 INFO req=5068 GET /api/feed 200 28ms\n" + - "2026-07-01 09:14:15.288 WARN req=6179 GET /api/search 200 934ms slow\n" + - "2026-07-01 09:14:16.399 INFO req=728a GET /api/users 200 11ms\n" + - "2026-07-01 09:14:17.510 INFO req=839b POST /api/orders 201 47ms\n" + - "2026-07-01 09:14:18.621 ERROR req=94ac GET /api/search 500 71ms query planner OOM\n" + - "2026-07-01 09:14:19.732 INFO req=a5bd GET /api/feed 200 31ms\n" + - "2026-07-01 09:14:20.843 INFO req=b6ce DELETE /api/session 204 5ms\n"; - -export const EXAMPLES: Example[] = [ - { - name: "data pipeline", - blurb: "an ETL agent that instruments itself with OpenTelemetry", - code: [ - "# An agent ETL pipeline — instrumented with OpenTelemetry, running on WasmGC.", - "# The nested spans in the Trace panel are emitted by THIS Python code.", - "from opentelemetry import trace", - "import json", - "", - 'tracer = trace.get_tracer("etl-agent")', - "", - 'with tracer.start_as_current_span("pipeline") as root:', - ' with tracer.start_as_current_span("extract") as s:', - ' orders = json.loads(open("/data/orders.json").read())', - ' s.set_attribute("orders", len(orders))', - "", - ' with tracer.start_as_current_span("transform") as s:', - " by_region = {}", - " for o in orders:", - ' by_region.setdefault(o["region"], []).append(o["total"])', - ' stats = {r: {"orders": len(v), "revenue": round(sum(v), 2)}', - " for r, v in by_region.items()}", - ' s.set_attribute("regions", len(stats))', - "", - ' with tracer.start_as_current_span("load") as s:', - ' open("/out/by_region.json", "w").write(json.dumps(stats, indent=2))', - ' s.set_attribute("path", "/out/by_region.json")', - "", - ' root.set_attribute("revenue", round(sum(o["total"] for o in orders), 2))', - "", - 'print(f"{len(orders)} orders -> {len(stats)} regions")', - "for r, st in sorted(stats.items()):", - " print(f\" {r:8} {st['orders']:>2} orders {st['revenue']:>9,.2f}\")", - ].join("\n"), - files: { "/data/orders.json": ORDERS }, - }, - { - name: "log triage", - blurb: "compute SLOs from a service log — traced end to end", - code: [ - "# Agent task: triage a service log — compute SLOs, group errors, write reports.", - "# Each phase is wrapped in an OpenTelemetry span (see the Trace panel).", - "from opentelemetry import trace", - "import json", - "", - 'tracer = trace.get_tracer("triage")', - "", - 'with tracer.start_as_current_span("parse") as s:', - " reqs = []", - ' for ln in open("/logs/app.log").read().splitlines():', - " p = ln.split()", - " if len(p) < 8:", - " continue", - ' reqs.append({"level": p[2], "method": p[4], "path": p[5],', - ' "status": int(p[6]), "latency": int(p[7].rstrip("ms")),', - ' "msg": " ".join(p[8:])})', - ' s.set_attribute("requests", len(reqs))', - "", - 'with tracer.start_as_current_span("analyze") as s:', - ' errors = [r for r in reqs if r["status"] >= 500]', - ' lat = sorted(r["latency"] for r in reqs)', - " p50 = lat[len(lat) // 2]", - " p95 = lat[min(len(lat) - 1, int(len(lat) * 0.95))]", - " by_path = {}", - " for r in reqs:", - ' by_path[r["path"]] = by_path.get(r["path"], 0) + 1', - ' s.set_attribute("errors", len(errors))', - ' s.set_attribute("p95_ms", p95)', - "", - 'with tracer.start_as_current_span("write_reports"):', - ' metrics = {"requests": len(reqs), "errors": len(errors),', - ' "error_rate": round(len(errors) / len(reqs), 3), "p50_ms": p50, "p95_ms": p95}', - ' open("/reports/metrics.json", "w").write(json.dumps(metrics, indent=2))', - ' open("/reports/errors.log", "w").write(', - " \"\\n\".join(f\"{r['method']} {r['path']} -> {r['status']} {r['msg']}\" for r in errors))", - "", - 'print(f"{len(reqs)} requests, {len(errors)} errors ({metrics[\'error_rate\']:.1%}), p95 {p95}ms")', - "for path, n in sorted(by_path.items(), key=lambda kv: -kv[1])[:4]:", - ' print(f" {path:16} {n:>2} requests")', - ].join("\n"), - files: { "/logs/app.log": APP_LOG, "/reports/.keep": "" }, - }, - { - name: "codemod", - blurb: "rename a function across a codebase, in place", - code: [ - "# Agent task: rename a function across the whole codebase, in place.", - "# Watch the Files panel: the sources are rewritten. Watch the Trace: a read + write", - "# span per file, plus the agent's own scan/apply spans.", - "from opentelemetry import trace", - "", - 'tracer = trace.get_tracer("codemod")', - 'OLD, NEW = "get_user", "fetch_user"', - 'sources = ["/src/models.py", "/src/handlers.py", "/src/service.py", "/src/main.py"]', - "", - "changed = []", - "for path in sources:", - ' with tracer.start_as_current_span("rewrite") as s:', - ' s.set_attribute("file", path)', - " text = open(path).read()", - " if OLD in text:", - " n = text.count(OLD)", - ' with open(path, "w") as f:', - " f.write(text.replace(OLD, NEW))", - " changed.append((path, n))", - ' s.set_attribute("occurrences", n)', - "", - 'print(f"renamed {OLD}() -> {NEW}() in {len(changed)} of {len(sources)} files")', - "for path, n in changed:", - ' print(f" {path:18} {n} occurrences")', - ].join("\n"), - files: { - "/src/models.py": "def get_user(uid):\n return db.query(uid)\n", - "/src/handlers.py": "from models import get_user\n\ndef handle(req):\n return get_user(req.uid).name\n", - "/src/service.py": "from models import get_user\n\ndef enrich(uid):\n u = get_user(uid)\n return {'id': uid, 'name': u.name}\n", - "/src/main.py": "print('starting')\n# no user lookup in this file\n", - }, - }, - { - name: "search index", - blurb: "read every doc, build an inverted index", - code: [ - "# Agent task: read every doc in the workspace and build an inverted search index.", - "# One read span per document (a clean fan-in in the Trace panel), then a single write.", - "from opentelemetry import trace", - "import json", - "", - 'tracer = trace.get_tracer("indexer")', - 'docs = ["/docs/intro.md", "/docs/api.md", "/docs/faq.md", "/docs/perf.md"]', - "", - "index = {}", - 'with tracer.start_as_current_span("index") as root:', - " for path in docs:", - " text = open(path).read().lower()", - ' name = path.split("/")[-1]', - " for word in text.split():", - ' w = "".join(c for c in word if c.isalnum())', - " if len(w) < 3:", - " continue", - " index.setdefault(w, []).append(name)", - ' root.set_attribute("terms", len(index))', - "", - "out = {w: sorted(set(docs)) for w, docs in index.items()}", - 'open("/index.json", "w").write(json.dumps(out, indent=2, sort_keys=True))', - "", - 'print(f"indexed {len(docs)} docs, {len(index)} unique terms")', - 'for term in ["python", "webassembly", "sandbox", "fast"]:', - " hits = out.get(term)", - " print(f\" {term:12} -> {hits if hits else '(no hits)'}\")", - ].join("\n"), - files: { - "/docs/intro.md": "pyex is a Python interpreter written in Elixir. It runs in a sandbox.", - "/docs/api.md": "The pyex API exposes run and a virtual filesystem for agent workloads.", - "/docs/faq.md": "Is it fast? pyex compiles to WebAssembly. Is it safe? Yes, every agent runs sandboxed.", - "/docs/perf.md": "Startup is fast: the interpreter loads once, then each run is sub-millisecond.", - }, - }, - { - name: "lru cache", - blurb: "the one they always ask you to whiteboard", - code: [ - "# You know the one. O(1) get and put — hash map + doubly linked list.", - "# (No, we will not be using an OrderedDict. We have standards.)", - "class Node:", - " def __init__(self, k=None, v=None):", - " self.k = k", - " self.v = v", - " self.prev = None", - " self.next = None", - "", - "class LRUCache:", - " def __init__(self, capacity):", - " self.cap = capacity", - " self.map = {}", - " self.head = Node() # most-recently-used sentinel", - " self.tail = Node() # least-recently-used sentinel", - " self.head.next = self.tail", - " self.tail.prev = self.head", - "", - " def _remove(self, n):", - " n.prev.next = n.next", - " n.next.prev = n.prev", - "", - " def _push_front(self, n):", - " n.next = self.head.next", - " n.prev = self.head", - " self.head.next.prev = n", - " self.head.next = n", - "", - " def get(self, key):", - " if key not in self.map:", - " return -1", - " n = self.map[key]", - " self._remove(n)", - " self._push_front(n)", - " return n.v", - "", - " def put(self, key, value):", - " if key in self.map:", - " self._remove(self.map[key])", - " n = Node(key, value)", - " self.map[key] = n", - " self._push_front(n)", - " if len(self.map) > self.cap:", - " lru = self.tail.prev", - " self._remove(lru)", - " del self.map[lru.k]", - "", - "cache = LRUCache(2)", - 'ops = [("put", 1, 1), ("put", 2, 2), ("get", 1), ("put", 3, 3),', - ' ("get", 2), ("put", 4, 4), ("get", 1), ("get", 3), ("get", 4)]', - "for op in ops:", - ' if op[0] == "put":', - " cache.put(op[1], op[2])", - ' print(f"put({op[1]}, {op[2]})")', - " else:", - ' print(f"get({op[1]}) -> {cache.get(op[1])}")', - ].join("\n"), - }, -]; diff --git a/demo/pyex-web/app/src/imports.mjs b/demo/pyex-web/app/src/imports.mjs deleted file mode 100644 index ba53e3a..0000000 --- a/demo/pyex-web/app/src/imports.mjs +++ /dev/null @@ -1,595 +0,0 @@ -// Shared host imports for compiled-Elixir WasmGC modules — the single source of truth -// so the import surfaces (big, math, str, crypto) can't drift between the various runners -// (runtime/scheduler.mjs, conformance/driver.mjs, gaps/runner.mjs, demo/*). Before this, -// each runner hand-rolled its own `str`, and they had already diverged (only some had -// re_split/re_run/titlecase/upchar), so a module compiled against the richer surface would -// LinkError under a leaner runner. -// -// `str` and `crypto` need the instance's exports (to read/write the WasmGC $binary via the -// exported bin_* helpers), but the instance is created AFTER the import object is built — the -// classic chicken-and-egg. So those factories take a getter, `getExports`, that returns the -// live exports; call them only at runtime (after instantiation), which every runner does. -// -// import { makeBig, makeMath, makeStr } from "./imports.mjs"; -// const imports = { big: makeBig(), math: makeMath(), str: makeStr(() => instance.exports) }; -// const instance = new WebAssembly.Instance(module, imports); - -// Exact arbitrary-precision integers (BIGNUM mode): the $big box wraps a host BigInt. Provided -// unconditionally — a module that doesn't import "big" simply ignores the extra import object. -export const makeBig = () => ({ - from_i64: (x) => x, from_float: (x) => BigInt(Math.trunc(x)), - from_str: (x) => BigInt(String(x)), - add: (a, b) => a + b, - sub: (a, b) => a - b, - mul: (a, b) => a * b, - div: (a, b) => a / b, - rem: (a, b) => a % b, - band: (a, b) => a & b, - bor: (a, b) => a | b, - bxor: (a, b) => a ^ b, - bsl: (a, b) => (b >= 0n ? a << b : a >> -b), - bsr: (a, b) => (b >= 0n ? a >> b : a << -b), - fits_i31: (a) => (a >= -1073741824n && a < 1073741824n ? 1 : 0), - to_i32: (a) => Number(a), - fits_i64: (a) => (a >= -9223372036854775808n && a <= 9223372036854775807n ? 1 : 0), - to_i64: (a) => BigInt.asIntN(64, a), - cmp: (a, b) => (a < b ? -1 : a > b ? 1 : 0), - to_u64: (a) => BigInt.asIntN(64, a), from_u64: (v) => BigInt.asUintN(64, v), bit_length: (a) => (a === 0n ? 0 : a.toString(2).length), - to_f64: (a) => Number(a), -}); - -// Floats: :math.* lowers to host (JS Math) imports. Provided unconditionally, like `big`. -const MATH_FNS = [ - "sin", "cos", "tan", "asin", "acos", "atan", "sqrt", "exp", "log", "log2", - "log10", "sinh", "cosh", "tanh", "ceil", "floor", "atan2", "pow", -]; -export const makeMath = () => Object.fromEntries(MATH_FNS.map((k) => [k, Math[k]])); - -const encU = new TextEncoder(); -const decU = new TextDecoder(); - -// Binary <-> JS helpers over the WasmGC $binary, via the exported bin_* helpers. `getExports` -// returns the live instance exports (resolved lazily; the instance exists by call time). -export const binCodec = (getExports) => { - const rawBytes = (b) => { - const e = getExports(); - const n = e.bin_len(b); - const u = new Uint8Array(n); - for (let i = 0; i < n; i++) u[i] = e.bin_get(b, i); - return u; - }; - const wrBytes = (u) => { - const e = getExports(); - const b = e.bin_alloc(u.length); - for (let i = 0; i < u.length; i++) e.bin_put(b, i, u[i]); - return b; - }; - const rdBin = (b) => decU.decode(rawBytes(b)); - const wrBin = (s) => wrBytes(encU.encode(s)); - return { rawBytes, wrBytes, rdBin, wrBin }; -}; - -// String/Regex host shims (genuinely Unicode-table-backed case mapping; :re via JS RegExp). -// This is the union of every runner's surface — the richest variant, so any runner can host any -// compiled module. reRun/reSplit framing matches what the compiler's bs_* match code expects. -export const makeStr = (getExports) => { - const { rdBin, wrBin, wrBytes } = binCodec(getExports); - - // PCRE -> JS RegExp translation (the documented NIF-fidelity boundary, maximized): - // - Elixir regex OPTS map to JS flags (i/m/s/u); x (extended) strips unescaped whitespace + - // #-comments outside character classes (JS has no x flag). - // - PCRE-only syntax JS rejects: (?'name'...) -> (?...); \A -> ^; \z/\Z -> $; - // \h -> [ \t]; \R -> any-newline alternation. - const pcre2js = (src, opts, extraFlags = "") => { - let flags = extraFlags; - // NB: PCRE's :unicode is deliberately NOT mapped to JS `u` — PCRE default is byte-mode and JS - // non-u mode is the closer (and escape-tolerant) semantics. - for (const f of ["i", "m", "s"]) if (opts.includes(f) && !flags.includes(f)) flags += f; - let s = src; - if (opts.includes("x")) { - let out = "", inClass = false; - for (let i = 0; i < s.length; i++) { - const c = s[i]; - if (c === "\\") { out += c + (s[i + 1] ?? ""); i++; continue; } - if (c === "[") inClass = true; - else if (c === "]") inClass = false; - if (!inClass) { - if (c === "#") { while (i < s.length && s[i] !== "\n") i++; continue; } - if (/\s/.test(c)) continue; - } - out += c; - } - s = out; - } - // \K (match-start reset) and \G (previous-match anchor) have NO JS equivalent — and JS would - // silently treat them as literal K/G (a wrong-value lie, not an error). Refuse honestly. - { - let inClass = false; - for (let i = 0; i < s.length; i++) { - const c = s[i]; - if (c === "\\") { - const n = s[i + 1]; - if (!inClass && (n === "K" || n === "G")) throw new Error(`unsupported PCRE \\${n} (no JS equivalent)`); - i++; continue; - } - if (c === "[") inClass = true; - else if (c === "]") inClass = false; - } - } - // PCRE's bare $ (and \Z) match before a FINAL newline; JS $ is absolute end. Rewrite unescaped - // $ outside character classes to (?=\n?$) in non-multiline mode (with m both engines agree). - if (!flags.includes("m")) { - let out = "", inClass = false; - for (let i = 0; i < s.length; i++) { - const c = s[i]; - if (c === "\\") { out += c + (s[i + 1] ?? ""); i++; continue; } - if (c === "[") inClass = true; - else if (c === "]") inClass = false; - out += (c === "$" && !inClass) ? "(?=\\n?$)" : c; - } - s = out; - } - s = s.replace(/\(\?'([^']+)'/g, "(?<$1>"); - s = s.replace(/\\A/g, "^").replace(/\\z/g, "$").replace(/\\Z/g, "(?=\\n?$)"); - s = s.replace(/\\h/g, "[ \\t]").replace(/\\R/g, "(?:\\r\\n|\\r|\\n)"); - s = s.replace(/\\#/g, "#").replace(/\\ /g, " "); // PCRE x-mode escapes JS rejects - // PCRE branch-reset (?|...) -> (?:...). Exact when the FIRST alternative participates (shared - // numbering); a later alternative shifts capture positions — a documented fidelity edge. - s = s.replace(/\(\?\|/g, "(?:"); - // PCRE atomic group (?>...) -> (?:...). Exact when the group's content cannot backtrack - // internally (single-token groups like (?>\n) — Earmark's usage); a documented fidelity edge - // for backtracking-sensitive patterns. - s = s.replace(/\(\?>/g, "(?:"); - return new RegExp(s, flags); - }; - // Compiled-RegExp cache: Earmark's LineScanner runs ~30 patterns per LINE, and without this - // every host call re-runs the PCRE->JS translation + `new RegExp`. lastIndex is reset on every - // hit because re_scan hands out "g" regexes whose exec loop mutates it. - const reCache = new Map(); - const jsre = (patB, optsB, extraFlags = "") => { - const key = rdBin(patB) + "\x00" + rdBin(optsB) + "\x00" + extraFlags; - let re = reCache.get(key); - if (!re) { - re = pcre2js(rdBin(patB), rdBin(optsB), extraFlags); - if (reCache.size < 4096) reCache.set(key, re); - } - re.lastIndex = 0; - return re; - }; - - // Regex.split via an exec loop — NOT JS .split(), which (a) injects capture-group text into the - // result and (b) drops the leading/trailing empty parts :re.split keeps. `partsLimit` (0 = - // unlimited) caps the part count with the remainder UNSPLIT (Regex.split parts:); `incCaps` - // interleaves the matched text (Regex.split include_captures:). Frame parts as - // <> big-endian. - const re_split = (patB, optsB, subjB, partsLimit = 0, incCaps = 0) => { - const re = jsre(patB, optsB, "g"); - const s = rdBin(subjB); - const parts = []; - let last = 0, m; - while ((m = re.exec(s)) !== null) { - if (partsLimit > 0 && parts.length >= (incCaps ? 2 : 1) * (partsLimit - 1)) break; - parts.push(s.slice(last, m.index)); - if (incCaps) parts.push(m[0]); - last = m.index + m[0].length; - if (m[0] === "") re.lastIndex++; // zero-width match: step forward - } - parts.push(s.slice(last)); - const chunks = parts.map((p) => encU.encode(p)); - const total = 4 + chunks.reduce((s, c) => s + 4 + c.length, 0); - const buf = new Uint8Array(total); - const dv = new DataView(buf.buffer); - dv.setUint32(0, chunks.length); - let o = 4; - for (const c of chunks) { - dv.setUint32(o, c.length); - o += 4; - buf.set(c, o); - o += c.length; - } - return wrBytes(buf); - }; - - // Regex.run -> JS .match. Frame: <>. Trailing - // non-participating groups are dropped; remaining undefined groups become empty strings - // (matches Erlang :re.run / Regex.run semantics). - const re_run = (patB, optsB, subjB) => { - const m = rdBin(subjB).match(jsre(patB, optsB)); - if (!m) return wrBytes(new Uint8Array([0])); - const caps = Array.from(m); - while (caps.length > 1 && caps[caps.length - 1] === undefined) caps.pop(); - const enc = caps.map((c) => encU.encode(c === undefined ? "" : c)); - const total = 5 + enc.reduce((s, c) => s + 4 + c.length, 0); - const buf = new Uint8Array(total); - const dv = new DataView(buf.buffer); - buf[0] = 1; - dv.setUint32(1, enc.length); - let o = 5; - for (const c of enc) { - dv.setUint32(o, c.length); - o += 4; - buf.set(c, o); - o += c.length; - } - return wrBytes(buf); - }; - - // Regex.run(re, subj, return: :index): match positions as BYTE offsets. Frame: - // <> for [full_match, captures...]; non-participating - // group -> (0xFFFFFFFF, 0) so the WAT can emit {-1,0} like :re. No match -> <<0>>. - const re_run_index = (patB, optsB, subjB) => { - const subj = rdBin(subjB); - const m = jsre(patB, optsB, "d").exec(subj); - if (!m) return wrBytes(new Uint8Array([0])); - const blen = (s) => encU.encode(s).length; // UTF-16 index -> byte offset - let idx = Array.from(m.indices); - while (idx.length > 1 && idx[idx.length - 1] === undefined) idx.pop(); - const buf = new Uint8Array(5 + idx.length * 8); - const dv = new DataView(buf.buffer); - buf[0] = 1; - dv.setUint32(1, idx.length); - let o = 5; - for (const gi of idx) { - if (gi === undefined) { dv.setUint32(o, 0xffffffff); dv.setUint32(o + 4, 0); } - else { const s = blen(subj.slice(0, gi[0])); dv.setUint32(o, s); dv.setUint32(o + 4, blen(subj.slice(0, gi[1])) - s); } - o += 8; - } - return wrBytes(buf); - }; - - // Regex.replace(re, subj, replacement) with a STRING replacement (global). Convert Elixir replacement - // syntax to JS: \0 -> whole match, \N -> capture N, literal $ -> $$ (so JS doesn't reinterpret it). - const elixirReplToJs = (r) => { - let out = ""; - for (let i = 0; i < r.length; i++) { - const c = r[i]; - if (c === "$") out += "$$"; - else if (c === "\\" && i + 1 < r.length) { - const n = r[i + 1]; - if (n === "0") { out += "$&"; i++; } - else if (n >= "1" && n <= "9") { out += "$" + n; i++; } - else if (n === "\\") { out += "\\"; i++; } - else out += c; - } else out += c; - } - return out; - }; - const re_replace = (patB, optsB, subjB, replB, global) => - wrBin(rdBin(subjB).replace(jsre(patB, optsB, global ? "g" : ""), elixirReplToJs(rdBin(replB)))); - - // Regex.replace with a FUNCTION replacement: per match, call back into the module's exported - // re_fun_call (which dispatches on the closure's arity: fn(match) or fn(match, cap1)). - const re_replace_fun = (patB, optsB, subjB, funRef, global) => { - const re = jsre(patB, optsB, global ? "g" : ""); - const ncaps = new RegExp(re.source + "|").exec("").length - 1; - const out = rdBin(subjB).replace(re, (...args) => { - const m = args[0]; - const cap1 = ncaps >= 1 && args[1] !== undefined ? args[1] : ""; - return rdBin(getExports().re_fun_call(funRef, wrBin(m), wrBin(cap1), ncaps)); - }); - return wrBin(out); - }; - - // Regex.match?/2 -> boolean i32. - const re_test = (patB, optsB, subjB) => (jsre(patB, optsB).test(rdBin(subjB)) ? 1 : 0); - - // Regex.scan/2: ALL matches. Frame: <>; each match - // emits [full, caps...] with a non-participating group as "" (Regex.scan semantics, unlike run's nil). - const re_scan = (patB, optsB, subjB) => { - const re = jsre(patB, optsB, "g"); - const s = rdBin(subjB); - const matches = []; - let m; - while ((m = re.exec(s)) !== null) { - matches.push(Array.from(m, (c) => (c === undefined ? "" : c))); - if (m[0] === "") re.lastIndex++; // avoid infinite loop on empty matches - } - const enc = matches.map((caps) => caps.map((c) => encU.encode(c))); - const total = 4 + enc.reduce((s1, caps) => s1 + 4 + caps.reduce((s2, c) => s2 + 4 + c.length, 0), 0); - const buf = new Uint8Array(total); - const dv = new DataView(buf.buffer); - dv.setUint32(0, enc.length); - let o = 4; - for (const caps of enc) { - dv.setUint32(o, caps.length); - o += 4; - for (const c of caps) { dv.setUint32(o, c.length); o += 4; buf.set(c, o); o += c.length; } - } - return wrBytes(buf); - }; - - // Regex.named_captures/2: all NAMED groups of the first match, non-participating -> "". - // Frame: <> big-endian. - const re_named = (patB, optsB, subjB) => { - const m = rdBin(subjB).match(jsre(patB, optsB)); - if (!m) return wrBytes(new Uint8Array([0])); - const pairs = Object.entries(m.groups ?? {}).map(([k, v]) => [encU.encode(k), encU.encode(v ?? "")]); - const total = 5 + pairs.reduce((s, [k, v]) => s + 8 + k.length + v.length, 0); - const buf = new Uint8Array(total); - const dv = new DataView(buf.buffer); - buf[0] = 1; - dv.setUint32(1, pairs.length); - let o = 5; - for (const [k, v] of pairs) { - dv.setUint32(o, k.length); o += 4; buf.set(k, o); o += k.length; - dv.setUint32(o, v.length); o += 4; buf.set(v, o); o += v.length; - } - return wrBytes(buf); - }; - - // Regex.escape/1 — Elixir's exact escape set: regex metachars, backslash, and whitespace, - // each prefixed with a backslash (the whitespace char itself is kept, prefixed). - const re_escape = (b) => wrBin(rdBin(b).replace(/[.^$*+?()[\]{}|#\\\s-]/g, (c) => "\\" + c)); - - // erlang.binary_to_float/1: both engines are correctly-rounded decimal->double, so Number() - // is exact. The BEAM rejects non-float syntax with badarg; mirror with an honest throw. - const bin_to_float = (b) => { - const s = rdBin(b); - if (!/^[+-]?\d+\.\d+([eE][+-]?\d+)?$/.test(s)) throw new Error("badarg: binary_to_float " + JSON.stringify(s.slice(0, 30))); - return Number(s); - }; - - return { - upcase: (b) => wrBin(rdBin(b).toUpperCase()), - bin_to_float, - downcase: (b) => wrBin(rdBin(b).toLowerCase()), - titlecase: (b) => { - const s = rdBin(b); - return wrBin(s.length ? s[0].toUpperCase() + s.slice(1) : s); - }, - upchar: (cp) => String.fromCodePoint(cp).toUpperCase().codePointAt(0), - re_split, - re_run, - re_run_index, - re_replace, - re_replace_fun, - re_test, - re_scan, - re_escape, - re_named, - // :unicode NF* normalization — JS .normalize uses the same Unicode tables - nfc: (b) => wrBin(rdBin(b).normalize("NFC")), - nfd: (b) => wrBin(rdBin(b).normalize("NFD")), - nfkc: (b) => wrBin(rdBin(b).normalize("NFKC")), - nfkd: (b) => wrBin(rdBin(b).normalize("NFKD")), - // Erlang float_to_binary(F, [:short]): the shortest round-trip DIGITS are unique (Ryu), and JS - // produces the same ones — only the formatting convention differs. Empirically-derived Erlang - // rule (validated by differential fuzz): plain iff -3 <= dp <= 15 and dp - len(digits) <= 2, - // else scientific d.ddd e(dp-1). Plain always keeps >= 1 fractional digit ("100.0"). - // Erlang float_to_binary. mode 0 = [:short]; 1 = default (20-digit scientific, e+NN); - // 2 = {:decimals, dec}; 3 = decimals + :compact (strip trailing zeros, keep >= 1). - // For :short: the shortest round-trip DIGITS are unique (Ryu) and JS produces the same ones — - // only formatting differs. Empirically-derived Erlang rule (validated by differential fuzz): - // plain iff -3 <= dp <= 15 and dp - len(digits) <= 2, else scientific d.ddd e(dp-1). - flt_fmt: (f, mode, dec) => { - if (mode === 1) { - const [m, e] = Math.abs(f).toExponential(20).split("e"); - const exp = Number(e); - const es = (exp < 0 ? "-" : "+") + String(Math.abs(exp)).padStart(2, "0"); - return wrBin((f < 0 || Object.is(f, -0) ? "-" : "") + m + "e" + es); - } - if (mode === 2 || mode === 3) { - let out = Math.abs(f).toFixed(dec); - if (mode === 3 && out.includes(".")) out = out.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ".0"); - if (mode === 3 && !out.includes(".")) out = out + ".0"; - return wrBin((f < 0 || Object.is(f, -0) ? "-" : "") + out); - } - // mode 0: :short - if (f === 0) return wrBin(Object.is(f, -0) ? "-0.0" : "0.0"); - const neg = f < 0 ? "-" : ""; - const s = String(Math.abs(f)); - let digits, dp; - if (s.includes("e")) { - const [m, e] = s.split("e"); - digits = m.replace(".", ""); - dp = Number(e) + (m.indexOf(".") === -1 ? m.length : m.indexOf(".")); - } else { - const i = s.indexOf("."); - if (i === -1) { dp = s.length; digits = s; } - else { - const ip = s.slice(0, i), fp = s.slice(i + 1); - if (ip === "0") { const z = (fp.match(/^0*/) || [""])[0].length; digits = fp.slice(z); dp = -z; } - else { digits = ip + fp; dp = ip.length; } - } - } - digits = digits.replace(/0+$/, "") || "0"; - const len = digits.length; - // Erlang picks the SHORTER of plain vs scientific (plain wins ties), and never goes - // plain at or above 2^53 (where doubles stop being integer-exact: 9007199254740991.0 - // is plain, 9007199254740992.0 is 9.007199254740992e15). Derived from a 438-point - // (len, dp) sweep + 200k bit-pattern fuzz of float_to_binary/2 on OTP 27 — the old - // "dp >= -3 -> plain" rule mis-rendered e.g. 2.07e-4 as 0.000207 (caught by the - // rebalancer's structured megafuzz). - const plainLen = dp >= len ? dp + 2 : dp > 0 ? len + 1 : 2 - dp + len; - const sciLen = (len === 1 ? 3 : len + 1) + 1 + String(dp - 1).length; - let out; - if (Math.abs(f) < 9007199254740992 && plainLen <= sciLen) { - if (dp <= 0) out = "0." + "0".repeat(-dp) + digits; - else if (dp >= len) out = digits + "0".repeat(dp - len) + ".0"; - else out = digits.slice(0, dp) + "." + digits.slice(dp); - } else { - out = digits[0] + "." + (digits.slice(1) || "0") + "e" + (dp - 1); - } - return wrBin(neg + out); - }, - }; -}; - -// :crypto.hash NIF -> real digest via node's crypto (OpenSSL). `nodeCrypto` is injected so this -// module stays free of node-only imports for runners that don't need crypto. -const NODE_ALGO = { sha: "sha1", sha224: "sha224", sha256: "sha256", sha384: "sha384", sha512: "sha512", md5: "md5" }; -export const makeCrypto = (getExports, nodeCrypto) => { - const { rawBytes, wrBytes } = binCodec(getExports); - return { - hash: (algoB, dataB) => { - const algo = decU.decode(rawBytes(algoB)); - const d = nodeCrypto.createHash(NODE_ALGO[algo] || algo).update(Buffer.from(rawBytes(dataB))).digest(); - return wrBytes(new Uint8Array(d)); - }, - }; -}; - -// ── The effects ABI: IO (file, console) handed back to the HOST ────────────────────────────── -// The host decides the backing: real fs on Node, a VIRTUAL filesystem (in-memory Map, or KV/R2/DO -// on Workers). An unwired effect traps honestly. Frames: fs_read -> <<1, bytes...>> (ok) or -// <<0, errcode>> (1=enoent, 2=eacces, 3=eio); fs_write -> errcode i32 (0 = ok). - -// In-memory virtual filesystem backing: `files` is a Map. -export const memFsBacking = (files = new Map()) => ({ - read: (path) => { - if (!files.has(path)) return { err: 1 }; - const v = files.get(path); - return typeof v === "string" ? encU.encode(v) : v; - }, - write: (path, bytes) => { files.set(path, bytes); return 0; }, - files, -}); - -// Real-filesystem backing (Node host). `nodeFs` = require("node:fs") injected by the runner. -// ⚠ SECURITY: grants the compiled module FULL host filesystem authority — the path comes -// straight from guest code with NO root confinement, `..` normalization, or allowlist. -// Wire this ONLY to trusted modules. For untrusted code use memFsBacking, or wrap this with -// your own realpath-confinement to a fixed prefix. See SECURITY.md ("Capability model"). -export const nodeFsBacking = (nodeFs) => ({ - read: (path) => { - try { return new Uint8Array(nodeFs.readFileSync(path)); } - catch (e) { return { err: e.code === "ENOENT" ? 1 : e.code === "EACCES" ? 2 : 3 }; } - }, - write: (path, bytes) => { - try { nodeFs.writeFileSync(path, bytes); return 0; } - catch (e) { return e.code === "EACCES" ? 2 : 3; } - }, -}); - -// ── terms across the boundary: walk a RETURNED WasmGC term graph into a live JS value. -// Built on the introspection exports (is_int/int_val, is_float/float_val, is_bin/bin_*, -// is_cons/head_term/tail, is_tuple/tup_*, is_map/map_kv, is_atom/atom_name). Conventions: -// [] (null ref) -> [] atoms true/false/nil -> true/false/null -// other atom -> ":name" (via the atom_name export when the build carries the name table, -// else ":idxN") tuple -> JS array map -> object (keys stringified) -// int -> Number when safe, else decimal string (exact bignums survive JSON) -export const termToJs = (e, v) => { - const dec = new TextDecoder(); - const rdBin = (b) => { - const n = e.bin_len(b); - const u = new Uint8Array(n); - for (let i = 0; i < n; i++) u[i] = e.bin_get(b, i); - return dec.decode(u); - }; - const atomName = (t) => (e.atom_name ? rdBin(e.atom_name(t)) : "idx" + e.atom_idx(t)); - const walk = (t) => { - if (t === null) return []; - if (e.is_atom(t)) { - const name = atomName(t); - if (name === "true") return true; - if (name === "false") return false; - if (name === "nil") return null; - return ":" + name; - } - if (e.is_int(t)) { - if (e.int_val) { - const b = e.int_val(t); // BigInt, exact at any size - return b >= -9007199254740991n && b <= 9007199254740991n ? Number(b) : String(b); - } - return e.get_int(t); - } - if (e.is_float && e.is_float(t)) return e.float_val(t); - if (e.is_bin(t)) return rdBin(t); - if (e.is_cons(t)) { - const out = []; - let l = t; - while (l !== null && e.is_cons(l)) { out.push(walk(e.head_term(l))); l = e.tail(l); } - return out; - } - if (e.is_map && e.is_map(t)) { - const kv = e.map_kv(t); // interleaved [k0, v0, k1, v1, ...] - const n = e.tup_len(kv); - const obj = {}; - for (let i = 0; i < n; i += 2) { - const k = walk(e.tup_get(kv, i)); - obj[typeof k === "string" ? k : JSON.stringify(k)] = walk(e.tup_get(kv, i + 1)); - } - return obj; - } - if (e.is_tuple(t)) { - const n = e.tup_len(t); - const out = []; - for (let i = 0; i < n; i++) out.push(walk(e.tup_get(t, i))); - return out; - } - return "#opaque"; // funs/pids/refs: no JSON shape - }; - return walk(v); -}; - -// ── SQL as a host effect (:sql_host.exec/2): SQL text + JSON params in, JSON rows out. -// The backing decides the engine: node:sqlite locally, the Durable Object's synchronous -// ctx.storage.sql in production. A SQL error throws -> an honest trap with the message. -export const makeSql = (getExports, backing) => { - const { rdBin, wrBin } = binCodec(getExports); - return { exec: (qB, pB) => wrBin(backing(rdBin(qB), rdBin(pB))) }; -}; - -// node:sqlite backing — `db` is a DatabaseSync; .all() executes ANY statement and returns -// its rows ([] for non-returning statements), so one path covers DDL/DML/SELECT. -// ⚠ SECURITY: executes ANY statement the guest emits (incl. DDL / PRAGMA / ATTACH) against the -// given database — no statement allowlist or read-only mode. The values are parameterized (no -// injection via params), but the SQL text itself is guest-controlled. Wire only to trusted -// modules, or give an isolated/read-only DB. Same caveat applies to doSqliteBacking below. -export const nodeSqliteBacking = (db) => (sql, paramsJson) => - JSON.stringify(db.prepare(sql).all(...JSON.parse(paramsJson))); - -// Durable Object backing — `sqlStorage` is this.ctx.storage.sql (synchronous in DOs). -export const doSqliteBacking = (sqlStorage) => (sql, paramsJson) => - JSON.stringify(sqlStorage.exec(sql, ...JSON.parse(paramsJson)).toArray()); - -export const makeFs = (getExports, backing) => { - const { rawBytes, wrBytes, rdBin } = binCodec(getExports); - return { - read_file: (pathB) => { - const r = backing.read(rdBin(pathB)); - if (r.err) return wrBytes(new Uint8Array([0, r.err])); - const buf = new Uint8Array(1 + r.length); - buf[0] = 1; buf.set(r, 1); - return wrBytes(buf); - }, - write_file: (pathB, dataB) => backing.write(rdBin(pathB), rawBytes(dataB)), - }; -}; - -// Console IO. `sink` collects lines (for differential capture); default = real console. -export const makeIo = (getExports, sink = null) => { - const { rdBin } = binCodec(getExports); - const emit = (s, warn) => { if (sink) sink.push(s); else (warn ? console.error : console.log)(s); }; - return { - puts: (b) => { emit(rdBin(b), false); return 0; }, - warn: (b) => { emit(rdBin(b), true); return 0; }, - }; -}; - -// Benign proc/sched stubs for runners that keep GenServer/Finch code alive via DCE but never -// execute it (the demo overrides the transport adapter). The REAL scheduler lives in -// runtime/scheduler.mjs; do not use these there. -// Wall clock as a host effect: :os.system_time/0 -> nanoseconds since the epoch (i64). Deterministic -// alternative: pass a fixed `nowNs` for reproducible turns (agent replay). -export const makeSys = (nowNs = null) => ({ - now: () => (nowNs != null ? BigInt(nowNs) : BigInt(Date.now()) * 1_000_000n), -}); - -export const makeProcStubs = () => { - const pdict = new Map(); - const proc = { - spawn: () => 999, spawn_link: () => 999, spawn_opt: () => 999, - send: (_p, m) => m, self: () => 1, - recv_has: () => 0, recv_cur: () => null, recv_remove: () => {}, recv_advance: () => {}, recv_wait: () => {}, recv_wait_timeout: () => 0, - exit: () => {}, exit2: () => {}, set_trap_exit: () => {}, register: () => {}, whereis: () => 0, - monitor: () => 1, demonitor: () => {}, alias_pid: (p) => p, - // OTP 29 gen:call timeout timers. In these single-shot stub contexts nothing fires, so a timer is - // inert: start returns a ref id, cancel is a no-op. (The real firing lives in scheduler.mjs.) - start_timer: () => 999, cancel_timer: () => 0, - pdict_get: (k) => (pdict.has(k) ? pdict.get(k) : null), - pdict_put: (k, v) => { const old = pdict.has(k) ? pdict.get(k) : null; pdict.set(k, v); return old; }, - }; - const sched = { yield: () => {} }; - return { proc, sched }; -}; diff --git a/demo/pyex-web/app/src/index.css b/demo/pyex-web/app/src/index.css deleted file mode 100644 index 51cf15b..0000000 --- a/demo/pyex-web/app/src/index.css +++ /dev/null @@ -1,39 +0,0 @@ -@import "tailwindcss"; - -@theme { - --color-bg: #08090c; - --color-surface: #0c0d11; - --color-surface2: #111319; - --color-elevate: #161821; - --color-line: #1c1f28; - --color-line2: #262a35; - --color-fg: #ededf0; - --color-muted: #8b909a; - --color-faint: #565b66; - --color-accent: #7c74ff; - --color-accent2: #9a8bff; - --color-grn: #5ce08a; - --color-red: #ff6b64; - --color-yel: #e3b341; - --font-mono: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace; - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif; -} - -html, body, #root { height: 100%; } -body { - margin: 0; background: - radial-gradient(900px 500px at 88% -8%, rgba(124,116,255,.10), transparent 60%), - radial-gradient(700px 500px at 8% 108%, rgba(92,224,138,.05), transparent 55%), - var(--color-bg); - color: var(--color-fg); font-family: var(--font-sans); - -webkit-font-smoothing: antialiased; letter-spacing: -.01em; overscroll-behavior: none; -} -* { -webkit-tap-highlight-color: transparent; } -::-webkit-scrollbar { width: 9px; height: 9px; } -::-webkit-scrollbar-thumb { background: var(--color-line2); border-radius: 6px; border: 2px solid transparent; background-clip: padding-box; } -::selection { background: rgba(124,116,255,.3); } -.cm-editor { background: transparent !important; height: 100%; } -.cm-editor .cm-scroller { font-family: var(--font-mono) !important; font-size: 13px; } -.cm-editor .cm-gutters { background: transparent !important; border: none !important; color: var(--color-faint) !important; } -.cm-editor.cm-focused { outline: none !important; } -@media (max-width: 860px) { .cm-editor .cm-scroller { font-size: 15px; } } diff --git a/demo/pyex-web/app/src/lib/pyex.ts b/demo/pyex-web/app/src/lib/pyex.ts deleted file mode 100644 index 4cb074a..0000000 --- a/demo/pyex-web/app/src/lib/pyex.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Client-side handle to the pyex Web Worker: compile-once, run-many, with a watchdog. -export type Footprint = Record; -export type Span = { - id: number; parent_id: number | null; name: string; scope: string; kind: string; - attributes: Record; start_seq: number; end_seq: number | null; -}; -export type Files = Record; -export type RunResult = - | { ok: true; stdout: string; footprint: Footprint; files: Files; spans: Span[]; ms: number } - | { ok: false; error: string; ms: number }; - -type Listener = { - onReady: (info: { sizeMB: number; ms: number }) => void; - onBootError: (msg: string) => void; - onResult: (id: number, r: RunResult) => void; -}; - -export class Pyex { - private worker!: Worker; - private ready = false; - private runId = 0; - private latest = 0; - private watchdog: number | undefined; - constructor(private l: Listener) { this.spawn(); } - - private spawn() { - this.worker = new Worker(new URL("../pyex.worker.ts", import.meta.url), { type: "module" }); - this.worker.onmessage = (ev: MessageEvent) => { - const d = ev.data; - if (d.type === "ready") { this.ready = true; this.l.onReady(d); } - else if (d.type === "boot-error") this.l.onBootError(d.message); - else if (d.type === "result") { - clearTimeout(this.watchdog); - if (d.id !== this.latest) return; - const r: RunResult = d.ok - ? { ok: true, stdout: d.stdout, footprint: d.footprint, files: d.files, spans: d.spans, ms: d.ms } - : { ok: false, error: d.error, ms: d.ms }; - this.l.onResult(d.id, r); - } - }; - } - - get isReady() { return this.ready; } - - run(code: string, files: Files, maxSteps = 2_000_000): number { - const id = ++this.runId; this.latest = id; - this.worker.postMessage({ id, code, filesJson: JSON.stringify(files), maxSteps }); - clearTimeout(this.watchdog); - this.watchdog = self.setTimeout(() => { - this.worker.terminate(); this.ready = false; - this.l.onResult(id, { ok: false, error: "run timed out — restarting the interpreter…", ms: 0 }); - this.spawn(); - }, 6000); - return id; - } -} diff --git a/demo/pyex-web/app/src/main.tsx b/demo/pyex-web/app/src/main.tsx deleted file mode 100644 index 07911f0..0000000 --- a/demo/pyex-web/app/src/main.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import "./index.css"; -import { App } from "./App"; - -createRoot(document.getElementById("root")!).render( - -); diff --git a/demo/pyex-web/app/src/pyex.worker.ts b/demo/pyex-web/app/src/pyex.worker.ts deleted file mode 100644 index f71e0ff..0000000 --- a/demo/pyex-web/app/src/pyex.worker.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Web Worker: loads the pyex WasmGC interpreter and runs Python off the main thread. -// @ts-ignore — imports.mjs is untyped host glue. -import { - makeBig, makeMath, makeStr, makeFs, makeIo, makeCrypto, makeProcStubs, makeSys, memFsBacking, termToJs, - // @ts-ignore -} from "./imports.mjs"; - -type RunMsg = { id: number; code: string; filesJson: string; maxSteps: number }; - -let e: any, ready = false; -const cryptoStub = { createHash: () => { throw new Error("hashlib not wired in this demo"); } }; -const { proc, sched } = makeProcStubs(); -const enc = new TextEncoder(); - -const WASM_URL = "/pyex.wasm?v=" + ((import.meta as any).env?.VITE_WASM_V || "dev"); - -(async () => { - try { - const t0 = performance.now(); - const bytes = await fetch(WASM_URL).then((r) => r.arrayBuffer()); - e = (await WebAssembly.instantiate(await WebAssembly.compile(bytes), { - big: makeBig(), math: makeMath(), str: makeStr(() => e), - crypto: makeCrypto(() => e, cryptoStub), sys: makeSys(), - fs: makeFs(() => e, memFsBacking()), io: makeIo(() => e, [] as any), - proc, sched, - })).exports; - ready = true; - postMessage({ type: "ready", sizeMB: bytes.byteLength / 1048576, ms: performance.now() - t0 }); - } catch (err) { - postMessage({ type: "boot-error", message: String(err) }); - } -})(); - -const bin = (s: string) => { - const u = enc.encode(s), b = e.bin_alloc(u.length); - for (let i = 0; i < u.length; i++) e.bin_put(b, i, u[i]); - return b; -}; - -onmessage = (ev: MessageEvent) => { - const { id, code, filesJson, maxSteps } = ev.data; - if (!ready) return; - const t0 = performance.now(); - let res: any, err: string | null = null; - try { res = termToJs(e, e.pyrun(bin(code), bin(filesJson), maxSteps)); } - catch (ex: any) { - err = (e.exc && ex instanceof (WebAssembly as any).Exception && ex.is(e.exc)) ? "uncaught Elixir exception" : String(ex); - } - const ms = performance.now() - t0; - - if (err) { - // A wasm trap (e.g. a Python corner the interpreter doesn't lower yet) can't be caught as an - // Elixir error — surface it honestly instead of leaking a raw RuntimeError stack. - const friendly = /unreachable|Elixir exception/.test(err) - ? "This program hit a corner of Python the sandbox doesn't support yet." - : "host error: " + err; - postMessage({ type: "result", id, ok: false, error: friendly, ms }); - return; - } - if (Array.isArray(res) && res[0] === ":ok") { - postMessage({ - type: "result", id, ok: true, ms, - stdout: res[1] || "", - footprint: res[2] || {}, - files: safeParse(res[3], {}), - spans: safeParse(res[4], []), - }); - } else { - postMessage({ type: "result", id, ok: false, ms, error: "Traceback (pyex):\n" + (Array.isArray(res) ? res[1] : String(res)) }); - } -}; - -function safeParse(s: any, fallback: any) { try { return JSON.parse(s); } catch { return fallback; } } diff --git a/demo/pyex-web/app/tsconfig.json b/demo/pyex-web/app/tsconfig.json deleted file mode 100644 index 549fe6f..0000000 --- a/demo/pyex-web/app/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], - "module": "ESNext", - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "allowJs": true - }, - "include": ["src"] -} diff --git a/demo/pyex-web/app/vite.config.ts b/demo/pyex-web/app/vite.config.ts deleted file mode 100644 index a34f40f..0000000 --- a/demo/pyex-web/app/vite.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -import tailwindcss from "@tailwindcss/vite"; -import { resolve } from "node:path"; - -export default defineConfig({ - plugins: [react(), tailwindcss()], - build: { - target: "es2022", - outDir: "dist", - rollupOptions: { - // MPA: / is the static landing page (runs Python via /api/run — no wasm - // download), /play/ is the full in-browser playground. - input: { - landing: resolve(__dirname, "index.html"), - play: resolve(__dirname, "play/index.html"), - }, - }, - }, - worker: { format: "es" }, -}); diff --git a/demo/pyex-web/deploy.sh b/demo/pyex-web/deploy.sh deleted file mode 100755 index 4958fe4..0000000 --- a/demo/pyex-web/deploy.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -# -# One-command deploy for pyex.dev — keeps the wasm's TWO homes in sync: -# R2 (browsers fetch /pyex.wasm at runtime) and worker/pyex.wasm (bundled module -# binding for /api/run — workerd forbids runtime WebAssembly.compile). -# -# ./deploy.sh [path/to/pyex.wasm] # default: ../../../pyex/wasm/pyex.wasm -# -# Ends by validating PRODUCTION: /api/health, the mobile-UX rig, and the lru example. -set -euo pipefail - -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WASM="${1:-$HERE/../../../pyex/wasm/pyex.wasm}" -[ -f "$WASM" ] || { echo "no wasm at $WASM — build one with \`mix wasm.build\` in pyex"; exit 1; } - -# content-addressed cache-buster: same bytes -> same URL -> immutable cache stays valid -V="$(shasum -a 256 "$WASM" | cut -c1-12)" -echo "==> deploying pyex.wasm $V ($(du -h "$WASM" | cut -f1))" - -cp "$WASM" "$HERE/worker/pyex.wasm" -(cd "$HERE/worker" && npx wrangler r2 object put pyex-wasm/pyex.wasm --file "$WASM" --remote) -(cd "$HERE/app" && VITE_WASM_V="$V" npm run build) -(cd "$HERE/worker" && npx wrangler deploy) - -echo "==> validating production" -curl -sf https://pyex.dev/api/health | grep -q '"ok":true' && echo "api/health: ok" -curl -sf https://pyex.dev/ | grep -q "as a function call" && echo "landing: ok" -curl -sfI https://pyex.dev/og.png | grep -qi "image/png" && echo "og.png: ok" -curl -sf -X POST https://pyex.dev/api/run -H 'content-type: text/plain' -d 'print(1+1)' | grep -q '"stdout":"2' && echo "api/run: ok" -(cd "$HERE/app" && PYEX_URL=https://pyex.dev/ npm run check:mobile) -(cd "$HERE/app" && PYEX_URL=https://pyex.dev/ node scripts/lru-check.mjs) -echo "==> deployed + validated: https://pyex.dev" diff --git a/demo/pyex-web/worker/.gitignore b/demo/pyex-web/worker/.gitignore deleted file mode 100644 index 6d90b4c..0000000 --- a/demo/pyex-web/worker/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -.wrangler/ -package-lock.json diff --git a/demo/pyex-web/worker/worker.mjs b/demo/pyex-web/worker/worker.mjs deleted file mode 100644 index deb8b56..0000000 --- a/demo/pyex-web/worker/worker.mjs +++ /dev/null @@ -1,172 +0,0 @@ -// pyex.dev — the Python-on-WasmGC demo, browser AND server. -// / → index.html (static asset; Python runs in the visitor's browser) -// /pyex.wasm → the interpreter, streamed from R2 for the browser. Served RAW — the edge -// compresses it once over the wire (~1.7 MB brotli). NB: do NOT pre-compress + -// set Content-Encoding here — the edge then double-compresses (advertising only -// one layer), and the browser hands WebAssembly.compile still-compressed bytes. -// POST /api/run → run Python IN THE WORKER: the same wasm, bundled as a module binding -// (workerd forbids runtime WebAssembly.compile, so the API can't reuse the R2 -// copy — keep worker/pyex.wasm in sync with the R2 object when deploying). -// Body: {"code": "...", "files": {"/path": "content"}, "max_steps": 5000000} -// or text/plain = raw Python. Reply mirrors the browser worker's result shape: -// {ok, ms, stdout, files, footprint, spans} | {ok: false, ms, error}. -// GET /api/health → boots the interpreter and runs a 1-liner. -// -// One instance per isolate, booted lazily on the first API hit (asset/wasm requests never pay it). -// Each pyrun call builds a fresh interpreter Ctx + VFS seeded from `files`, so requests share -// nothing but the compiled module. A runaway program burns its step budget and returns a clean -// Python error; the platform CPU cap is the hard backstop behind that. -import pyexModule from "./pyex.wasm"; -import { - makeBig, makeMath, makeStr, makeFs, makeIo, makeCrypto, makeProcStubs, makeSys, - memFsBacking, termToJs, -} from "../app/src/imports.mjs"; - -const KEY = "pyex.wasm"; -const MAX_CODE = 65_536; // the lexer's per-char recursion depth is the platform stack bound -const MAX_FILES_JSON = 1_048_576; -// V8 cannot collect WasmGC garbage while a synchronous wasm call is on the stack, so the -// interpreter's per-step allocations accumulate for the whole pyrun. The death line is -// SHAPE-dependent (scripts/calibrate.mjs sweeps it): most loops survive 3M+ steps, but a -// list-append loop kills the 128 MB isolate at ~60k steps (~6 KB of uncollectable garbage -// per append — a quadratic-copy lowering artifact). The cap sits 2x under the WORST -// shape's prod line so a runaway ALWAYS ends in pyex's own LimitError (tens of ms): the sandbox -// refusing, not the platform collapsing. For scale: the heaviest playground example needs -// 471 steps — 30k is ~60x headroom for demo-sized programs. -const DEFAULT_STEPS = 8_000; -const MAX_STEPS = 10_000; -const LIMIT_HINT = - "this public endpoint caps runs at a small deterministic step budget; " + - "the playground at pyex.dev/play runs bigger budgets in your browser, and the " + - "Elixir library (hex.pm/packages/pyex) takes whatever limits you hand it"; - -const CORS = { - "access-control-allow-origin": "*", - "access-control-allow-methods": "POST, GET, OPTIONS", - "access-control-allow-headers": "content-type", -}; - -// Boot LAZILY on the first API request. Instantiating the 12 MB WasmGC module at -// module scope exceeds workerd's startup budget and kills every worker-handled route -// (tried; the deploy passed but all /api/* and /pyex.wasm requests died with no logs). -// The cost of lazy boot: a cold isolate's first /api request pays instantiation + -// builtins build, which can trip 1102 on tight CPU plans — clients should retry once; -// the retry lands warm. -let e = null; -const enc = new TextEncoder(); - -function boot() { - if (e) return e; - const { proc, sched } = makeProcStubs(); - e = new WebAssembly.Instance(pyexModule, { - big: makeBig(), - math: makeMath(), - str: makeStr(() => e), - crypto: makeCrypto(() => e, { createHash: () => { throw new Error("hashlib not wired in this host"); } }), - sys: makeSys(), - fs: makeFs(() => e, memFsBacking()), - io: makeIo(() => e, []), - proc, sched, - }).exports; - return e; -} - -const bin = (s) => { - const u = enc.encode(s), b = e.bin_alloc(u.length); - for (let i = 0; i < u.length; i++) e.bin_put(b, i, u[i]); - return b; -}; - -const safeParse = (s, fallback) => { try { return JSON.parse(s); } catch { return fallback; } }; -const json = (obj, status = 200) => - new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json", ...CORS } }); - -function runPython(code, files, maxSteps) { - boot(); - const t0 = Date.now(); - let res; - try { - res = termToJs(e, e.pyrun(bin(code), bin(JSON.stringify(files)), maxSteps)); - } catch (ex) { - // A wasm trap (a Python corner the compiler doesn't lower yet) can't be caught as an Elixir - // error — surface it honestly instead of leaking a raw RuntimeError stack. - const raw = e.exc && ex instanceof WebAssembly.Exception && ex.is(e.exc) ? "uncaught Elixir exception" : String(ex); - const friendly = /unreachable|Elixir exception/.test(raw) - ? "this program hit a corner of Python the sandbox doesn't support yet" - : "host error: " + raw; - return { ok: false, ms: Date.now() - t0, error: friendly }; - } - const ms = Date.now() - t0; - if (Array.isArray(res) && res[0] === ":ok") { - return { - ok: true, ms, - stdout: res[1] || "", - footprint: res[2] || {}, - files: safeParse(res[3], {}), - spans: safeParse(res[4], []), - }; - } - const error = Array.isArray(res) ? res[1] : String(res); - const out = { ok: false, ms, error }; - if (/LimitError: step limit/.test(error)) out.hint = LIMIT_HINT; - return out; -} - -async function handleRun(request, env) { - // Local calibration only: `wrangler dev --var MAX_STEPS_OVERRIDE:5000000` lifts the - // clamp so scripts/calibrate.mjs can probe past it. Never set in production. - const stepCap = Number(env?.MAX_STEPS_OVERRIDE) || MAX_STEPS; - let code, files = {}, maxSteps = Math.min(DEFAULT_STEPS, stepCap); - const ctype = request.headers.get("content-type") || ""; - if (ctype.includes("application/json")) { - let body; - try { body = await request.json(); } catch { return json({ ok: false, error: "invalid JSON body" }, 400); } - code = String(body.code ?? ""); - if (body.files != null) { - if (typeof body.files !== "object" || Array.isArray(body.files)) { - return json({ ok: false, error: "files must be an object of path -> content strings" }, 400); - } - files = body.files; - } - if (body.max_steps != null) maxSteps = Math.min(Math.max(1, Number(body.max_steps) || DEFAULT_STEPS), stepCap); - } else { - code = await request.text(); - } - if (!code.trim()) return json({ ok: false, error: "no code provided" }, 400); - if (code.length > MAX_CODE) return json({ ok: false, error: `code too large (${MAX_CODE} bytes max)` }, 413); - const filesJson = JSON.stringify(files); - if (filesJson.length > MAX_FILES_JSON) return json({ ok: false, error: `files too large (${MAX_FILES_JSON} bytes max)` }, 413); - return json(runPython(code, files, maxSteps)); -} - -export default { - async fetch(request, env) { - const url = new URL(request.url); - - if (url.pathname.startsWith("/api/")) { - if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS }); - if (url.pathname === "/api/health") { - const r = runPython("print(1 + 1)", {}, 100_000); - return json({ ok: r.ok && r.stdout === "2\n", exports: Object.keys(boot()).length }); - } - if (url.pathname === "/api/run" && request.method === "POST") return handleRun(request, env); - return json({ ok: false, error: "POST /api/run — body {code, files?, max_steps?} or text/plain Python" }, 404); - } - - if (url.pathname === "/pyex.wasm") { - const obj = await env.WASM.get(KEY); - if (!obj) return new Response("interpreter not uploaded to R2 yet", { status: 503 }); - - return new Response(obj.body, { - headers: { - "content-type": "application/wasm", - "etag": obj.httpEtag, - "cache-control": "public, max-age=31536000, immutable", - }, - }); - } - - // index.html, assets, and any 404s fall through to the static-asset handler. - return env.ASSETS.fetch(request); - }, -}; diff --git a/demo/pyex-web/worker/wrangler.jsonc b/demo/pyex-web/worker/wrangler.jsonc deleted file mode 100644 index c7b855d..0000000 --- a/demo/pyex-web/worker/wrangler.jsonc +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "./node_modules/wrangler/config-schema.json", - "name": "pyex", - "main": "worker.mjs", - "compatibility_date": "2026-06-20", - - // pyex.dev is registered on this Cloudflare account; custom_domain routes make - // wrangler own the DNS records + cert. workers.dev keeps serving as an alias. - "routes": [ - { "pattern": "pyex.dev", "custom_domain": true }, - { "pattern": "www.pyex.dev", "custom_domain": true } - ], - - // index.html etc. are served as static assets; the Worker runs for everything else: - // /pyex.wasm streamed from R2 (browser path) and /api/* (server-side Python). - "assets": { "directory": "../app/dist", "binding": "ASSETS" }, - - // the interpreter for the BROWSER, served raw from R2 (edge compresses over the wire). - "r2_buckets": [{ "binding": "WASM", "bucket_name": "pyex-wasm" }], - - // the interpreter for the API: ./pyex.wasm is bundled as a precompiled module binding - // (workerd forbids runtime WebAssembly.compile). Keep it in sync with the R2 object. - "rules": [{ "type": "CompiledWasm", "globs": ["**/*.wasm"], "fallthrough": true }], - - "limits": { "cpu_ms": 5000 }, - - "observability": { "enabled": true } -} diff --git a/lib/beam2wasm.ex b/lib/beam2wasm.ex index d188dba..f25a625 100644 --- a/lib/beam2wasm.ex +++ b/lib/beam2wasm.ex @@ -188,11 +188,20 @@ defmodule Beam2Wasm do Process.put(:mapsfold, mapsfold?) + # Task.async/1 is shimmed synchronously (calls a $clos0 Fun) — gate it (needs $ftab+$clos0) and force $clos0. + task? = + Enum.any?(user, fn {_m, {:function, _, _, _, is}} -> + Enum.any?(is, &match?({_, _, {:extfunc, Task, :async, 1}}, &1)) + end) + + Process.put(:task, task?) + clos_ns = (Enum.map(clos_refs, fn {_m, _f, ar, nf} -> ar - nf end) ++ call_fun_arities(user) ++ tramp_ns ++ if(mapsfold?, do: [3], else: []) ++ + if(task?, do: [0], else: []) ++ if(proc, do: [0], else: []) ++ if(regex_replace?, do: [1, 2], else: [])) |> Enum.uniq() @@ -355,7 +364,10 @@ defmodule Beam2Wasm do :compact, :undef, :utf8, - :latin1 + :latin1, + :timeout, + # returned by the :elixir_config.identifier_tokenizer/0 shim (atom inspection via Macro.classify_atom) + String.Tokenizer ] ++ if(http_get?, do: [:body, :status, :__struct__, Req.Response], else: []) ++ if(req_in_user, @@ -409,7 +421,7 @@ defmodule Beam2Wasm do ) end) end) or Enum.any?(collect_literal_funs(user), &match?({:erlang, :atom_to_binary, _}, &1)) or - to_string?(user) or crypto_hash? + to_string?(user) or crypto_hash? or System.get_env("ATOMNAMES") != nil Process.put(:atom_names, atom_names?) # String case mapping is genuinely table-backed -> delegate to the host (like math/big). Gated. @@ -511,9 +523,19 @@ defmodule Beam2Wasm do end) end) + # :os.system_time/0 — wall-clock as a host effect (DateTime.utc_now, :timer, VFS mtimes lean on it). + os_systime? = + Enum.any?(user, fn {_m, {:function, _, _, _, is}} -> + Enum.any?(is, fn op -> + match?({_, _, {:extfunc, :os, :system_time, 0}}, op) or + match?({_, _, {:extfunc, :os, :system_time, 0}, _}, op) + end) + end) + Process.put(:fs_shim, fs?) Process.put(:io_shim, io?) Process.put(:sql_shim, sql?) + Process.put(:os_systime, os_systime?) # :unicode NF* normalization -> host (JS String.prototype.normalize — same Unicode tables) uninorm? = Enum.any?(user, fn {_m, {:function, _, _, _, is}} -> @@ -667,6 +689,7 @@ defmodule Beam2Wasm do else: [] ) ++ if(fs?, do: [{:file, :read_file, 1}, {:file, :write_file, 2}, {:file, :write_file, 3}], else: []) ++ + if(os_systime?, do: [{:os, :system_time, 0}], else: []) ++ if(sql?, do: [{:sql_host, :exec, 2}], else: []) ++ if(fltparse?, do: [{:erlang, :binary_to_float, 1}, {:erlang, :list_to_float, 1}], else: []) ++ if(fltfmt?, @@ -681,7 +704,9 @@ defmodule Beam2Wasm do if(io?, do: [{IO, :puts, 1}, {IO, :puts, 2}, {IO, :warn, 1}], else: []) ++ if(titlecase?, do: [{:string, :titlecase, 1}], else: []) ++ if(http_get?, do: [{Req, :get!, 1}], else: []) ++ - if(crypto_hash?, do: [{:crypto, :hash, 2}], else: [])) + if(crypto_hash?, do: [{:crypto, :hash, 2}], else: []) ++ + # start_process() provides these when process-mode is on (OTP 29 gen:call timeout timers). + if(Process.get(:proc), do: [{:erlang, :start_timer, 3}, {:erlang, :cancel_timer, 1}, {:erlang, :cancel_timer, 2}], else: [])) |> MapSet.new() stubs = @@ -786,6 +811,10 @@ defmodule Beam2Wasm do " (import \"sql\" \"exec\" (func $host_sql_exec (param (ref null eq)) (param (ref null eq)) (result (ref null eq))))", else: "" ), + if(os_systime?, + do: " (import \"sys\" \"now\" (func $host_sys_now (result i64)))", + else: "" + ), if(io?, do: " (import \"io\" \"puts\" (func $host_io_puts (param (ref null eq)) (result i32)))\n (import \"io\" \"warn\" (func $host_io_warn (param (ref null eq)) (result i32)))", @@ -1455,10 +1484,16 @@ defmodule Beam2Wasm do defp unconsolidated?(path) do {:beam_file, _, _, _, _, fns} = :beam_disasm.file(String.to_charlist(path)) + # The runtime impl_for fallback that a CONSOLIDATED protocol drops: pre-1.20 it was + # Module.concat/1,2; Elixir 1.20 emits Protocol.__concat__/2 instead. Detect either — missing + # the 1.20 form made us treat the (still unconsolidated) stdlib protocol as consolidated, so + # dispatch fell through to the runtime concat and trapped (e.g. Enum.reduce_while over a Range). Enum.any?(fns, fn {:function, _n, _a, _e, is} -> Enum.any?(is, fn op -> match?({_, _, {:extfunc, Module, :concat, _}}, op) or - match?({_, _, {:extfunc, Module, :concat, _}, _}, op) + match?({_, _, {:extfunc, Module, :concat, _}, _}, op) or + match?({_, _, {:extfunc, Protocol, :__concat__, _}}, op) or + match?({_, _, {:extfunc, Protocol, :__concat__, _}, _}, op) end) end) end @@ -1815,7 +1850,10 @@ defmodule Beam2Wasm do (import "proc" "pdict_put" (func $pdict_put (param (ref null eq)) (param (ref null eq)) (result (ref null eq)))) (import "proc" "spawn_opt" (func $spawn_opt_raw (param (ref null eq)) (param (ref null eq)) (param (ref null eq)) (param i32) (result i32))) (import "proc" "demonitor" (func $demonitor_raw (param i32))) - (import "proc" "alias_pid" (func $alias_pid (param i32) (result i32)))\ + (import "proc" "alias_pid" (func $alias_pid (param i32) (result i32))) + ;; OTP 29 gen:call implements its call timeout with erlang:start_timer/3 (was `receive after`). + (import "proc" "start_timer" (func $start_timer_raw (param i32) (param i32) (param (ref null eq)) (result i32))) + (import "proc" "cancel_timer" (func $cancel_timer_raw (param i32) (result i32)))\ """ end @@ -1867,6 +1905,28 @@ defmodule Beam2Wasm do (func (export "make_down") (param $ref i32) (param $pid i32) (param $reason (ref null eq)) (result (ref null eq)) (array.new_fixed $tuple 5 (global.get $atom_DOWN) (struct.new $ref (local.get $ref) (i32.const 0)) (global.get $atom_process) (struct.new $pid (local.get $pid)) (local.get $reason))) (func (export "get_normal") (result (ref null eq)) (global.get $atom_normal)) + ;; the host builds a fired timer's message {:timeout, TimerRef, Msg} (atoms/refs live in Wasm). + (func (export "make_timeout") (param $ref i32) (param $msg (ref null eq)) (result (ref null eq)) + (array.new_fixed $tuple 3 (global.get $atom_timeout) (struct.new $ref (local.get $ref) (i32.const 0)) (local.get $msg))) + ;; erlang:start_timer(Time, Dest, Msg) -> TimerRef. Time is a small int term; Dest a pid/name/ref. + (func $erlang.start_timer_3 (param $t (ref null eq)) (param $dest (ref null eq)) (param $msg (ref null eq)) (result (ref null eq)) + (struct.new $ref + (call $start_timer_raw + (i31.get_s (ref.cast (ref i31) (local.get $t))) + (call $resolve_dest (local.get $dest)) + (local.get $msg)) + (i32.const 0))) + ;; erlang:cancel_timer(TimerRef) -> RemainingMs | false. false only if it had already fired. + (func $erlang.cancel_timer_1 (param $ref (ref null eq)) (result (ref null eq)) + (local $r i32) + (local.set $r (call $cancel_timer_raw (struct.get $ref 0 (ref.cast (ref $ref) (local.get $ref))))) + (if (result (ref null eq)) (i32.lt_s (local.get $r) (i32.const 0)) + (then (global.get $atom_false)) (else (ref.i31 (local.get $r))))) + ;; erlang:cancel_timer(TimerRef, Opts) — OTP 29 gen_server cancels with [{async,true},{info,false}]. + ;; Options only govern whether/how the result is reported; the cancel itself is the same. -> :ok. + (func $erlang.cancel_timer_2 (param $ref (ref null eq)) (param $opts (ref null eq)) (result (ref null eq)) + (drop (call $cancel_timer_raw (struct.get $ref 0 (ref.cast (ref $ref) (local.get $ref))))) + (global.get $atom_ok)) ;; send dest may be a $pid, a monitor-alias $ref (gen:reply uses one), or a registered name ;; (atom) -> resolve to a raw pid id. (func $resolve_dest (param $d (ref null eq)) (result i32) @@ -1887,7 +1947,7 @@ defmodule Beam2Wasm do do: [{m, fun, arity, length(free)}] defp make_fun_refs(t) when is_tuple(t), do: t |> Tuple.to_list() |> Enum.flat_map(&make_fun_refs/1) - defp make_fun_refs(l) when is_list(l), do: Enum.flat_map(l, &make_fun_refs/1) + defp make_fun_refs([h | t]), do: make_fun_refs(h) ++ make_fun_refs(t) defp make_fun_refs(_), do: [] defp collect_literal_funs(user) do @@ -1916,7 +1976,9 @@ defmodule Beam2Wasm do end defp literal_funs_in(t) when is_tuple(t), do: t |> Tuple.to_list() |> Enum.flat_map(&literal_funs_in/1) - defp literal_funs_in(l) when is_list(l), do: Enum.flat_map(l, &literal_funs_in/1) + # cons-cell recursion (not Enum.flat_map) so IMPROPER lists — e.g. an iodata/charlist constant with a + # binary tail, common in Erlang stdlib beams the auto-feed closure pulls in — don't crash flat_map. + defp literal_funs_in([h | t]), do: literal_funs_in(h) ++ literal_funs_in(t) # a fun can be nested inside a constant MAP value (e.g. Logger metadata `%{report_cb: &format_report/1}`); # recurse so its name atom is interned (materialize references $atom_) and it's a DCE root. defp literal_funs_in(m) when is_map(m), @@ -2178,7 +2240,7 @@ defmodule Beam2Wasm do defp atoms_in({:literal, term}), do: term_atoms(term) defp atoms_in({:atom, a}), do: [a] defp atoms_in(t) when is_tuple(t), do: t |> Tuple.to_list() |> Enum.flat_map(&atoms_in/1) - defp atoms_in(l) when is_list(l), do: Enum.flat_map(l, &atoms_in/1) + defp atoms_in([h | t]), do: atoms_in(h) ++ atoms_in(t) defp atoms_in(_), do: [] defp term_atoms(a) when is_atom(a), do: [a] @@ -2188,7 +2250,7 @@ defmodule Beam2Wasm do do: Map.to_list(m) |> Enum.flat_map(fn {k, v} -> term_atoms(k) ++ term_atoms(v) end) defp term_atoms(t) when is_tuple(t), do: t |> Tuple.to_list() |> Enum.flat_map(&term_atoms/1) - defp term_atoms(l) when is_list(l), do: Enum.flat_map(l, &term_atoms/1) + defp term_atoms([h | t]), do: term_atoms(h) ++ term_atoms(t) defp term_atoms(_), do: [] defp const_globals do diff --git a/lib/beam2wasm/codegen/common.ex b/lib/beam2wasm/codegen/common.ex index 7a72b5e..06659f0 100644 --- a/lib/beam2wasm/codegen/common.ex +++ b/lib/beam2wasm/codegen/common.ex @@ -113,6 +113,9 @@ defmodule Beam2Wasm.Codegen.Common do else: "(ref.test (ref i31) #{vw})" ) + def type_test_i32(:is_number, vw), + do: "(i32.or #{type_test_i32(:is_integer, vw)} #{type_test_i32(:is_float, vw)})" + def type_test_i32(:is_list, vw), do: "(i32.or (ref.is_null #{vw}) (ref.test (ref $cons) #{vw}))" def type_test_i32(:is_boolean, vw), diff --git a/lib/beam2wasm/codegen/emit.ex b/lib/beam2wasm/codegen/emit.ex index 67f8eed..8ca17d9 100644 --- a/lib/beam2wasm/codegen/emit.ex +++ b/lib/beam2wasm/codegen/emit.ex @@ -458,6 +458,7 @@ defmodule Beam2Wasm.Codegen.Emit do :is_float, :is_port, :is_integer, + :is_number, :is_list, :is_boolean ] -> @@ -500,6 +501,9 @@ defmodule Beam2Wasm.Codegen.Emit do else: "(ref.test (ref i31) #{val.(a)})" ) + :is_number -> + type_test_i32(:is_number, val.(a)) + :is_list -> "(i32.or (ref.is_null #{val.(a)}) (ref.test (ref $cons) #{val.(a)}))" @@ -830,6 +834,11 @@ defmodule Beam2Wasm.Codegen.Emit do {:badmatch, _} -> {["(unreachable)"], true} + # OTP 27+: a record field op on a non-matching tuple raises {badrecord, Tag}. Like badmatch, + # this is an error landing pad — unreached on the happy path — not an unsupported opcode. + {:badrecord, _} -> + {["(unreachable)"], true} + {:case_end, _} -> {["(unreachable)"], true} @@ -1268,6 +1277,10 @@ defmodule Beam2Wasm.Codegen.Emit do {["(local.set $fr#{n} (f64.#{o} #{frval.(a)} #{frval.(b)}))"], false} + # OTP 29: unary float minus (`-x` on a float) lowers to the fnegate float-register op. + {:bif, :fnegate, _f, [a], {:fr, n}} -> + {["(local.set $fr#{n} (f64.neg #{frval.(a)}))"], false} + {:loop_rec, {:f, e}, dst} -> {["(if (i32.eqz (call $recv_has)) (then #{jump.(e)}))", set.(dst, "(call $recv_cur)")], false} @@ -2401,8 +2414,13 @@ defmodule Beam2Wasm.Codegen.Emit do def build_map(src, kvs, val) do pairs = kvs |> Enum.chunk_every(2) |> Enum.map(fn [k, v] -> {k, v} end) + # The static fast path is ONLY valid when the source is the EMPTY map (we build the whole tree + # from `kvs` alone). NB: `%{}` as a *pattern* matches ANY map, so it must be guarded by + # `map_size == 0` — otherwise a non-empty literal source (OTP 29 emits `Map.put(%{a: 1}, …)` as + # `put_map_assoc {:literal, %{a: 1}}`) wrongly takes this path and DROPS the source's entries. static? = - match?({:literal, %{}}, src) and Enum.all?(pairs, fn {k, _} -> match?({:ok, _}, key_term(k)) end) + match?({:literal, m} when map_size(m) == 0, src) and + Enum.all?(pairs, fn {k, _} -> match?({:ok, _}, key_term(k)) end) if static? do ordered = diff --git a/lib/beam2wasm/codegen/runtime.ex b/lib/beam2wasm/codegen/runtime.ex index 72e43fe..ba6c47d 100644 --- a/lib/beam2wasm/codegen/runtime.ex +++ b/lib/beam2wasm/codegen/runtime.ex @@ -474,6 +474,11 @@ defmodule Beam2Wasm.Codegen.Runtime do # :elixir_config.get(key, default) — no config ETS in the sandbox → the default. "$elixir_config.get_2" => " (func $elixir_config.get_2 (param $key (ref null eq)) (param $default (ref null eq)) (result (ref null eq))\n (local.get $default))", + # :elixir_config.identifier_tokenizer() — the atom-classification tokenizer module. Constant in + # the sandbox: the default, String.Tokenizer (fed as pure Elixir). Lets Macro.classify_atom (via + # Inspect.Atom) resolve, so inspecting an atom returns a value instead of trapping. + "$elixir_config.identifier_tokenizer_0" => + " (func $elixir_config.identifier_tokenizer_0 (result (ref null eq))\n (global.get $atom_Elixir_46_String_46_Tokenizer))", # :os.type() — an OS query NIF. Constant in the sandbox; the :unix family is all that affects behavior. "$os.type_0" => " (func $os.type_0 (result (ref null eq))\n (array.new_fixed $tuple 2 (global.get $atom_unix) (global.get $atom_linux)))", @@ -539,6 +544,73 @@ defmodule Beam2Wasm.Codegen.Runtime do (func $Elixir_46_Code.ensure_compiled_1 (param $m (ref null eq)) (result (ref null eq)) (array.new_fixed $tuple 2 (global.get $atom_module) (local.get $m)))\ """, + # Task.yield/shutdown — identity. In the synchronous sandbox the "task" already carries its result + # (see the gated $Elixir_46_Task.async_1 below), so yield returns it (truthy → {:ok, result}) and + # shutdown is a no-op. (async_1 is gated separately: it calls a closure via $ftab, which only + # exists when the program has closures.) + "$Elixir_46_Task.yield_2" => """ + (func $Elixir_46_Task.yield_2 (param $task (ref null eq)) (param $timeout (ref null eq)) (result (ref null eq)) + (local.get $task))\ + """, + "$Elixir_46_Task.shutdown_2" => """ + (func $Elixir_46_Task.shutdown_2 (param $task (ref null eq)) (param $how (ref null eq)) (result (ref null eq)) + (local.get $task))\ + """, + # Code.ensure_loaded?(Module) -> true. Closed world: any module referenced here IS shipped. + # (Pyex.Stdlib.fetch/1 gates every `import` on this + function_exported?/3; a `false` here would + # make EVERY guest import fail.) + "$Elixir_46_Code.ensure_loaded_63__1" => """ + (func $Elixir_46_Code.ensure_loaded_63__1 (param $m (ref null eq)) (result (ref null eq)) + (global.get $atom_true))\ + """, +# :rand snapshot/restore — a turn snapshots the caller's PRNG state (export_seed/0) and reseeds + # (seed/1,2), restoring afterward so randomness never leaks between turns. With process-local + # entropy modeled as inert here, the snapshot is nil and reseeds are no-ops: correct for any + # program that doesn't draw randomness. (Actual `random.*` needs a faithful exsss PRNG shim — + # not yet built; such a draw would trap on :rand.uniform rather than silently diverge.) + "$rand.export_seed_0" => """ + (func $rand.export_seed_0 (result (ref null eq)) (global.get $atom_nil))\ + """, + "$rand.seed_1" => """ + (func $rand.seed_1 (param $a (ref null eq)) (result (ref null eq)) (global.get $atom_nil))\ + """, + "$rand.seed_2" => """ + (func $rand.seed_2 (param $a (ref null eq)) (param $b (ref null eq)) (result (ref null eq)) (global.get $atom_nil))\ + """, + # :telemetry.execute/3 — dispatches an event to attached handlers. Our closed, single-turn + # world attaches none, so it is a no-op: return :ok without touching the (absent) handler ETS + # table. Shimming here keeps the real `telemetry` app (ETS + process machinery) out of the graph. + "$telemetry.execute_3" => """ + (func $telemetry.execute_3 (param $ev (ref null eq)) (param $meas (ref null eq)) (param $meta (ref null eq)) (result (ref null eq)) + (global.get $atom_ok))\ + """, + # :telemetry.span/3 — runs the 0-arg span function (which returns {result, stop_meta}), emitting + # start/stop events around it; with no handlers attached the events are no-ops, so we just invoke + # the closure and return its `result`. pyex wraps FS ops in this. (clos0 = self-only closure; $ftab + # exists whenever closures do, which pyex always has.) + # Gated on process-mode: $ftab + $clos0 (the closure-call machinery) are only in the module then + # — and telemetry.span is only reached by pyex, which is always process-mode. builtins() is emitted + # WHOLESALE, so a non-proc module must get the type-free stub or wasm-as fails ("unknown type $clos0"). + "$telemetry.span_3" => + if(Process.get(:proc), + do: """ + (func $telemetry.span_3 (param $prefix (ref null eq)) (param $meta (ref null eq)) (param $fun (ref null eq)) (result (ref null eq)) + (local $r (ref null eq)) + (local.set $r (call_indirect $ftab (type $clos0) (local.get $fun) (struct.get $fun 0 (ref.cast (ref $fun) (local.get $fun))))) + (array.get $tuple (ref.cast (ref $tuple) (local.get $r)) (i32.const 0)))\ + """, + else: " (func $telemetry.span_3 (param (ref null eq)) (param (ref null eq)) (param (ref null eq)) (result (ref null eq)) (unreachable))" + ), + # :re.import/1 — OTP 28+ Regex literals store the compiled pattern as an "exported" + # (serializable) form; at the ~r call site Elixir materializes it with :re.import/1 to + # populate the %Regex{}.re_pattern field. Our Regex.run/scan/split builtins run from the + # struct's `source` and never read re_pattern, so the imported value is inert — identity is + # a faithful shim (the host regex engine always recompiles from source). See beam2wasm.ex + # regex_run? and $regex_src. + "$re.import_1" => """ + (func $re.import_1 (param $p (ref null eq)) (result (ref null eq)) + (local.get $p))\ + """, # read a 64-bit big-endian IEEE-754 double from $bytes at byte offset $off (bs_get_float2, default flags) "$read_f64_be" => """ (func $read_f64_be (param $b (ref $bytes)) (param $off i32) (result f64) @@ -918,6 +990,17 @@ defmodule Beam2Wasm.Codegen.Runtime do """, else: " (func $file.read_file_1 (param $p (ref null eq)) (result (ref null eq)) (unreachable))" ), +# :os.system_time/0 — native-unit wall clock from the host (DateTime.utc_now et al.). The host `sys.now` + # returns i64 nanoseconds; hand it back as an integer term. Only wired when reachable (import gated). + "$os.system_time_0" => + if(Process.get(:os_systime), + do: " (func $os.system_time_0 (result (ref null eq)) (call $narrow (call $host_sys_now)))", + else: " (func $os.system_time_0 (result (ref null eq)) (unreachable))" + ), + # OTP 29: File.read/1 lowers to :file.read_file/2 (path, opts). Options don't affect the + # bytes returned by our host VFS, so drop them and reuse the /1 body. + "$file.read_file_2" => + " (func $file.read_file_2 (param $p (ref null eq)) (param $_opts (ref null eq)) (result (ref null eq)) (return_call $file.read_file_1 (local.get $p)))", "$file.write_file_2" => if(Process.get(:fs_shim), do: """ @@ -1396,9 +1479,12 @@ defmodule Beam2Wasm.Codegen.Runtime do """, # Optional callback checks (e.g. GenServer terminate/2). We do not expose a dynamic code server; # absent callbacks are reported as not exported. + # function_exported?(M, F, A) -> true. Closed world: every function referenced here IS compiled + # in, so "is it exported" is true. (Returning false here made Pyex.Stdlib.fetch/1 — + # `ensure_loaded?(m) and function_exported?(m, :module_value, 0)` — reject EVERY guest import.) "$erlang.function_exported_3" => """ (func $erlang.function_exported_3 (param $m (ref null eq)) (param $f (ref null eq)) (param $a (ref null eq)) (result (ref null eq)) - (global.get $atom_false))\ + (global.get $atom_true))\ """, "$erlang.exit_1" => if(Process.get(:exc), @@ -1806,6 +1892,21 @@ defmodule Beam2Wasm.Codegen.Runtime do } # maps:fold/3 needs $clos3 + the $ftab table, so emit it only when the program uses it. + # Task.async/1 runs the 0-arity closure SYNCHRONOUSLY (the sandbox is single-threaded + step-bounded, + # so pyex's Task-based ReDoS guard collapses to a direct call) and returns {:ok, result}. Gated like + # maps.fold: it dispatches through $ftab/$clos0, which only exist when the program has closures. + base = + if Process.get(:task) do + Map.put(base, "$Elixir_46_Task.async_1", """ + (func $Elixir_46_Task.async_1 (param $fun (ref null eq)) (result (ref null eq)) + (array.new_fixed $tuple 2 (global.get $atom_ok) + (call_indirect $ftab (type $clos0) (local.get $fun) + (struct.get $fun 0 (ref.cast (ref $fun) (local.get $fun))))))\ + """) + else + base + end + base = if Process.get(:mapsfold) do Map.put(base, "$maps.fold_3", """ diff --git a/lib/mix/tasks/wasm.build.ex b/lib/mix/tasks/wasm.build.ex index 6f9a30e..79eae5e 100644 --- a/lib/mix/tasks/wasm.build.ex +++ b/lib/mix/tasks/wasm.build.ex @@ -55,16 +55,44 @@ defmodule Mix.Tasks.Wasm.Build do Tuple, Range, Stream, + Stream.Reducers, + # date/time (needed once :os.system_time is wired — e.g. VFS mtimes, datetime, time.time()) + Calendar, + Calendar.ISO, + Date, + Time, + DateTime, + NaiveDateTime, Enumerable, Collectable, Inspect, Inspect.Algebra, + Inspect.Opts, + # Inspect protocol impls — pyex builds error messages with Kernel.inspect (e.g. inspect_tokens/1 + # over atoms/tuples/binaries/integers), so a bad-input error must not trap the whole sandbox. + Inspect.Atom, + Inspect.Integer, + Inspect.Float, + Inspect.List, + Inspect.Tuple, + Inspect.Map, + Inspect.BitString, + # atom inspection (Inspect.Atom -> Macro.inspect_atom -> Code.Identifier -> Macro.classify_atom -> + # :elixir_config.identifier_tokenizer().tokenize) — so inspecting an atom (e.g. pyex formatting a + # parse/lex error via inspect_tokens) returns a clean traceback instead of trapping the sandbox. + Macro, + Code.Identifier, + String.Tokenizer, + # Elixir Path — pyex's pathlib (Pyex.Path.join/basename/dirname/…) delegates to it. + Path, Access, ArgumentError, RuntimeError, KeyError, :lists, :maps, + # Elixir Path delegates path manipulation to Erlang :filename (basename/dirname/join/…). + :filename, :sets, :ordsets, :gb_sets, @@ -104,28 +132,53 @@ defmodule Mix.Tasks.Wasm.Build do strict: [ module: :string, export: :keep, + dep: :keep, out: :string, worker: :boolean, strict: :boolean, - stdlib: :boolean + stdlib: :boolean, + # auto-feed: also feed the transitive closure of statically-referenced modules (via BEAM + # import tables), so a module the code CALLS is never silently a missing-external trap. dce: + # keep function-level dead-code elimination (default true). For an interpreter like pyex, + # `--auto-feed --no-dce` compiles the whole referenced closure and leans on the differential + # harness (CPython oracle) instead of DCE's static-reachability proxy. + auto_feed: :boolean, + dce: :boolean ] ) - exports = Keyword.get_values(opts, :export) - if exports == [], do: Mix.raise("at least one --export \"name:args->ret\" is required") + # Declarative defaults from the project: `wasm: [module: ..., exports: [...], deps: [...]]` in + # mix.exs. CLI flags override. This is what lets a project keep its whole wasm build config + # versioned and reviewable instead of memorized in a shell command. + cfg = Mix.Project.config()[:wasm] || [] + + exports = Keyword.get_values(opts, :export) ++ (cfg[:exports] || []) + if exports == [], do: Mix.raise("at least one --export \"name:args->ret\" is required (or :exports in the mix.exs :wasm config)") + + # dep allowlist: which dependency apps to feed. Empty = feed every dep (DCE prunes). An explicit + # list keeps effectful/irrelevant deps (req, bandit, …) OUT as honest host-boundary externals — + # the intentional "what's in the sandbox" boundary, declared rather than hand-curated in bash. + dep_allow = + case Keyword.get_values(opts, :dep) do + [] -> cfg[:deps] + list -> Enum.map(list, &String.to_atom/1) + end Mix.Task.run("compile") app = Mix.Project.config()[:app] - module = Module.concat([Keyword.get(opts, :module, Macro.camelize(to_string(app)))]) + module = Module.concat([Keyword.get(opts, :module) || cfg[:module] || Macro.camelize(to_string(app))]) out = Path.expand(Keyword.get(opts, :out, "wasm"), File.cwd!()) File.mkdir_p!(out) - beams = collect_beams(module, Keyword.get(opts, :stdlib, true)) - Mix.shell().info("collected #{length(beams)} beams (#{inspect(module)} first)") + auto_feed = Keyword.get(opts, :auto_feed, Keyword.get(cfg, :auto_feed, false)) + dce = Keyword.get(opts, :dce, Keyword.get(cfg, :dce, true)) + + beams = collect_beams(module, Keyword.get(opts, :stdlib, true), dep_allow, auto_feed) + Mix.shell().info("collected #{length(beams)} beams (#{inspect(module)} first)#{if auto_feed, do: " [auto-feed]"}#{unless dce, do: " [no-dce]"}") watf = Path.join(out, "#{app}.wat") wasmf = Path.join(out, "#{app}.wasm") - {wat, compile_stubs} = compile(beams, Enum.join(exports, ";")) + {wat, compile_stubs} = compile(beams, Enum.join(exports, ";"), dce) File.write!(watf, wat) externals = external_stubs(wat) @@ -170,13 +223,44 @@ defmodule Mix.Tasks.Wasm.Build do # ---- beam collection ------------------------------------------------------------------ - defp collect_beams(module, stdlib?) do + # Modules the compiler shims natively at the host boundary — feeding their BEAM double-defines the + # shimmed functions (wasm-as: duplicate function). Preloaded/NIF modules with no .beam are skipped + # automatically (:code.which returns :preloaded). This list is only for shimmed modules that DO ship + # a .beam. If auto-feed hits a new duplicate, add the module here. + @no_feed MapSet.new([ + Regex, + :re, + :binary, + :unicode, + :math, + :crypto, + :rand, + :os, + :file, + :persistent_term, + :elixir_config, + :ets, + Code, + # String case-mapping (downcase/upcase/3) is shimmed to the host — feeding double-defines + String.Unicode + ]) + + defp collect_beams(module, stdlib?, dep_allow, auto_feed \\ false) do build_lib = Path.join(Mix.Project.build_path(), "lib") + this_app = to_string(Mix.Project.config()[:app]) + + # apps to feed: always the project itself + the compiler-excluded set; when an allowlist is given, + # restrict deps to it (+ the primary app). Nil allowlist = feed every dep (DCE prunes). + allow = dep_allow && MapSet.new([this_app | Enum.map(dep_allow, &to_string/1)]) app_beams = Path.wildcard(Path.join([build_lib, "*", "ebin", "*.beam"])) # the compiler itself is a dep of the host project — never feed it to itself |> Enum.reject(&String.contains?(&1, "/beam2wasm/")) + |> Enum.filter(fn path -> + # path is .../build//lib//ebin/.beam — keep if no allowlist, or app ∈ allow + allow == nil or MapSet.member?(allow, path |> Path.dirname() |> Path.dirname() |> Path.basename()) + end) consolidated = Path.wildcard(Path.join(Mix.Project.consolidation_path(), "*.beam")) @@ -196,8 +280,67 @@ defmodule Mix.Tasks.Wasm.Build do [] end - ([primary | app_beams -- [primary]] ++ stdlib_beams ++ consolidated) - |> dedup_prefer_consolidated(consolidated) + base = [primary | app_beams -- [primary]] ++ stdlib_beams ++ consolidated + + beams = + if auto_feed do + base ++ discover_closure(base) + else + base + end + + dedup_prefer_consolidated(beams, consolidated) + end + + # Transitive closure of statically-referenced modules: walk each beam's IMPORT table, resolve every + # referenced module to its .beam on the code path, and repeat to fixpoint. Retires the "forgot to feed + # module X" class for anything the code statically calls. (Dynamic dispatch — protocol/apply/config — + # is NOT in the import table; those still need consolidation or a host shim.) + defp discover_closure(seed_beams) do + seen = MapSet.new(seed_beams, &beam_module/1) + do_discover(seed_beams, MapSet.union(seen, @no_feed), []) + end + + defp do_discover([], _seen, acc), do: acc + + defp do_discover([beam | rest], seen, acc) do + {new_beams, seen} = + beam + |> beam_imports() + |> Enum.reject(&MapSet.member?(seen, &1)) + |> Enum.reduce({[], seen}, fn mod, {beams, seen} -> + seen = MapSet.put(seen, mod) + + case mod_beam(mod) do + nil -> {beams, seen} + path -> {[path | beams], seen} + end + end) + + do_discover(rest ++ new_beams, seen, acc ++ new_beams) + end + + defp beam_module(path), do: path |> Path.basename(".beam") |> String.to_atom() + + # imported (externally-called) modules of a beam, from its ImpT chunk + defp beam_imports(path) do + case :beam_lib.chunks(String.to_charlist(path), [:imports]) do + {:ok, {_mod, [{:imports, imports}]}} -> imports |> Enum.map(fn {m, _f, _a} -> m end) |> Enum.uniq() + _ -> [] + end + end + + # a module's .beam path, or nil for preloaded/NIF/absent modules (nothing to compile) and the + # compiler itself (never fed to itself) + defp mod_beam(mod) do + case :code.which(mod) do + path when is_list(path) -> + str = to_string(path) + if String.contains?(str, "/beam2wasm/"), do: nil, else: str + + _ -> + nil + end end # drop unconsolidated copies of modules that have a consolidated build; preserve order @@ -221,10 +364,10 @@ defmodule Mix.Tasks.Wasm.Build do # ---- compile + assemble --------------------------------------------------------------- - defp compile(beams, exports_spec) do + defp compile(beams, exports_spec, dce \\ true) do # :stub so unsupported constructs become COUNTED traps we can report (and fail on # under --strict) instead of aborting mid-module. - case Beam2Wasm.compile(beams, exports: exports_spec, stub: true) do + case Beam2Wasm.compile(beams, exports: exports_spec, stub: true, dce: dce) do {:ok, %Beam2Wasm.Result{wat: wat, stubs: stubs}} -> {wat, stubs} {:error, e} -> Mix.raise("compile failed: " <> Exception.message(e)) end diff --git a/runtime/imports.mjs b/runtime/imports.mjs index 030426f..ba53e3a 100644 --- a/runtime/imports.mjs +++ b/runtime/imports.mjs @@ -570,6 +570,12 @@ export const makeIo = (getExports, sink = null) => { // Benign proc/sched stubs for runners that keep GenServer/Finch code alive via DCE but never // execute it (the demo overrides the transport adapter). The REAL scheduler lives in // runtime/scheduler.mjs; do not use these there. +// Wall clock as a host effect: :os.system_time/0 -> nanoseconds since the epoch (i64). Deterministic +// alternative: pass a fixed `nowNs` for reproducible turns (agent replay). +export const makeSys = (nowNs = null) => ({ + now: () => (nowNs != null ? BigInt(nowNs) : BigInt(Date.now()) * 1_000_000n), +}); + export const makeProcStubs = () => { const pdict = new Map(); const proc = { @@ -578,6 +584,9 @@ export const makeProcStubs = () => { recv_has: () => 0, recv_cur: () => null, recv_remove: () => {}, recv_advance: () => {}, recv_wait: () => {}, recv_wait_timeout: () => 0, exit: () => {}, exit2: () => {}, set_trap_exit: () => {}, register: () => {}, whereis: () => 0, monitor: () => 1, demonitor: () => {}, alias_pid: (p) => p, + // OTP 29 gen:call timeout timers. In these single-shot stub contexts nothing fires, so a timer is + // inert: start returns a ref id, cancel is a no-op. (The real firing lives in scheduler.mjs.) + start_timer: () => 999, cancel_timer: () => 0, pdict_get: (k) => (pdict.has(k) ? pdict.get(k) : null), pdict_put: (k, v) => { const old = pdict.has(k) ? pdict.get(k) : null; pdict.set(k, v); return old; }, }; diff --git a/runtime/scheduler.mjs b/runtime/scheduler.mjs index fc79c38..acce702 100644 --- a/runtime/scheduler.mjs +++ b/runtime/scheduler.mjs @@ -15,6 +15,7 @@ const DEBUG = process.env.SCHED_DEBUG === "1"; const procs = new Map(); // pid -> {fn?, mailbox, cursor, status, resolve, reject} const registry = new Map();// name-atom-index -> pid (named processes) const monitors = []; // {by, target, ref} — `by` gets {:DOWN, ref, :process, target, reason} +const timerTable = new Map(); // erlang:start_timer id -> setTimeout handle (for cancel_timer) let nextPid = 2; // main process is pid 1 let nextRef = 1; let current = 0; @@ -45,7 +46,7 @@ const imports = { spawn_link: (fn) => { const pid = nextPid++; procs.set(pid, newProc({ fn })); procs.get(pid).links.add(current); P().links.add(pid); enqStart(pid); return pid; }, // spawn a process running apply(M,F,Args); optionally bidirectionally link to the spawner. spawn_opt: (m, f, a, link) => { - const pid = nextPid++; procs.set(pid, newProc({ mfa: [m, f, a] })); + const pid = nextPid++; if(process.env.SCHED_DEBUG)console.error("[sched] spawn_opt pid="+pid+" by="+current); procs.set(pid, newProc({ mfa: [m, f, a] })); if (link) { procs.get(pid).links.add(current); P().links.add(pid); } enqStart(pid); return pid; }, @@ -56,12 +57,32 @@ const imports = { // exit(pid, reason): signal another process. A parked target is unwound (kill-by-unwind); a // trapping target instead receives {:EXIT, from, reason}. :normal to another process is a no-op. exit2: (pid, reason) => { signal_exit(pid, reason); return 1; }, - register: (nameIdx, pid) => { registry.set(nameIdx, pid); }, + register: (nameIdx, pid) => { if(process.env.SCHED_DEBUG)console.error("[sched] register name="+nameIdx+" pid="+pid);registry.set(nameIdx, pid); }, whereis: (nameIdx) => registry.get(nameIdx) ?? 0, // 0 -> no process (send no-ops) monitor: (pid) => { const ref = nextRef++; monitors.push({ by: current, target: pid, ref }); return ref; }, demonitor: (ref) => { for (let i = monitors.length - 1; i >= 0; i--) if (monitors[i].ref === ref) monitors.splice(i, 1); }, // a monitor ref doubles as a reply alias (gen:call): sending to it delivers to the monitor owner. alias_pid: (ref) => { const m = monitors.find(m => m.ref === ref); return m ? m.by : 0; }, + // erlang:start_timer(Time, DestPid, Msg): after Time ms, deliver {:timeout, TimerRef, Msg} to Dest. + // Returns the timer-ref id. Used by OTP 29 gen:call for its timeout; normally cancelled on reply. + start_timer: (ms, destPid, msg) => { + const id = nextRef++; + pendingTimers++; + const t = setTimeout(() => { + timerTable.delete(id); pendingTimers--; + const p = procs.get(destPid); + if (p && !p.dead) { p.mailbox.push(makeTimeout(id, msg)); wake(destPid); } + }, Number(ms)); + timerTable.set(id, t); + return id; + }, + // cancel_timer(id) -> remaining ms (>=0) if still pending, or -1 if it had already fired/unknown. + cancel_timer: (id) => { + const t = timerTable.get(id); + if (t === undefined) return -1; + clearTimeout(t); timerTable.delete(id); pendingTimers--; + return 0; + }, recv_has: () => (P().cursor < P().mailbox.length ? 1 : 0), recv_cur: () => P().mailbox[P().cursor], recv_remove: () => { const p = P(); p.mailbox.splice(p.cursor, 1); p.cursor = 0; }, @@ -108,6 +129,7 @@ const startProcess = WebAssembly.promising(instance.exports.start_process); const startMfa = instance.exports.start_mfa && WebAssembly.promising(instance.exports.start_mfa); const runEntry = WebAssembly.promising(instance.exports[entry]); const setReds = instance.exports.set_reds; +const makeTimeout = instance.exports.make_timeout; // build {:timeout, TimerRef, Msg} for a fired timer const makeExit = instance.exports.make_exit; // build {:EXIT, pid, reason} (atoms live in Wasm) const makeDown = instance.exports.make_down; // build {:DOWN, ref, :process, pid, reason} const getNormal = instance.exports.get_normal; // the :normal atom @@ -151,6 +173,7 @@ function signal_exit(pid, reason) { // non-trapping one is killed too if the exit was abnormal (propagation). function finish(pid, normal) { const p = procs.get(pid); if (!p || p.dead) return; p.dead = true; p.status = "done"; + if (process.env.SCHED_DEBUG) console.error(`[sched] finish pid=${pid} normal=${normal} abnormal=${p.abnormal} links=[${[...p.links]}]`); unwindParked(p); // free an engine-rooted suspended stack, don't abandon it for (const [name, registeredPid] of registry) if (registeredPid === pid) registry.delete(name); const reasonRef = (p.exitReason != null) ? p.exitReason : getNormal(); @@ -189,7 +212,11 @@ function finish(pid, normal) { function startChild(pid) { const p = procs.get(pid); if (!p || p.dead) return; current = pid; p.status = "running"; fresh(); const run = p.mfa ? startMfa(p.mfa[0], p.mfa[1], p.mfa[2]) : startProcess(p.fn); - run.then(() => finish(pid, true), () => finish(pid, false)); + run.then(() => finish(pid, true), (e) => { + if (process.env.SCHED_DEBUG && !(e instanceof ProcExit) && !(e instanceof ProcKill)) + console.error(`[sched] pid=${pid} THREW: ${(e && e.stack) ? e.stack.split("\n").slice(0,4).join(" | ") : e}`); + finish(pid, false); + }); } function resume(pid) { const p = procs.get(pid); if (!p || p.dead) return; // killed while queued -> skip the dispatch @@ -211,7 +238,13 @@ async function main() { else if (pendingTimers > 0) await new Promise((r) => setTimeout(r, 0)); // idle: let a receive-after timer fire else break; // truly nothing left to do } - if (!mainDone) { console.error("DEADLOCK (no runnable processes, main not done)"); process.exit(2); } + if (!mainDone) { + if (process.env.SCHED_DEBUG) { + for (const [pid, p] of procs) console.error(` proc ${pid}: status=${p.status} dead=${p.dead} links=${[...(p.links||[])]}`); + console.error(` monitors: ${JSON.stringify(monitors)}`); + } + console.error("DEADLOCK (no runnable processes, main not done)"); process.exit(2); + } if (DEBUG) console.error(`[sched] ${dispatches} dispatches`); // Optional leak check (run with `node --expose-gc` and SCHED_MEM=1): after the run, GC and report // live heap + lingering process records. With kill-by-unwind + record cleanup this stays flat as the diff --git a/verify.exs b/verify.exs index 8a9ddde..dd16dc9 100644 --- a/verify.exs +++ b/verify.exs @@ -27,7 +27,7 @@ defmodule Verify do {"gaps", "bench/gaps", [], ~r/20\/20 programs PROVABLY CORRECT/, false}, {"genfuzz", "bench/genfuzz", [], ~r/12\/12 programs bit-exact/, false}, {"regexdiff", "bench/regexdiff", [], ~r/0 LIES/, false}, - {"scoreboard", "bench/scoreboard", [], ~r/487\/487 bit-exact \(100\.0%\)/, true}, + {"scoreboard", "bench/scoreboard", [], ~r/496\/496 bit-exact \(100\.0%\)/, true}, {"markdown", "demo/markdown", [], ~r/3\/3 pages BYTE-IDENTICAL/, true}, {"calc-parser", "demo/calc-parser", [], ~r/13\/13 expressions BYTE-IDENTICAL/, true}, {"effects", "demo/effects", [], ~r/byte-identical to the VM/, false}