wasm-viewer: open by URL, lazily, with a request counter

The page gets a URL box; ?file=<url> now opens with openUrl instead of
downloading the file, and the header shows the requests made and bytes
fetched so far ("5 requests, 5 MiB of 191 MiB fetched (2.6%)"). Every
file call is awaited, so local files (open) and remote ones share the
code; a selection that finishes after another was made is not shown.

browser.sh serves the page and fixtures with test/serve.py (no symlinks
into the repository any more) and also checks a server without range
support and, on the 200 MB file, that a small dataset and a window of
the big one render with a single-digit percentage fetched.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-27 06:44:51 -05:00
co-authored by Claude Opus 5.5
parent 5107583b97
commit b8c85f7627
2 changed files with 140 additions and 74 deletions
+94 -44
View File
@@ -56,27 +56,39 @@
border: 1px solid var(--line); border-radius: 4px; } border: 1px solid var(--line); border-radius: 4px; }
.error { color: var(--bad); font-family: var(--mono); white-space: pre-wrap; } .error { color: var(--bad); font-family: var(--mono); white-space: pre-wrap; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
form.url { display: flex; gap: 6px; margin-left: auto; flex: 1 1 320px; max-width: 560px; }
form.url input { flex: 1; min-width: 0; font: 13px var(--mono); padding: 4px 8px; background: var(--panel);
color: var(--ink); border: 1px solid var(--line); border-radius: 6px; }
.netstats { flex-basis: 100%; color: var(--muted); font-size: 12.5px; font-family: var(--mono); }
.netstats:empty { display: none; }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>HDF5 Viewer</h1> <h1>HDF5 Viewer</h1>
<span class="file" id="filename">no file</span> <span class="file" id="filename">no file</span>
<form class="url" id="urlform">
<input type="url" id="url" placeholder="https://…/file.h5 (read by HTTP range requests)" aria-label="File URL">
<button class="button" type="submit">Open URL</button>
</form>
<label class="button">Open file…<input type="file" id="picker" accept=".h5,.hdf5,.he5,.nc,.nc4,.cdf" hidden></label> <label class="button">Open file…<input type="file" id="picker" accept=".h5,.hdf5,.he5,.nc,.nc4,.cdf" hidden></label>
<span class="netstats" id="netstats"></span>
</header> </header>
<main> <main>
<nav id="tree"></nav> <nav id="tree"></nav>
<section id="detail"> <section id="detail">
<div class="drop"> <div class="drop">
<p><strong>Drop an HDF5 or NetCDF-4 file here</strong>, or use “Open file…”.</p> <p><strong>Drop an HDF5 or NetCDF-4 file here</strong>, use “Open file…”, or give a URL.</p>
<p class="muted">The file is read in this page by clawhdf5 compiled to WebAssembly; it is not uploaded anywhere.</p> <p class="muted">The file is read in this page by clawhdf5 compiled to WebAssembly; a local file is not uploaded anywhere.
A file given by URL is not downloaded: only the byte ranges each view needs are fetched (HTTP range requests),
and the requests and bytes it has cost are shown at the top.</p>
<p class="muted" id="version"></p> <p class="muted" id="version"></p>
</div> </div>
</section> </section>
</main> </main>
<script type="module"> <script type="module">
import init, { open, version } from "./pkg/clawhdf5_wasm.js"; import init, { open, openUrl, version } from "./pkg/clawhdf5_wasm.js";
import { joinPath, formatValue, viewWindow, toRows, perElement } from "./viewer-lib.js"; import { joinPath, formatValue, viewWindow, toRows, perElement, formatStats } from "./viewer-lib.js";
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const el = (tag, props = {}, ...kids) => { const el = (tag, props = {}, ...kids) => {
@@ -85,30 +97,58 @@ const el = (tag, props = {}, ...kids) => {
return e; return e;
}; };
// The open file: an H5File (a local file, read in memory; synchronous
// methods) or a RemoteFile (openUrl: bytes fetched by range requests as
// needed; methods return promises). Every call below is awaited, so both
// work the same.
let file = null; let file = null;
let selectedNode = null; let selectedNode = null;
// Bumped by every selection, so a slow read for an object no longer shown
// does not overwrite the current one.
let showSeq = 0;
await init(); await init();
$("version").textContent = `clawhdf5 ${version()}`; $("version").textContent = `clawhdf5 ${version()}`;
async function load(blob) { // Requests and bytes a remote file has cost so far.
const bytes = new Uint8Array(await blob.arrayBuffer()); function updateStats() {
$("netstats").textContent = file && file.stats ? formatStats(file.stats()) : "";
}
async function setFile(name, opener) {
if (file) file.free(); if (file) file.free();
file = null; file = null;
$("filename").textContent = blob.name; selectedNode = null;
showSeq++;
$("filename").textContent = name;
$("tree").replaceChildren(); $("tree").replaceChildren();
$("netstats").textContent = "";
$("detail").replaceChildren(el("p", { className: "muted", textContent: `Opening ${name}…` }));
try { try {
file = open(bytes); file = await opener();
} catch (e) { } catch (e) {
$("detail").replaceChildren(el("p", { className: "error", textContent: `Cannot open ${blob.name}: ${e.message}` })); $("detail").replaceChildren(el("p", { className: "error", textContent: `Cannot open ${name}: ${e.message}` }));
return; return;
} }
updateStats();
const root = el("ul", { className: "tree" }); const root = el("ul", { className: "tree" });
root.append(treeNode("/", "/", "group")); root.append(treeNode("/", "/", "group"));
$("tree").append(root); $("tree").append(root);
root.querySelector(".node").click(); await activate(root.querySelector(".node"));
} }
async function load(blob) {
const bytes = new Uint8Array(await blob.arrayBuffer());
await setFile(blob.name, async () => open(bytes));
}
async function loadUrl(url) {
$("url").value = url;
await setFile(url, () => openUrl(new URL(url, location.href).href));
}
// A tree row: clicking it selects it (and expands a group); `activate`
// does the same and resolves once the listing and the detail are shown.
function treeNode(path, name, kind) { function treeNode(path, name, kind) {
const li = el("li"); const li = el("li");
const icon = el("span", { className: "icon", textContent: kind === "group" ? "▸" : "·" }); const icon = el("span", { className: "icon", textContent: kind === "group" ? "▸" : "·" });
@@ -116,34 +156,39 @@ function treeNode(path, name, kind) {
row.dataset.path = path; row.dataset.path = path;
li.append(row); li.append(row);
let children = null; let children = null;
row.addEventListener("click", () => { row.activate = async () => {
if (selectedNode) selectedNode.classList.remove("selected"); if (selectedNode) selectedNode.classList.remove("selected");
row.classList.add("selected"); row.classList.add("selected");
selectedNode = row; selectedNode = row;
const shown = show(path, kind);
if (kind === "group") { if (kind === "group") {
if (children) { if (children) {
children.hidden = !children.hidden; children.hidden = !children.hidden;
} else { } else {
children = el("ul", { className: "tree" }); children = el("ul", { className: "tree" });
li.append(children);
try { try {
for (const c of file.list(path)) children.append(treeNode(joinPath(path, c.name), c.name, c.kind)); for (const c of await file.list(path)) children.append(treeNode(joinPath(path, c.name), c.name, c.kind));
} catch (e) { } catch (e) {
children.append(el("li", { className: "error", textContent: e.message })); children.append(el("li", { className: "error", textContent: e.message }));
} }
li.append(children); updateStats();
} }
icon.textContent = children.hidden ? "▸" : "▾"; icon.textContent = children.hidden ? "▸" : "▾";
} }
show(path, kind); await shown;
}); };
row.addEventListener("click", () => row.activate());
return li; return li;
} }
function attrsTable(path) { const activate = (row) => row.activate();
async function attrsTable(path) {
let attrs, errors; let attrs, errors;
try { try {
attrs = file.attrs(path); attrs = await file.attrs(path);
errors = file.attrErrors(path); errors = await file.attrErrors(path);
} catch (e) { } catch (e) {
return el("p", { className: "error", textContent: e.message }); return el("p", { className: "error", textContent: e.message });
} }
@@ -157,14 +202,16 @@ function attrsTable(path) {
return el("div", { className: "scroll" }, t); return el("div", { className: "scroll" }, t);
} }
function show(path, kind) { async function show(path, kind) {
const seq = ++showSeq;
const current = () => seq === showSeq;
const out = [el("h2", { textContent: path })]; const out = [el("h2", { textContent: path })];
if (kind === "dataset") { if (kind === "dataset") {
let info; let info;
try { try {
info = file.info(path); info = await file.info(path);
} catch (e) { } catch (e) {
$("detail").replaceChildren(...out, el("p", { className: "error", textContent: e.message })); if (current()) $("detail").replaceChildren(...out, el("p", { className: "error", textContent: e.message }));
return; return;
} }
const max = info.maxshape === null ? "—" : `(${info.maxshape.map((d) => d ?? "∞").join(", ")})`; const max = info.maxshape === null ? "—" : `(${info.maxshape.map((d) => d ?? "∞").join(", ")})`;
@@ -172,15 +219,16 @@ function show(path, kind) {
el("dt", { textContent: "type" }), el("dd", { textContent: info.dtype }), el("dt", { textContent: "type" }), el("dd", { textContent: info.dtype }),
el("dt", { textContent: "shape" }), el("dd", { textContent: `(${info.shape.join(", ")})` }), el("dt", { textContent: "shape" }), el("dd", { textContent: `(${info.shape.join(", ")})` }),
el("dt", { textContent: "max shape" }), el("dd", { textContent: max }))); el("dt", { textContent: "max shape" }), el("dd", { textContent: max })));
out.push(el("h3", { textContent: "Attributes" }), attrsTable(path)); out.push(el("h3", { textContent: "Attributes" }), await attrsTable(path));
out.push(el("h3", { textContent: "Values" }), valuesView(path, info)); out.push(el("h3", { textContent: "Values" }), await valuesView(path, info, current));
} else { } else {
out.push(el("h3", { textContent: "Attributes" }), attrsTable(path)); out.push(el("h3", { textContent: "Attributes" }), await attrsTable(path));
} }
$("detail").replaceChildren(...out); updateStats();
if (current()) $("detail").replaceChildren(...out);
} }
function valuesView(path, info) { async function valuesView(path, info, current) {
const shape = info.shape; const shape = info.shape;
const per = perElement(info.elementShape); const per = perElement(info.elementShape);
const state = { row: 0, col: 0, rows: 50, cols: 12, fixed: shape.slice(0, Math.max(0, shape.length - 2)).map(() => 0) }; const state = { row: 0, col: 0, rows: 50, cols: 12, fixed: shape.slice(0, Math.max(0, shape.length - 2)).map(() => 0) };
@@ -202,15 +250,19 @@ function valuesView(path, info) {
if (shape.length >= 2) controls.append(num("column", "col")); if (shape.length >= 2) controls.append(num("column", "col"));
state.fixed.forEach((_, i) => controls.append(num(`dim ${i}`, "fixed", i))); state.fixed.forEach((_, i) => controls.append(num(`dim ${i}`, "fixed", i)));
function render() { let renderSeq = 0;
async function render() {
const seq = ++renderSeq;
const w = viewWindow(shape, state); const w = viewWindow(shape, state);
let res; let res;
try { try {
res = w === null ? file.read(path) : file.readHyperslab(path, w.start, w.count); res = w === null ? await file.read(path) : await file.readHyperslab(path, w.start, w.count);
} catch (e) { } catch (e) {
body.replaceChildren(el("p", { className: "error", textContent: e.message })); if (seq === renderSeq) body.replaceChildren(el("p", { className: "error", textContent: e.message }));
return; return;
} }
updateStats();
if (seq !== renderSeq || !current()) return;
if (w === null) { if (w === null) {
body.replaceChildren(el("pre", { textContent: toRows(res.data, 1, 1, per)[0][0] })); body.replaceChildren(el("pre", { textContent: toRows(res.data, 1, 1, per)[0][0] }));
return; return;
@@ -229,41 +281,39 @@ function valuesView(path, info) {
(shape.length >= 2 ? `, columns ${w.col}–${w.col + w.cols - 1}` : "") + ` of ${total} values` }); (shape.length >= 2 ? `, columns ${w.col}–${w.col + w.cols - 1}` : "") + ` of ${total} values` });
body.replaceChildren(t, note); body.replaceChildren(t, note);
} }
render(); await render();
return box; return box;
} }
// Expand the tree down to `path` and select it. // Expand the tree down to `path` and select it.
function reveal(path) { async function reveal(path) {
const rowFor = (p) => [...document.querySelectorAll(".node")].find((n) => n.dataset.path === p); const rowFor = (p) => [...document.querySelectorAll(".node")].find((n) => n.dataset.path === p);
let cur = "/"; let cur = "/";
for (const part of path.split("/").filter(Boolean)) { for (const part of path.split("/").filter(Boolean)) {
const row = rowFor(cur); const row = rowFor(cur);
if (!row) return; if (!row) return;
const kids = row.parentElement.querySelector(":scope > ul"); const kids = row.parentElement.querySelector(":scope > ul");
if (!kids || kids.hidden) row.click(); if (!kids || kids.hidden) await activate(row);
cur = joinPath(cur, part); cur = joinPath(cur, part);
} }
const target = rowFor(cur); const target = rowFor(cur);
if (target && target !== selectedNode) target.click(); if (target && target !== selectedNode) await activate(target);
} }
// ?file=<url>&path=<object> opens a file from a URL (same origin, or one // ?file=<url>&path=<object> opens a file from a URL (same origin, or one
// that allows CORS) and selects an object in it. // that allows CORS) by range requests and selects an object in it.
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
if (params.get("file")) { if (params.get("file")) {
const url = params.get("file"); await loadUrl(params.get("file"));
try { if (file && params.get("path")) await reveal(params.get("path"));
const resp = await fetch(url); document.body.dataset.ready = "1";
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
await load(new File([blob], url.split("/").pop()));
if (file && params.get("path")) reveal(params.get("path"));
} catch (e) {
$("detail").replaceChildren(el("p", { className: "error", textContent: `Cannot fetch ${url}: ${e.message}` }));
}
} }
$("urlform").addEventListener("submit", (e) => {
e.preventDefault();
const url = $("url").value.trim();
if (url) loadUrl(url);
});
$("picker").addEventListener("change", (e) => e.target.files[0] && load(e.target.files[0])); $("picker").addEventListener("change", (e) => e.target.files[0] && load(e.target.files[0]));
document.addEventListener("dragover", (e) => { e.preventDefault(); document.body.classList.add("dragging"); }); document.addEventListener("dragover", (e) => { e.preventDefault(); document.body.classList.add("dragging"); });
document.addEventListener("dragleave", () => document.body.classList.remove("dragging")); document.addEventListener("dragleave", () => document.body.classList.remove("dragging"));
+46 -30
View File
@@ -3,10 +3,13 @@
# #
# browser.sh FIXTURE_DIR # browser.sh FIXTURE_DIR
# #
# FIXTURE_DIR holds fixture.h5 from make_fixture.py; ../pkg must be built. # FIXTURE_DIR holds fixture.h5 from make_fixture.py (and big.h5 when it was
# The page is opened with ?file=fixture.h5&path=<object>, which fetches the # run with WASM_BIG_MB); ../pkg must be built. test/serve.py serves the
# file, builds the tree down to <object> and shows it; the rendered DOM is # page and, under /fix, the fixtures, with HTTP range requests (no copies,
# dumped and checked for the values libhdf5 reads. # no links). The page is opened with ?file=fix/<name>&path=<object>, which
# opens the file with openUrl (range requests), builds the tree down to
# <object> and shows it; the rendered DOM is dumped and checked for the
# values libhdf5 reads and for the request counter.
# #
# Browser: $CHROME, else chromium/google-chrome on PATH, else a Playwright # Browser: $CHROME, else chromium/google-chrome on PATH, else a Playwright
# download under ~/.cache/ms-playwright. Exit 3 when none is found. # download under ~/.cache/ms-playwright. Exit 3 when none is found.
@@ -31,63 +34,76 @@ if [ -z "$chrome" ] || [ ! -x "$chrome" ]; then
exit 3 exit 3
fi fi
root="$(mktemp -d)" profiles="$(mktemp -d)"
server="" server=""
cleanup() { cleanup() {
[ -n "$server" ] && kill "$server" 2>/dev/null || true [ -n "$server" ] && kill "$server" 2>/dev/null || true
rm -rf "$root" rm -rf "$profiles"
} }
trap cleanup EXIT trap cleanup EXIT
ln -s "$HERE/../index.html" "$HERE/../viewer-lib.js" "$HERE/../pkg" "$FIX/fixture.h5" "$root/"
port=$("$PY" -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])') "$PY" "$HERE/serve.py" --root "fix=$FIX" --root "=$HERE/.." > "$profiles/port" &
"$PY" -m http.server --bind 127.0.0.1 --directory "$root" "$port" >/dev/null 2>&1 &
server=$! server=$!
for _ in $(seq 50); do for _ in $(seq 50); do
"$PY" -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:$port/index.html')" 2>/dev/null && break [ -s "$profiles/port" ] && break
sleep 0.1 sleep 0.1
done done
port="$(head -1 "$profiles/port")"
fails=0 fails=0
# A fresh profile per page: a second instance on the same profile fails. # A fresh profile per page: a second instance on the same profile fails.
render() { render() {
local profile local profile
profile="$(mktemp -d "$root/profile.XXXXXX")" profile="$(mktemp -d "$profiles/profile.XXXXXX")"
"$chrome" --headless --no-sandbox --disable-gpu --user-data-dir="$profile" \ "$chrome" --headless --no-sandbox --disable-gpu --user-data-dir="$profile" \
--virtual-time-budget=20000 \ --virtual-time-budget=20000 \
--dump-dom "http://127.0.0.1:$port/index.html?file=fixture.h5&path=$1" 2>/dev/null --dump-dom "http://127.0.0.1:$port/index.html?file=$1&path=$2" 2>/dev/null
} }
# expect PATH TEXT...: every TEXT appears in the page rendered for PATH. # expect FILE PATH TEXT...: every TEXT appears in the page rendered for PATH
# of FILE (a URL relative to the page); "re:TEXT" is an extended regex.
expect() { expect() {
local path="$1" dom local file="$1" path="$2" dom
shift shift 2
dom="$(render "$path")" dom="$(render "$file" "$path")"
[ -n "${BROWSER_DEBUG:-}" ] && printf "%s\n" "$dom" > "$BROWSER_DEBUG.$(echo "$path" | tr / _).html" [ -n "${BROWSER_DEBUG:-}" ] && printf "%s\n" "$dom" > "$BROWSER_DEBUG.$(echo "$file$path" | tr / _).html"
for text in "$@"; do for text in "$@"; do
if ! grep -qF -- "$text" <<<"$dom"; then local flag=-qF
echo "FAIL: page for $path lacks: $text" >&2 if [ "${text#re:}" != "$text" ]; then flag=-qE; text="${text#re:}"; fi
if ! grep $flag -- "$text" <<<"$dom"; then
echo "FAIL: page for $file $path lacks: $text" >&2
fails=$((fails + 1)) fails=$((fails + 1))
fi fi
done done
echo "rendered $path" echo "rendered $file $path"
} }
H5=fix/fixture.h5
# Tree (root expanded; the group row carries its path) and root attributes. # Tree (root expanded; the group row carries its path) and root attributes.
expect "/" 'data-path="/sensors"' 'data-path="/grid"' '<td>title</td><td>"wasm fixture"</td>' \ expect $H5 "/" 'data-path="/sensors"' 'data-path="/grid"' '<td>title</td><td>"wasm fixture"</td>' \
'<td>big</td><td>9223372036854775813</td>' '(compound{x: f64, n: i32})' '<td>big</td><td>9223372036854775813</td>' '(compound{x: f64, n: i32})'
# A chunked, deflated 2-D dataset: type, shape, the first window of values. # A chunked, deflated 2-D dataset: type, shape, the first window of values;
expect "/grid" '<dd>f64</dd>' '<dd>(6, 10)</dd>' '<th>9</th>' '<td>0.25</td>' '<td>14.75</td>' \ # the request counter.
'showing rows 0–5, columns 0–9 of 60 values' expect $H5 "/grid" '<dd>f64</dd>' '<dd>(6, 10)</dd>' '<th>9</th>' '<td>0.25</td>' '<td>14.75</td>' \
'showing rows 0–5, columns 0–9 of 60 values' 'request' 'fetched (100.0%)'
# Nested path revealed through the tree; big-endian float32. # Nested path revealed through the tree; big-endian float32.
expect "/sensors/temp" 'data-path="/sensors/temp"' '<dd>f32</dd>' '<td>21.5</td>' '<td>22.25</td>' expect $H5 "/sensors/temp" 'data-path="/sensors/temp"' '<dd>f32</dd>' '<td>21.5</td>' '<td>22.25</td>'
# 64-bit integers stay exact; strings; array datatype cells. # 64-bit integers stay exact; strings; array datatype cells.
expect "/u64" '<td>18446744073709551615</td>' expect $H5 "/u64" '<td>18446744073709551615</td>'
expect "/vlen_str" '<td>"двa"</td>' '<dd>vlen string</dd>' expect $H5 "/vlen_str" '<td>"двa"</td>' '<dd>vlen string</dd>'
expect "/pairs" '<td>[2, 3]</td>' '<dd>array[2]&lt;i32&gt;</dd>' expect $H5 "/pairs" '<td>[2, 3]</td>' '<dd>array[2]&lt;i32&gt;</dd>'
# 3-D: leading dimension held at 0, window over the last two. # 3-D: leading dimension held at 0, window over the last two.
expect "/cube" '<dd>(2, 5, 6)</dd>' '<td>29</td>' 'dim 0' expect $H5 "/cube" '<dd>(2, 5, 6)</dd>' '<td>29</td>' 'dim 0'
# Unsupported type: an error, not values. # Unsupported type: an error, not values.
expect "/table" 'class="error"' 'reading compound{x: f64, n: i32} datasets is not supported' expect $H5 "/table" 'class="error"' 'reading compound{x: f64, n: i32} datasets is not supported'
# A server without range support: the file is downloaded whole, and says so.
expect norange/$H5 "/grid" '<td>14.75</td>' 'downloaded whole'
# A large file: a small dataset, and a window of the large one, fetch a few
# blocks (the counter shows a small percentage: "(1.5%)", not "(100.0%)").
if [ -f "$FIX/big.h5" ]; then
expect fix/big.h5 "/small" '<td>1.5</td>' '<td>3.25</td>' 're:MiB of 191 MiB fetched \([0-9]\.[0-9]%\)'
expect fix/big.h5 "/big" '<dd>(25000000)</dd>' '<td>0</td>' '<td>24.5</td>' \
're:MiB of 191 MiB fetched \([0-9]\.[0-9]%\)'
fi
if [ "$fails" -gt 0 ]; then if [ "$fails" -gt 0 ]; then
echo "browser: $fails checks failed" >&2 echo "browser: $fails checks failed" >&2