Skip to content

Commit 6e79486

Browse files
apiadclaude
andcommitted
feat(app): serve Pyodide from origin instead of CDN
Pyodide assets (~14MB across pyodide.js, .asm.js, .asm.wasm, python_stdlib.zip, pyodide-lock.json) used to be loaded from cdn.jsdelivr.net on every page load. Two problems: 1. Dev iteration: even with browser cache, cold loads were ~10s. 2. PWA tier: the SW can't cache cross-origin opaque responses reliably, so "offline-capable" apps were never truly offline. New behavior: - New route /_violetear/pyodide/{filename} serves from a local disk cache at ~/.cache/violetear/pyodide-<version>/ (override via VIOLETEAR_PYODIDE_CACHE env var). - Cache is populated lazily on first request from the same CDN — no work at App() construction, no impact on tests that don't fetch Pyodide files. - Atomic .partial → final rename so a crashed download leaves no half-baked file. - The injected <script src=...> now points at the local route; loadPyodide()'s default indexURL follows automatically. - PWA Service Worker asset list now includes the Pyodide files so tier 4 (pomodoro) can be fully offline after first visit. Three new smoke tests: - Local route serves the seeded files; whitelist-only (no path traversal). - Injected client script uses /_violetear/pyodide/, not the CDN URL. - SW asset list includes all five Pyodide files. Tests stub the cache via VIOLETEAR_PYODIDE_CACHE → no real network fetch in CI. 43 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e0b250a commit 6e79486

2 files changed

Lines changed: 172 additions & 2 deletions

File tree

tests/test_engine.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,81 @@ def home():
227227
compile(bundle, "<bundle>", "exec")
228228

229229

230+
def test_pyodide_route_serves_from_local_cache(tmp_path, monkeypatch):
231+
"""Pyodide is served from `/_violetear/pyodide/<file>` via a local disk cache.
232+
233+
We seed the cache with stub files via the VIOLETEAR_PYODIDE_CACHE env override
234+
so the test runs without downloading ~14MB from the CDN.
235+
"""
236+
monkeypatch.setenv("VIOLETEAR_PYODIDE_CACHE", str(tmp_path))
237+
# Seed all expected files so the route succeeds.
238+
from violetear.app import PYODIDE_FILES
239+
240+
for fname in PYODIDE_FILES:
241+
(tmp_path / fname).write_text(f"// stub for {fname}\n")
242+
243+
app = App(title="Pyodide", version="pyodide1")
244+
245+
@app.view("/")
246+
def home():
247+
return Document(title="x")
248+
249+
client = TestClient(app.api)
250+
251+
# Each declared file is served and matches what we wrote on disk.
252+
for fname in PYODIDE_FILES:
253+
r = client.get(f"/_violetear/pyodide/{fname}")
254+
assert r.status_code == 200, fname
255+
assert f"stub for {fname}" in r.text
256+
257+
# Unknown filenames are 404 — the route is a whitelist, not a directory walk.
258+
r = client.get("/_violetear/pyodide/etc/passwd")
259+
assert r.status_code == 404
260+
r = client.get("/_violetear/pyodide/random.js")
261+
assert r.status_code == 404
262+
263+
264+
def test_injected_client_script_points_at_local_pyodide_route():
265+
"""When the app has client code, the injected <script src=...> uses
266+
`/_violetear/pyodide/pyodide.js` (origin), not the CDN URL."""
267+
app = App(title="Local Pyodide", version="pyodide2")
268+
269+
@app.client.callback
270+
async def noop(event):
271+
pass
272+
273+
@app.view("/")
274+
def home():
275+
return Document(title="x")
276+
277+
client = TestClient(app.api)
278+
html = client.get("/").text
279+
assert "/_violetear/pyodide/pyodide.js" in html
280+
assert "cdn.jsdelivr.net" not in html
281+
282+
283+
def test_pwa_service_worker_precaches_pyodide_assets(tmp_path, monkeypatch):
284+
"""PWA-enabled apps add Pyodide files to the SW asset list so the app
285+
loads fully offline after first visit."""
286+
monkeypatch.setenv("VIOLETEAR_PYODIDE_CACHE", str(tmp_path))
287+
288+
app = App(title="PWA Offline", version="pwaoff")
289+
290+
@app.view("/", pwa=True)
291+
def home():
292+
return Document(title="x")
293+
294+
import hashlib
295+
from violetear.app import PYODIDE_FILES
296+
297+
h = hashlib.md5(b"/").hexdigest()[:8]
298+
client = TestClient(app.api)
299+
sw = client.get(f"/_violetear/pwa/{h}/sw.js").text
300+
301+
for fname in PYODIDE_FILES:
302+
assert f"/_violetear/pyodide/{fname}" in sw, fname
303+
304+
230305
def test_reactive_class_binding_via_class_name_alias():
231306
"""`class_name=` (React-style) is honored as an alias for `classes=`.
232307

violetear/app.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
import inspect
66
import hashlib
77
import json
8+
import threading
89
from pathlib import Path
910
from textwrap import dedent
1011
from typing import Any, Callable, Dict, List, Set, Union, cast
12+
from urllib.request import urlretrieve
1113
import uuid
1214

1315
from .stylesheet import StyleSheet
@@ -16,6 +18,74 @@
1618
from .state import ReactiveProxy, local
1719

1820

21+
# --- Pyodide local hosting ---
22+
# We serve Pyodide assets from the same origin as the app instead of a CDN, so:
23+
# (a) the dev iteration loop isn't gated on a ~10MB CDN round-trip every page
24+
# load (browser cache helps but isn't always honored mid-iteration);
25+
# (b) Service Workers can pre-cache Pyodide for fully offline PWA support;
26+
# (c) air-gapped deployments work after a single download per machine.
27+
28+
PYODIDE_VERSION = "0.29.0"
29+
PYODIDE_FILES = (
30+
"pyodide.js",
31+
"pyodide.asm.js",
32+
"pyodide.asm.wasm",
33+
"python_stdlib.zip",
34+
"pyodide-lock.json",
35+
)
36+
PYODIDE_CDN_BASE = f"https://cdn.jsdelivr.net/pyodide/v{PYODIDE_VERSION}/full/"
37+
38+
39+
def _pyodide_cache_dir() -> Path:
40+
"""Resolve the local Pyodide cache directory.
41+
42+
Honors ``VIOLETEAR_PYODIDE_CACHE`` env override; otherwise defaults to
43+
``~/.cache/violetear/pyodide-<version>/``. Version is part of the path so
44+
bumping ``PYODIDE_VERSION`` produces a clean new cache (no stale files).
45+
"""
46+
override = os.environ.get("VIOLETEAR_PYODIDE_CACHE")
47+
if override:
48+
return Path(override)
49+
return Path.home() / ".cache" / "violetear" / f"pyodide-{PYODIDE_VERSION}"
50+
51+
52+
_pyodide_download_lock = threading.Lock()
53+
54+
55+
def _ensure_pyodide_cached() -> Path:
56+
"""Download missing Pyodide assets to the local cache. Returns the cache path.
57+
58+
Idempotent: existing files are not re-downloaded. Thread-safe via a module
59+
lock so concurrent first-requests coalesce. Uses ``.partial`` rename for
60+
atomic completion — a crashed download leaves no half-baked file behind.
61+
"""
62+
cache = _pyodide_cache_dir()
63+
cache.mkdir(parents=True, exist_ok=True)
64+
65+
missing = [f for f in PYODIDE_FILES if not (cache / f).exists()]
66+
if not missing:
67+
return cache
68+
69+
with _pyodide_download_lock:
70+
# Re-check inside the lock — another thread may have raced ahead.
71+
missing = [f for f in PYODIDE_FILES if not (cache / f).exists()]
72+
if not missing:
73+
return cache
74+
75+
print(
76+
f"[violetear] Downloading Pyodide v{PYODIDE_VERSION} to {cache} "
77+
f"(~14MB, one-time per machine)…"
78+
)
79+
for fname in missing:
80+
print(f"[violetear] {fname}")
81+
tmp = cache / f"{fname}.partial"
82+
urlretrieve(PYODIDE_CDN_BASE + fname, tmp)
83+
tmp.rename(cache / fname)
84+
print("[violetear] Pyodide cache ready.")
85+
86+
return cache
87+
88+
1989
# --- Optional Server Dependencies ---
2090
try:
2191
from pydantic import create_model
@@ -320,6 +390,22 @@ async def lifespan(api: FastAPI):
320390
async def get_favicon():
321391
return FileResponse(self.favicon)
322392

393+
# Pyodide assets served from origin. Cache is populated lazily on the
394+
# first request — no work happens at App() construction time.
395+
@self.api.get("/_violetear/pyodide/{filename}")
396+
async def get_pyodide_asset(filename: str):
397+
if filename not in PYODIDE_FILES:
398+
return Response(status_code=404)
399+
try:
400+
cache = _ensure_pyodide_cached()
401+
except Exception as e:
402+
return Response(
403+
status_code=503,
404+
content=f"Pyodide download failed: {e}",
405+
media_type="text/plain",
406+
)
407+
return FileResponse(cache / filename)
408+
323409
# Registry of served styles to prevent duplicate route registration
324410
self.served_styles: Dict[str, StyleSheet] = {}
325411

@@ -714,8 +800,10 @@ def _inject_client_side(self, doc: Document):
714800
)
715801
doc.script(content=cloak_script)
716802

717-
# 2. Load Pyodide (from CDN)
718-
doc.script(src="https://cdn.jsdelivr.net/pyodide/v0.29.0/full/pyodide.js")
803+
# 2. Load Pyodide from the local origin route. loadPyodide() defaults
804+
# its indexURL to the directory where pyodide.js was loaded from, so
805+
# all subsequent fetches (wasm, stdlib, lock file) hit our route too.
806+
doc.script(src="/_violetear/pyodide/pyodide.js")
719807

720808
# 3. Bootstrap Script
721809
# We update this to remove the cloak once hydration is complete.
@@ -786,6 +874,13 @@ def _register_pwa(self, path: str, config: Union[bool, Manifest]):
786874
self._version_url("/favicon.ico"),
787875
)
788876

877+
# Pre-cache Pyodide assets so the PWA loads fully offline after first
878+
# visit. These URLs are stable across versions (not version-bumped) so
879+
# we don't apply self._version_url to them — the Pyodide files
880+
# themselves are pinned by PYODIDE_VERSION on the server side.
881+
for fname in PYODIDE_FILES:
882+
sw.add_assets(f"/_violetear/pyodide/{fname}")
883+
789884
self.pwa_registry[scope_hash] = (manifest, sw)
790885

791886
def _inject_pwa_tags(self, doc: Document, path: str):

0 commit comments

Comments
 (0)