Ctrl K
ComunidadGuías…

scripts/build_pages.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Assemble the static GitHub Pages site from web/ into a deploy-ready folder.

    python3 scripts/build_pages.py            # -> dist/pages/
    python3 scripts/build_pages.py --out site # CI artifact directory

The site frontend is already host-agnostic (document-relative asset refs, an
ABS() helper for fetches, root-finding manifest), so this build only lays the
files out for a project-pages host and appends the pieces that only make
sense there:

* `index.html`, `manifest.json`, the icons and the COI service worker live at
  the SITE ROOT; everything else lives under `static/` to mirror the URL
  layout the app already uses (`static/archiver-…`).
* A five-line bootstrap in <head> registers `archiver-coi-sw.js` and reloads
  the page once so GitHub Pages — which cannot send response headers — ends
  up cross-origin-isolated anyway, keeping the multithreaded WebAssembly
  runtime (SharedArrayBuffer) alive. Desktop Chrome and Edge gate
  SharedArrayBuffer behind isolation too, so every desktop browser takes the
  one-time reload on its first visit; the worker stamps worker scripts as well
  as documents, because an isolated page refuses a worker script without COEP.

SAFETY GATES — the build refuses to publish a site that is anything other
than the static frontend:
* no `*.db` (a memory bank must never reach a public bucket),
* no `.git`/`.env`/`*.pem`/`*.key`,
* no Python/app-server files (Pages is static; shipping backend source only
  advertises its internals),
* the emitted HTML/JS must not keep root-absolute `/static/` or `/api/`
  references, which would silently 404 under `/Archiver/`.

Only standard library; mirrors the no-build-step philosophy.
"""

from __future__ import annotations

import argparse
import re
import shutil
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"

# Files that live at the site root (everything else in web/ goes under static/).
ROOT_FILES = ("index.html", "manifest.json", "favicon.svg", "apple-touch-icon.png")

# Injected into <head> of the built index.html only. Kept deliberately small;
# the worker it registers ships as web/archiver-coi-sw.js. Contract (tested by
# tests/pages.js in a VM with fake navigator/sessionStorage/location):
#   * an isolated page (or one that cannot be isolated) never registers or reloads;
#   * the service worker is registered at the app scope, then control is awaited
#     with a bounded timeout (controllerchange, or an existing controller);
#   * at most ONE reload per tab, of the EXACT current URL (query and hash kept),
#     guarded by sessionStorage written BEFORE the reload;
#   * if sessionStorage is denied, nothing is reloaded and the state says so;
#   * window.__archiverIsolation records what happened for the app's status line.
COI_BOOTSTRAP = """<!-- pages-bootstrap: added by scripts/build_pages.py. GitHub Pages cannot send
     COOP/COEP headers, and the WebAssembly runtime needs a cross-origin-isolated
     document. The service worker synthesizes those headers; this script reloads
     the current URL at most once per tab so the reload lands on an isolated page. -->
<script>
(function () {
  var KEY = 'archiver-coi';
  var WAIT_MS = 6000;
  function set(state, reason) {
    try { window.__archiverIsolation = { state: state, reason: reason }; } catch (_) {}
  }
  try {
    if (window.crossOriginIsolated === true) { set('isolated', 'native'); return; }
    if (!window.isSecureContext || !('serviceWorker' in navigator)) { set('unavailable', 'no-service-worker'); return; }
    var store = null;
    try { store = window.sessionStorage; store.getItem(KEY); } catch (_) { store = null; }
    if (!store) { set('unavailable', 'storage-denied'); return; }
    if (store.getItem(KEY) === 'reloaded') { set('unavailable', 'not-isolated-after-reload'); return; }
    var scope = new URL('.', document.baseURI);
    var swUrl = new URL('archiver-coi-sw.js', scope).href;
    var reloaded = false;
    var controlled = new Promise(function (resolve) {
      if (navigator.serviceWorker.controller) { resolve(true); return; }
      navigator.serviceWorker.addEventListener('controllerchange', function () { resolve(true); });
    });
    var bounded = new Promise(function (resolve) { setTimeout(function () { resolve(false); }, WAIT_MS); });
    set('reloading', 'service-worker');
    navigator.serviceWorker.register(swUrl, { scope: scope.pathname }).then(function () {
      return Promise.race([controlled, bounded]);
    }).then(function () {
      if (reloaded) return;
      reloaded = true;
      try { store.setItem(KEY, 'reloaded'); } catch (_) { set('unavailable', 'storage-denied'); return; }
      location.reload();
    }, function () { set('unavailable', 'sw-register-failed'); });
  } catch (_) { set('unavailable', 'bootstrap-error'); }
})();
</script>
"""

FORBIDDEN_RE = re.compile(
    r"(?:^|/)(?:\.env|\.git$)|\.(?:db|db-wal|db-shm|py|pem|key|sqlite3?)$",
)

# References that would silently 404 under a project-pages subpath: network
# primitives fed root-absolute literals, and HTML attributes pointing at the
# host root. (api() call sites take '/api/…' strings deliberately — ABS()
# rebases them at runtime.)
BAD_REF_PATTERNS = [
    re.compile(r"""\bfetch\(\s*['"`]/"""),
    re.compile(r"""\bimport\(\s*['"`]/"""),
    re.compile(r"""\bnew Worker\(\s*['"`]/"""),
    re.compile(r"""location\.href\s*=\s*['"`]/"""),
    re.compile(r"""(?:href|src)\s*=\s*["']/[^/"']"""),
]


def fail(msg: str) -> "None":
    print(f"build_pages: {msg}", file=sys.stderr)
    raise SystemExit(1)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("--out", default=str(ROOT / "dist" / "pages"), help="output directory (recreated)")
    ap.add_argument("--allow-absent", action="store_true",
                    help="tolerate missing vendor .wasm/.gz files (source checkout without them)")
    args = ap.parse_args()

    out = Path(args.out).resolve()
    if out == ROOT or out.parent == out or str(out) in ("", "/", str(Path.home())):
        fail(f"refusing to build into {out}")

    if not (WEB / "index.html").is_file():
        fail(f"{WEB} does not look like the web/ directory")

    # Fresh output directory.
    if out.exists():
        shutil.rmtree(out)
    (out / "static").mkdir(parents=True)

    # Everything goes under static/, mirroring the /static mount of the app.
    for src in sorted(WEB.rglob("*")):
        if src.is_dir() or src.name == ".DS_Store":
            continue
        rel = src.relative_to(WEB)
        dst = out / "static" / rel
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dst)

    # Root copies of the entry files. (index.html and manifest.json also exist
    # under static/ as harmless duplicates; the mount serves them too.)
    for name in ROOT_FILES:
        shutil.copy2(WEB / name, out / name)

    # The COI worker must sit at the site root: a service worker's scope is
    # limited to its own directory.
    sw_src = WEB / "archiver-coi-sw.js"
    if not sw_src.is_file():
        fail("web/archiver-coi-sw.js is missing")
    shutil.copy2(sw_src, out / "archiver-coi-sw.js")

    # Inject the bootstrap into the head of the BUILT page only — the Render
    # deploy already sends real COOP/COEP headers and must not change.
    idx_path = out / "index.html"
    idx = idx_path.read_text(encoding="utf-8")
    anchor = '<link rel="icon"'
    if anchor not in idx:
        fail("index.html head anchor not found")
    idx = idx.replace(anchor, COI_BOOTSTRAP + anchor, 1)
    if idx.count("pages-bootstrap:") != 1:
        fail("the Pages bootstrap must be injected exactly once")
    idx_path.write_text(idx, encoding="utf-8")

    # ---- verification ----------------------------------------------------
    files = [p for p in sorted(out.rglob("*")) if p.is_file()]
    if not files:
        fail("produced an empty site")

    for p in files:
        rel = p.relative_to(out)
        name = rel.as_posix()
        if FORBIDDEN_RE.search(name):
            fail(f"refusing to publish {name} — the Pages site is the static frontend only")

    required = ["index.html", "manifest.json", "favicon.svg", "apple-touch-icon.png",
                "archiver-coi-sw.js", "static/archiver-engine.js", "static/archiver-worker.js",
                "static/archiver-prep.js", "static/archiver-sha256.js", "static/archiver-hash-worker.js"]
    for name in required:
        if not (out / name).is_file():
            fail(f"missing {name} in built site")

    vendor = ["static/vendor/web-llm-0.2.80.js", "static/vendor/wllama-3.6.1.js",
              "static/vendor/wllama-3.6.1.wasm", "static/vendor/wllama-compat-3.6.1.js",
              "static/vendor/wllama-compat-3.6.1.wasm"]
    for name in vendor:
        if not (out / name).is_file():
            if args.allow_absent:
                print(f"build_pages: warning — {name} absent (--allow-absent)")
            else:
                fail(f"missing {name}; run scripts/vendor_wllama.py / vendor_webllm.py first")

    # No root-absolute references may survive in the HTML or our own JS.
    offenders = []
    for p in files:
        name = p.relative_to(out).as_posix()
        if not re.search(r"\.(?:html|js|json)$", name):
            continue
        if name.startswith("static/vendor/"):
            continue  # third-party runtimes are served verbatim; wllama
                      # resolves its locateFile paths relative to us
        text = p.read_text(encoding="utf-8", errors="replace")
        for rx in BAD_REF_PATTERNS:
            for m in rx.finditer(text):
                line_no = text.count("\n", 0, m.start()) + 1
                offenders.append(f"{name}:{line_no}: {m.group(0).strip()[:60]}")
    if offenders:
        fail("root-absolute refs would 404 under a project-pages subpath:\n  " + "\n  ".join(offenders[:8]))

    total = sum(p.stat().st_size for p in files)
    print(f"built {out}: {len(files)} files, {total:,} bytes")
    return 0


if __name__ == "__main__":
    sys.exit(main())