tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

commit edd33e6172a8236e0bc08250ec6c6031660097ac
parent 9f20361d0438051dd6b7f7bbcea5f885258a94bb
Author: James Eversole <james@eversole.co>
Date:   Mon, 19 Oct 2026 10:18:44 -0500

Version browser asset trees for immutable caching

Package the complete JS, Worker, WASM, metadata and stylesheet graph under a content-derived release namespace, preserving relative imports and independent registry bundle identities.

Verify deterministic release identities and browser cache reuse across demo selection, Stop, recompilation and local validation. Update release-aware probes and document nginx caching and deployment retention. Validated with Chromium and Firefox on both backends, 114 Node tests, generated-JS parity and nix flake check.

Diffstat:
Mext/demo-site/README.md | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Aext/demo-site/asset-release.py | 39+++++++++++++++++++++++++++++++++++++++
Mext/demo-site/release.nix | 4+++-
Aext/demo-site/test/asset-cache-probe.mjs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aext/demo-site/test/asset-release.py | 39+++++++++++++++++++++++++++++++++++++++
Mext/demo-site/test/backend-benchmark.mjs | 2+-
Mext/demo-site/test/backend-browser.mjs | 2+-
Mext/demo-site/test/backend-probe.mjs | 2+-
Mext/demo-site/test/backend-resume-probe.mjs | 2+-
Mext/demo-site/test/backend-server.mjs | 17+++++++++++------
Mext/demo-site/test/ide-probe.mjs | 2+-
Mext/demo-site/test/live-ux-probe.mjs | 7++++---
Mext/demo-site/test/local-module-probe.mjs | 4++--
Mext/demo-site/test/module-probe.mjs | 2+-
Mext/demo-site/test/release.py | 15+++++++++++++++
Mext/demo-site/test/zig-session-probe.mjs | 2+-
Mflake.nix | 1+
17 files changed, 234 insertions(+), 24 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -665,7 +665,15 @@ nix build .#arboricxSiteDeps --out-link result-site-deps result-site-deps/bin/arboricx-seed-site "${REGISTRY_STORE:?select an absolute persistent store path}" ``` -`arboricxSite` contains HTML, CSS, strict freshly compiled JS, `catalogue.json`, `packages.json`, `environments.json`, and `compiler.json`. It has **no executable bundles**. A small `nix-support` +`arboricxSite` contains `index.html` and a complete asset tree under +`assets/<release-sha256>/`: CSS, freshly compiled JS, both WASM runtimes and their +support files, `catalogue.json`, `packages.json`, `environments.json`, and +`compiler.json`. `assets.json` records each file's path, size and SHA-256. The +release identity hashes that sorted manifest with the `arboricx.site-assets.v1` +domain. Changes anywhere in the tree produce a new namespace. HTML references +that namespace; relative module imports, nested Workers and runtime fetches stay +inside it. Registry bundle URLs retain their independent content hashes. +A small `nix-support` reference retains its matching dependency pack in the Nix closure for rollback. `arboricxSiteDeps` contains the pinned demo/compiler/environment/library bundles and retained indexed releases, a @@ -684,13 +692,64 @@ No CDN, analytics, external font or runtime dependency is loaded. Serve the site output with nginx and proxy `/_arboricx/` to the existing registry. The registry still serves inline HTML at its `/`, not a static directory; remove an exact nginx `= /` proxy when serving the homepage as static files. Public GETs -need no public publication access. Use no-cache for site assets/metadata (unhashed -JS names must update with a release), immutable caching only for successful hash -responses, and no-store/no negative caching for missing/error responses. Registry -health is `/_arboricx/health`; a `/health` convenience route must rewrite to it. +need no public publication access. Serve HTML and the root `assets.json` with +`Cache-Control: no-cache`. Serve successful `assets/<release-sha256>/` responses +with `Cache-Control: public, max-age=31536000, immutable`; use `no-store` for +missing/error responses. These headers are deployment configuration: the static +Nix output alone cannot set HTTP headers. Keep immutable caching for successful +registry hash responses too. Registry health is `/_arboricx/health`; a `/health` convenience route must rewrite to it. An ordinary static-only server can show the ecosystem but cannot run these demos or the compiler without the registry route. +### Immutable asset caching + +For nginx, add this map in the `http` context: + +```nginx +map $status $arboricx_asset_cache { + default "no-store"; + 200 "public, max-age=31536000, immutable"; + 206 "public, max-age=31536000, immutable"; + 304 "public, max-age=31536000, immutable"; +} +``` + +In the static site's `server`, use its existing document root and add: + +```nginx +location ~ "/assets/[0-9a-f]{64}/" { + try_files $uri =404; + add_header Cache-Control $arboricx_asset_cache always; +} +``` + +This also matches subdirectory deployments. Ensure other location rules allow +this handler to serve versioned assets. Keep HTML revalidation in the ordinary +static handler. A fresh Worker then uses the browser HTTP cache for its complete +JS dependency graph, even after demo changes, Stop or recompilation. Runtime +heaps and module instances remain isolated per Worker. + +Deploy the HTML and asset directory together. To support pages left open during +a deployment, retain older asset directories alongside the current one for the +chosen support window. Missing old releases return an error; they must not be +rewritten to current assets or the HTML entrypoint. Reloading selects the current +release. Keep matching registry bundles available as well. + +The source checkout retains unversioned paths for development. Cache validation +uses the built site with production-like headers and counts server requests: + +```sh +ARBORICX_SITE_ROOT=$(nix build .#arboricxSite --no-link --print-out-paths) \ +TEST_ASSET_CACHE=1 TEST_BACKEND=haskell-wasm \ + nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c \ + node ext/demo-site/test/backend-browser.mjs unused +``` + +Repeat with `TEST_BACKEND=zig-wasm`. For Firefox, use `nixpkgs#firefox` and +`backend-firefox.mjs`. The probe exercises actual demo switching, expression +execution, recompilation and Stop, and verifies each code URL reaches the server +once. The release check verifies every versioned file and deterministic identity. + ### Disk seeding and NixOS ordering The HTTP store layout is `objects/<first-three-hash-characters>/<sha256>.arboricx`. diff --git a/ext/demo-site/asset-release.py b/ext/demo-site/asset-release.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Place a complete static dependency tree under a reproducible release URL.""" +import argparse +import hashlib +import json +from pathlib import Path + + +def freeze(root): + root = Path(root) + names = ['dist', 'runtime', 'style.css', 'catalogue.json', 'compiler.json', + 'packages.json', 'environments.json'] + files = sorted(p for name in names for p in + ((root / name).rglob('*') if (root / name).is_dir() else [root / name]) + if p.is_file()) + rows = [{'path': p.relative_to(root).as_posix(), 'size': p.stat().st_size, + 'sha256': hashlib.sha256(p.read_bytes()).hexdigest()} for p in files] + encoded = json.dumps(rows, sort_keys=True, separators=(',', ':')).encode() + version = hashlib.sha256(b'arboricx.site-assets.v1\0' + encoded).hexdigest() + prefix = 'assets/' + version + html = (root / 'index.html').read_text() + for name in ['dist/app.js', 'style.css']: + old = './' + name + if html.count('"' + old + '"') != 1: + raise ValueError('Expected one HTML entrypoint: ' + old) + html = html.replace('"' + old + '"', '"./' + prefix + '/' + name + '"') + destination = root / prefix + destination.mkdir(parents=True, exist_ok=False) + for name in names: + (root / name).rename(destination / name) + (root / 'index.html').write_text(html) + (root / 'assets.json').write_text(json.dumps( + {'version': version, 'base': prefix + '/', 'files': rows}, indent=2) + '\n') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('root', type=Path) + freeze(parser.parse_args().root) diff --git a/ext/demo-site/release.nix b/ext/demo-site/release.nix @@ -24,7 +24,7 @@ let --add-flags "$out/share/arboricx-site-deps/seed.py --deps $out/share/arboricx-site-deps" ''; site = pkgs.runCommand "arboricx-site" { - nativeBuildInputs = [ pkgs.typescript ]; + nativeBuildInputs = [ pkgs.typescript pkgs.python3 ]; # Retain a matching dependency pack for every retained site release/rollback. passthru.dependencies = dependencies; } '' @@ -43,6 +43,8 @@ let mkdir -p $out/runtime/haskell cp ${haskellRuntime}/{client.mjs,worker.mjs,bridge.mjs,pins.mjs,reactor.wasm} $out/runtime/haskell/ cp -r ${haskellRuntime}/{shim,data,site} $out/runtime/haskell/ + chmod u+w $out/index.html + python3 ${./asset-release.py} $out # Keep the dependency pack in this output's Nix closure, not its webroot. mkdir -p $out/nix-support echo ${dependencies} > $out/nix-support/site-dependencies diff --git a/ext/demo-site/test/asset-cache-probe.mjs b/ext/demo-site/test/asset-cache-probe.mjs @@ -0,0 +1,49 @@ +// Count requests at the server: cached DevTools entries are not network traffic. +const el=id=>document.getElementById(id),delay=ms=>new Promise(r=>setTimeout(r,ms)); +const assert=(ok,message)=>{if(!ok)throw Error(message);}; +async function status(wanted){for(let i=0;i<4500;i++){if(el('status').textContent===wanted)return;await delay(20);}throw Error('Waiting for '+wanted+': '+el('status').textContent+' '+el('compile-diagnostic').textContent);} +const change=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('change'));}; +const input=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('input'));}; +export async function run(control,backend='haskell-wasm'){ + await status('Ready'); + if(el('runtime-backend').value!==backend){change('runtime-backend',backend);await status('Ready');} + const base=new URL('../',document.querySelector('script[type=module][src]').src); + assert(/\/assets\/[a-f0-9]{64}\/$/.test(base.pathname),'Testing a versioned release'); + const html=await fetch(new URL('index.html?noProbe=1',location.href)); + assert(html.headers.get('Cache-Control')==='no-cache','HTML selects the current release through revalidation'); + const metadata=await fetch(new URL('compiler.json',base)); + assert(metadata.headers.get('Cache-Control')==='public, max-age=31536000, immutable','Metadata shares the immutable release'); + const requests=async()=>await(await fetch(control+'/requests')).json(); + const code=paths=>paths.filter(p=>p.startsWith(base.pathname)&&/\.(?:js|mjs|wasm)(?:\?|$)/.test(p)); + const evaluate=async()=>{input('expression-editor','42');change('output-type','number');change('mode','pure');el('run').click();await status('Finished');assert(el('result').textContent==='42','Evaluation works');}; + await evaluate(); // Warm the compiler and expression path too. + const warm=code(await requests());assert(warm.length>10,'Network observed the initial dependency graph'); + for(const name of ['rgb','microscope','intro']){ + change('program-picker','demo:'+name);await status('Ready');await evaluate(); + } + input('source-editor','!export answer\nanswer = 42');await status('Compiled'); + await evaluate(); + // A compile may introduce additional code; warm it before the repeat check. + const compiled=code(await requests()); + input('source-editor','!export answer\nanswer = 43');await status('Compiled');await evaluate(); + input('expression-editor','(x : x x) (x : x x)');el('run').click();await status('Running'); + el('stop-run').click();await status('Stopped'); + input('source-editor','!export answer\nanswer = 44');await status('Compiled');await evaluate(); + // The separately cancellable local-file validator has its own module Worker. + const catalogue=await(await fetch(new URL('catalogue.json',base))).json(); + const bytes=await(await fetch('/_arboricx/bundle/hash/'+catalogue[0].hash)).arrayBuffer(); + const open=()=>{const transfer=new DataTransfer();transfer.items.add(new File([bytes],'module.arboricx'));el('local-module-file').files=transfer.files;el('local-module-file').dispatchEvent(new Event('change'));}; + const checked=async()=>{for(let i=0;i<3000;i++){if(!el('local-module-file').disabled&&el('local-module-list').children.length===1)return;await delay(20);}throw Error('Local validation failed');}; + // Check runtime code reuse before warming the additional validator entrypoint. + assert(JSON.stringify(code(await requests()))===JSON.stringify(compiled),'Fresh compile/Stop Workers reuse cached code'); + open();await checked();const localWarm=code(await requests());open();await checked(); + const final=code(await requests()); + assert(JSON.stringify(final)===JSON.stringify(localWarm),'Local validator Workers reuse cached code'); + + const counts=new Map();for(const p of final)counts.set(p,(counts.get(p)||0)+1); + assert([...counts.values()].every(n=>n===1),'Demo changes and fresh Workers fetch each code URL once: '+JSON.stringify([...counts].filter(([,n])=>n>1))); + // Errors remain retryable; an unknown release must not alias current bytes. + const missing=new URL(base.href.replace(/assets\/[a-f0-9]{64}/,'assets/'+'0'.repeat(64))+'dist/app.js'); + const response=await fetch(missing);assert(response.status===404,'Unknown release is missing');assert(response.headers.get('Cache-Control')==='no-store','Errors are not cached'); + return {userAgent:navigator.userAgent,checks:['versioned graph; demo switching; Stop/recompile; local validation; each code URL fetched once; missing release no-store'],codeRequests:final.length}; +} diff --git a/ext/demo-site/test/asset-release.py b/ext/demo-site/test/asset-release.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Determinism and invalidation tests for the complete release namespace.""" +import importlib.util +import json +from pathlib import Path +import sys +import tempfile + +spec = importlib.util.spec_from_file_location('assets', sys.argv[1]) +assets = importlib.util.module_from_spec(spec) +spec.loader.exec_module(assets) +with tempfile.TemporaryDirectory() as temporary: + parent = Path(temporary) + entries = ['dist/app.js', 'dist/wasm-worker.js', 'dist/local-module-worker.js', + 'dist/dependency.js', 'runtime/haskell/worker.mjs', + 'runtime/haskell/shim/index.js', 'runtime/haskell/reactor.wasm', + 'runtime/haskell/data/compiler.dag', 'runtime/zig/site-session.mjs', + 'runtime/zig/arboricx.wasm', 'catalogue.json', 'packages.json', + 'compiler.json', 'environments.json', 'style.css'] + def build(number, changed=None, reverse=False): + root = parent / str(number) + root.mkdir() + (root / 'index.html').write_text('<link href="./style.css"><script type="module" src="./dist/app.js"></script>') + for name in reversed(entries) if reverse else entries: + file = root / name + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(name + (' changed' if name == changed else '')) + assets.freeze(root) + manifest = json.loads((root / 'assets.json').read_text()) + base = root / manifest['base'] + for name in entries: + assert (base / name).read_text() == name + (' changed' if name == changed else '') + assert not (root / name).is_file() + return manifest + first = build(0) + assert first == build(1, reverse=True), 'Creation order must not affect identity' + for i, name in enumerate(entries, 2): + assert build(i, name)['version'] != first['version'], name +print('PASS deterministic release identity; every asset kind invalidates the namespace') diff --git a/ext/demo-site/test/backend-benchmark.mjs b/ext/demo-site/test/backend-benchmark.mjs @@ -1,6 +1,6 @@ // Matched production Worker benchmark. No evaluator instrumentation or GC tuning. export async function run(control,backend,memoryOnly=false){ - const base=new URL('/nested/site/',location.href); + const base=new URL('../',document.querySelector('script[type=module][src]').src); const [fixtures,catalogue,{Graph},{view}]=await Promise.all([ fetch('/zig-fixtures.json').then(r=>r.json()),fetch(new URL('catalogue.json',base)).then(r=>r.json()), import(new URL('dist/graph.js',base)),import(new URL('dist/codecs.js',base)), diff --git a/ext/demo-site/test/backend-browser.mjs b/ext/demo-site/test/backend-browser.mjs @@ -53,7 +53,7 @@ try { return result.result.value; }; for(let i=0;!await evaluate(`location.href===${JSON.stringify(url)} && document.readyState==='complete'`);i++){if(i>400)throw Error('Page did not load');await new Promise(r=>setTimeout(r,25));} - const report=await evaluate(`import(${JSON.stringify(process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS}))`); + const report=await evaluate(`import(${JSON.stringify(process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS}))`); assert.deepEqual(exceptions,[]); if(process.env.TEST_LIVE_UX||process.env.TEST_IDE){ for(const width of [320,390,1440]){ diff --git a/ext/demo-site/test/backend-probe.mjs b/ext/demo-site/test/backend-probe.mjs @@ -7,7 +7,7 @@ const assert={ export async function run(control, selectedBackend='haskell-wasm') { assert.equal([...document.getElementById('runtime-backend').options].some(x=>x.value===selectedBackend),true,'Selected backend must be offered by the real page'); const selectBackend=async()=>{const el=document.getElementById('runtime-backend');el.value=selectedBackend;el.dispatchEvent(new Event('change'));}; - const compilerHash=(await (await fetch(new URL('./compiler.json',location.href))).json()).hash; + const compilerHash=(await (await fetch(new URL('../compiler.json',document.querySelector('script[type=module][src]').src))).json()).hash; const evaluate=async expression=>(0,eval)(expression); const requestLog=async()=> (await fetch(control+'/requests')).json(); const fault=async values=>{const r=await fetch(control+'/fault',{method:'POST',body:JSON.stringify(values)});if(!r.ok)throw Error('Fault control failed');}; diff --git a/ext/demo-site/test/backend-resume-probe.mjs b/ext/demo-site/test/backend-resume-probe.mjs @@ -1,6 +1,6 @@ // Exercise actual page-facing request IDs, independently of private runtime IO. export async function probeResume(backend){ - const worker=new Worker(new URL('dist/wasm-worker.js?backend='+backend,location.href),{type:'module'}),events=[]; + const worker=new Worker(new URL('./wasm-worker.js?backend='+backend,document.querySelector('script[type=module][src]').src),{type:'module'}),events=[]; worker.onmessage=e=>events.push(e.data); const assert=(ok,message)=>{if(!ok)throw Error(message);}; const sleep=()=>new Promise(r=>setTimeout(r,40)); diff --git a/ext/demo-site/test/backend-server.mjs b/ext/demo-site/test/backend-server.mjs @@ -6,12 +6,14 @@ import {randomBytes} from 'node:crypto'; import {fixtures} from './zig-negative-fixtures.mjs'; export async function startSiteServer(directory,autoRun=false) { const root=resolve(process.env.ARBORICX_SITE_ROOT||'ext/demo-site'),runtime=resolve(directory),registry=resolve('ext/demo-site/bundles'); + const assetManifest=await readFile(resolve(root,'assets.json'),'utf8').then(JSON.parse).catch(()=>null); + const assetRoot=assetManifest?resolve(root,assetManifest.base):root; const control='/test-'+randomBytes(24).toString('hex'),requests=[],marks=[]; let failRuntime=false,compilerMismatch=false,moduleMissing=null,moduleCorrupt=null,moduleManifest=null,deliver,fail; const completion=new Promise((resolve,reject)=>{deliver=resolve;fail=reject;});completion.catch(()=>{}); const server=createServer(async(req,res)=>{ try { - const path=new URL(req.url,'http://localhost').pathname;requests.push(path); + const url=new URL(req.url,'http://localhost'),path=url.pathname;requests.push(process.env.TEST_ASSET_CACHE?path+url.search:path); res.setHeader('Cache-Control','no-store'); if(path===control+'/requests'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify(requests));return;} if(path===control+'/fault'||path===control+'/report'||path===control+'/phase') { @@ -26,6 +28,7 @@ export async function startSiteServer(directory,autoRun=false) { else {data.ok?deliver(data.report):fail(Error(data.error));} res.end('ok');return; } + if(path==='/asset-cache-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./asset-cache-probe.mjs',import.meta.url)));return;} if(path==='/zig-negative.json'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify(fixtures()));return;} if(path==='/zig-fixtures.json'){res.setHeader('Content-Type','application/json');const data=JSON.parse(await readFile(new URL('../demos.json',import.meta.url)));for(const id of ['parser','lexer'])data.demos[id]=JSON.parse(await readFile(new URL('../'+id+'-demo.json',import.meta.url)));res.end(JSON.stringify(data));return;} if(path==='/zig-session-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./zig-session-probe.mjs',import.meta.url)));return;} @@ -40,12 +43,12 @@ export async function startSiteServer(directory,autoRun=false) { if(path==='/live-ux-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./live-ux-probe.mjs',import.meta.url)));return;} if(path==='/backend-probe.mjs'){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('./backend-probe.mjs',import.meta.url)));return;} if(failRuntime&&(path.endsWith('/reactor.wasm')||path.endsWith('/arboricx.wasm')))throw Error('Injected artifact failure'); - if(compilerMismatch&&path==='/nested/site/compiler.json') { - const descriptor=JSON.parse(await readFile(resolve(root,'compiler.json'))); + if(compilerMismatch&&path.endsWith('/compiler.json')) { + const descriptor=JSON.parse(await readFile(resolve(assetRoot,'compiler.json'))); res.setHeader('Content-Type','application/json');res.end(JSON.stringify({...descriptor,hash:'0'.repeat(64)}));return; } - if(moduleManifest&&path==='/nested/site/packages.json'){ - const index=JSON.parse(await readFile(resolve(root,'packages.json'))); + if(moduleManifest&&path.endsWith('/packages.json')){ + const index=JSON.parse(await readFile(resolve(assetRoot,'packages.json'))); const other=index.releases.find(r=>r.name==='tricu-lib-packages-io'); index.releases=index.releases.map(r=>r.manifest===moduleManifest?{...r,hash:other.hash}:r); res.setHeader('Content-Type','application/json');res.end(JSON.stringify(index));return; @@ -63,9 +66,11 @@ export async function startSiteServer(directory,autoRun=false) { let bytes=await readFile(file); if(moduleCorrupt&&path==='/_arboricx/bundle/hash/'+moduleCorrupt){bytes=Buffer.from(bytes);bytes[0]^=1;} if(autoRun&&path==='/nested/site/index.html'&&!new URL(req.url,'http://localhost').searchParams.has('noProbe'))bytes=Buffer.from(bytes.toString()+`<script type="module"> - try{const m=await import(${JSON.stringify(process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')});const report=await m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS});await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:true,report})});} + try{const m=await import(${JSON.stringify(process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')});const report=await m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS});await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:true,report})});} catch(e){await fetch(${JSON.stringify(control+'/report')},{method:'POST',body:JSON.stringify({ok:false,error:String(e.stack||e)})});} </script>`); + if(process.env.TEST_ASSET_CACHE && ['/nested/site/index.html','/nested/site/assets.json'].includes(path))res.setHeader('Cache-Control','no-cache'); + if(process.env.TEST_ASSET_CACHE && assetManifest && path.startsWith('/nested/site/'+assetManifest.base))res.setHeader('Cache-Control','public, max-age=31536000, immutable'); res.end(bytes); }catch{res.writeHead(404);res.end();} }); diff --git a/ext/demo-site/test/ide-probe.mjs b/ext/demo-site/test/ide-probe.mjs @@ -11,7 +11,7 @@ export async function run(control,backend='haskell-wasm'){ await status('Ready'); await syntaxProbe(); assert([...el('runtime-backend').options].map(o=>o.value).join(',')==='haskell-wasm,zig-wasm','Only Haskell and Zig are production runtimes'); - for(const path of ['worker.js','compiler-worker.js','conformance/worker.js','conformance/compiler-worker.js'])assert((await fetch(new URL('dist/'+path,location.href))).status===404,'JS conformance Workers are not shipped: '+path); + for(const path of ['worker.js','compiler-worker.js','conformance/worker.js','conformance/compiler-worker.js'])assert((await fetch(new URL('./'+path,document.querySelector('script[type=module][src]').src))).status===404,'JS conformance Workers are not shipped: '+path); assert(el('program-picker').value==='demo:intro','Start Here loads on arrival'); assert(!document.querySelector('[role=tab],#graph-result,#export-definition'),'No tabs or graph-JSON surface'); assert(!el('result').textContent&&!el('terminal').textContent,'Arrival does not execute'); diff --git a/ext/demo-site/test/live-ux-probe.mjs b/ext/demo-site/test/live-ux-probe.mjs @@ -17,9 +17,10 @@ export async function run(control,backend='haskell-wasm',alreadyUsed=false){ const requests=async()=>await(await fetch(control+'/requests')).json(); if(!alreadyUsed)assert(!(await requests()).some(x=>x.endsWith('/data/compiler.dag')),'Loading alone does not fetch the compiler kit'); const fixtures=await(await fetch('/zig-fixtures.json')).json(); - const {Graph}=await import('/nested/site/dist/graph.js'); - const {view,readList}=await import('/nested/site/dist/codecs.js'); - const {printLiteral}=await import('/nested/site/dist/literal-input.js'); + const base=new URL('../',document.querySelector('script[type=module][src]').src); + const {Graph}=await import(new URL('dist/graph.js',base)); + const {view,readList}=await import(new URL('dist/codecs.js',base)); + const {printLiteral}=await import(new URL('dist/literal-input.js',base)); for(const [name,demo] of Object.entries(fixtures.demos)){ change('program-picker','demo:'+name);await status('Ready'); const c=demo.haskellCases[0];change('export',c.export); diff --git a/ext/demo-site/test/local-module-probe.mjs b/ext/demo-site/test/local-module-probe.mjs @@ -7,7 +7,7 @@ const status=s=>wait(()=>el('status').textContent===s); const input=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('input'));}; const change=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('change'));}; export async function run(control,backend){ - const base=new URL('/nested/site/',location.href); + const base=new URL('../',document.querySelector('script[type=module][src]').src); const {createRuntimeSession}=await import(new URL('dist/runtime-session.js',base)); const {validateLocalModule}=await import(new URL('dist/local-modules.js',base)); const {encodeModule}=await import(new URL('dist/module-bundle.js',base)); @@ -84,7 +84,7 @@ export async function run(control,backend){ change('runtime-backend',backend==='haskell-wasm'?'zig-wasm':'haskell-wasm'); el('compile').click();await status('Compiled');input('expression-editor','answer');el('run').click();await status('Finished'); equal(el('result').textContent,'42','Local bytes survive Stop and backend replacement'); - const frame=document.createElement('iframe');frame.src=new URL('index.html?noProbe=1',base).href;document.body.append(frame); + const frame=document.createElement('iframe');frame.src=new URL('index.html?noProbe=1',location.href).href;document.body.append(frame); await wait(()=>frame.contentDocument?.getElementById('status')?.textContent==='Ready'); equal(frame.contentDocument.getElementById('local-module-list').children.length,0,'A new page has no persisted local modules');frame.remove(); [...el('local-module-list').querySelectorAll('button')].find(b=>b.textContent==='Remove').click(); diff --git a/ext/demo-site/test/module-probe.mjs b/ext/demo-site/test/module-probe.mjs @@ -8,7 +8,7 @@ async function status(value){for(let i=0;i<4500;i++){if(el('status').textContent const input=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('input'));}; const change=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('change'));}; export async function run(control,backend='haskell-wasm'){ - const base=new URL('/nested/site/',location.href); + const base=new URL('../',document.querySelector('script[type=module][src]').src); const {createRuntimeSession}=await import(new URL('dist/runtime-session.js',base)); const {packageIndex,packageReleases}=await import(new URL('dist/packages.js',base)); const releases=packageIndex(await(await fetch(new URL('packages.json',base))).json()); diff --git a/ext/demo-site/test/release.py b/ext/demo-site/test/release.py @@ -15,6 +15,21 @@ def main(): parser.add_argument('--deps', type=Path, required=True) parser.add_argument('--seeder', type=Path, required=True) args = parser.parse_args() + # Every relative runtime/Worker/metadata URL stays in the same release tree. + assets = json.loads((args.site / 'assets.json').read_text()) + encoded = json.dumps(assets['files'], sort_keys=True, separators=(',', ':')).encode() + assert assets['version'] == hashlib.sha256(b'arboricx.site-assets.v1\0' + encoded).hexdigest() + assert assets['base'] == 'assets/' + assets['version'] + '/' + html = (args.site / 'index.html').read_text() + for entry in ['dist/app.js', 'style.css']: + assert '"./' + assets['base'] + entry + '"' in html + assert not (args.site / 'dist').exists() + args.site = args.site / assets['base'] + assert sorted(p.relative_to(args.site).as_posix() for p in args.site.rglob('*') if p.is_file()) == [r['path'] for r in assets['files']] + for row in assets['files']: + data = (args.site / row['path']).read_bytes() + assert len(data) == row['size'] + assert hashlib.sha256(data).hexdigest() == row['sha256'] pack = args.deps / 'share/arboricx-site-deps' manifest = json.loads((pack / 'dependencies.json').read_text()) rows = manifest['bundles'] diff --git a/ext/demo-site/test/zig-session-probe.mjs b/ext/demo-site/test/zig-session-probe.mjs @@ -3,7 +3,7 @@ const assert=(ok,message)=>{if(!ok)throw Error(message);}; const equal=(a,b,label)=>assert(JSON.stringify(a)===JSON.stringify(b),label+': '+JSON.stringify(a).slice(0,160)); async function rejects(fn,pattern){try{await fn();}catch(e){assert(pattern.test(e.message),'Wrong rejection: '+e.message);return;}throw Error('Expected '+pattern);} export async function run() { - const base=new URL('/nested/site/',location.href); + const base=new URL('../',document.querySelector('script[type=module][src]').src); const {create}=await import(new URL('runtime/zig/site-session.mjs',base)); const fixtures=await(await fetch('/zig-fixtures.json')).json(); const catalogue=await(await fetch(new URL('catalogue.json',base))).json(); diff --git a/flake.nix b/flake.nix @@ -520,6 +520,7 @@ checks.arboricxSite = pkgs.runCommand "arboricx-site-release-check" { nativeBuildInputs = [ pkgs.python3 ]; } '' + python3 ${./ext/demo-site/test/asset-release.py} ${./ext/demo-site/asset-release.py} python3 ${./ext/demo-site/test/release.py} \ --site ${siteRelease.arboricxSite} --deps ${siteRelease.arboricxSiteDeps} \ --seeder ${./ext/demo-site/seed.py}