ifcviewer-web: stream remote sidecars over HTTP Range (?model=URL)

Adds a network byte-source alongside the local Blob one. The async-chunk
infra is source-agnostic — only the two JS primitives knew it was a Blob —
so this generalises them and reuses everything else:

  - ifcvReadRangeInto: local → Blob.slice; remote → fetch() with a Range
    header (206). If a server ignores Range and returns 200, the requested
    window is sliced out so it still works (without the bandwidth saving).
  - ifcvFileSize: Blob size, or the URL's total length resolved up front.
  - ifcvBeginUrlSource: resolves total size (HEAD Content-Length, else a
    0-0 ranged GET's Content-Range) then fires _ifcv_source_ready.
  - The metadata bootstrap is extracted into a source-agnostic
    loadSidecarMetadataWeb(label); loadSidecarFromBlobWeb / FromUrlWeb are
    thin entries. streaming_from_blob → streaming_from_web (now covers both).

main_web exports load_sidecar_from_url_c(url); shell.html reads a
?model=URL query param and ccalls it once the app is live (same-origin
needs no CORS; cross-origin hosts must send CORS + Accept-Ranges).

Test: serve.mjs now answers HEAD + Range (206) and falls back to the
ifcviewer-web source dir for sample.ifcview (embedded in the wasm, not in
build-web). New smoke case loads ?model=/sample.ifcview and asserts it
renders via the Range path. 6/6 web smoke + 107/107 unit pass; desktop
unaffected (web-guarded; only the shared field rename touches it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-30 12:44:37 +10:00
parent bc31e91e35
commit 23dc5dac48
8 changed files with 260 additions and 66 deletions
+46 -5
View File
@@ -14,6 +14,10 @@ const ROOT = process.env.WEB_BUILD_DIR
? path.resolve(process.env.WEB_BUILD_DIR)
: path.resolve(__dirname, '../../../build-web');
const PORT = Number(process.env.PORT || 8124);
// Fallback root: the ifcviewer-web source dir holds sample.ifcview (which is
// embedded in the wasm, not copied into build-web). Lets the remote-backend
// test fetch http://localhost/sample.ifcview over real HTTP Range.
const SRC = path.resolve(__dirname, '..');
const MIME = {
'.html': 'text/html; charset=utf-8',
@@ -28,11 +32,48 @@ http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
let p = decodeURIComponent(url.pathname);
if (p === '/') p = '/IfcViewerWeb.html';
const file = path.join(ROOT, p);
// Contain to ROOT.
if (!file.startsWith(ROOT)) { res.writeHead(403).end(); return; }
const body = await readFile(file);
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
const inRoot = path.join(ROOT, p);
const inSrc = path.join(SRC, p);
// Contain to one of the two allowed roots.
if (!inRoot.startsWith(ROOT) && !inSrc.startsWith(SRC)) { res.writeHead(403).end(); return; }
let body;
try { body = await readFile(inRoot); }
catch { body = await readFile(inSrc); } // fall back to the source dir
const ctype = MIME[path.extname(p)] || 'application/octet-stream';
// HEAD: headers only — lets the remote backend resolve total size.
if (req.method === 'HEAD') {
res.writeHead(200, {
'Content-Type': ctype,
'Content-Length': body.length,
'Accept-Ranges': 'bytes',
});
res.end();
return;
}
// Range: serve 206 partial content so the HTTP-Range backend is exercised
// exactly as a real Accept-Ranges host would (handles bytes=a-b and a-).
const range = req.headers['range'];
const m = range && /^bytes=(\d*)-(\d*)$/.exec(range.trim());
if (m) {
let start = m[1] === '' ? undefined : parseInt(m[1], 10);
let end = m[2] === '' ? undefined : parseInt(m[2], 10);
if (start === undefined) { start = body.length - end; end = body.length - 1; } // bytes=-N
if (end === undefined || end > body.length - 1) end = body.length - 1; // bytes=a-
if (Number.isNaN(start) || start > end) { res.writeHead(416).end(); return; }
const slice = body.subarray(start, end + 1);
res.writeHead(206, {
'Content-Type': ctype,
'Content-Range': `bytes ${start}-${end}/${body.length}`,
'Accept-Ranges': 'bytes',
'Content-Length': slice.length,
});
res.end(slice);
return;
}
res.writeHead(200, { 'Content-Type': ctype, 'Accept-Ranges': 'bytes' });
res.end(body);
} catch {
res.writeHead(404).end('not found');