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:
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}