Build, run, and deployment#
This page explains what “build” means for Cellucid, how to run it reliably, and what to watch for when deploying the web app (or hosting datasets).
Key idea: Cellucid is a static ES-module web app#
Cellucid is shipped as static files (HTML/CSS/JS + data assets). There is no required bundler step to run it locally:
cellucid/index.htmlstarts the app from its boot script (<script id="cellucid-page-boot">, CELLUCID_PAGE_BOOT), which imports the entry once the document is parsed, which is once its stylesheet is in (<script id="cellucid-style-gate">, CELLUCID_STYLE_GATE).On this machine (
localhost,127.0.0.1,[::1]: a development server, the browser tests’ server, the Python and R packages’ servers) the entry isassets/js/app/main.js, and the browser loads the ES modules directly from the server. Every script URL names its bytes (main.js?v=<first 12 hex digits of its SHA-256>); an import map inindex.htmldoes the same for every import. A static server ignores the query, so a module you edit loads before you have run the generator.Anywhere else (a deployed site, a notebook served through a proxy) the entry is the page bundle: the same module graph built by esbuild (pinned in
package.json) intoassets/page/, minified with function and class names kept, the parts loaded on demand (import(): the analysis panel, the benchmark, the figure renderers) in chunks of their own, every file named by its content (<name>.<content hash>.js). The boot script asks for the entry and every file it imports at startup at once, from the head (modulepreload). A module that reads its own address (import.meta) is not bundled but imported at its versioned URL (PAGE_EXTERNALSinscripts/build-page-bundle.mjs): the worker scripts, the worker-start registry andapp/asset-url.js.Workers follow the same rule: their sources on this machine, a bundle per worker in
assets/workers/anywhere else.Stylesheets name what they import the same way: every
@importandurl()inassets/css/carries the version of its file (?v=), and each page preloads every stylesheet its stylesheet imports (one round trip, not one per@importlevel).The generator writes all of it (checklist item 5); the bundles are committed with the sources, so a host needs no build of its own.
That makes development simpler, but it also means your server configuration matters.
Run locally (static server)#
Recommended minimal server:
cd cellucid
python -m http.server 8000
Then open http://localhost:8000.
Why this is required:
Browsers restrict ES module imports and
fetch()behavior underfile://.A real HTTP server makes your behavior match real deployments.
Deploy the web app (static hosting)#
Any static host works (GitHub Pages, Netlify, S3+CloudFront, Cloudflare Pages, nginx, Apache), as long as it satisfies the constraints below.
Deployment checklist (web app files)#
Correct MIME types for JS modules
.jsmust be served as JavaScript (e.g.text/javascript).If your host serves
.jsastext/plain, module imports will fail.
Don’t break module paths
The app uses relative module imports under
assets/js/….If you deploy under a subpath, verify that relative URLs still resolve.
CSP expectations
cellucid/index.htmldefines CSP in a<meta http-equiv="Content-Security-Policy">.The policy is
default-src 'self'. Every JavaScript dependency is vendored underassets/external/and served from the app’s own origin; the only external script origins allowed are Google’s tag manager and, loaded only when a Google Drive file needs them, Google’s sign-in and picker (accounts.google.com,apis.google.com; the picker’s frame comes fromdocs.google.com). Adding a CDN dependency requires editing this policy, which is the intended friction.script-srcgrants'wasm-unsafe-eval'and nothing else that evaluates code: the readers compile the WebAssembly decoders (assets/wasm/codecs.wasm: zstd 1.5.6, lz4 1.10.0 and c-blosc 1.21.6, built from their pinned release tarballs byscripts/build-wasm-codecs.mjs), and JavaScript evaluation ('unsafe-eval') stays refused. Onlyindex.htmlruns the readers, so only it grants it;tests/data-loading-contract.test.mjsandtests/browser/content-security-policy.spec.mjsfail if'unsafe-eval'is granted or'wasm-unsafe-eval'is missing.If you move CSP into an HTTP header (recommended for production), keep it consistent with required endpoints (datasets, link providers, Google/Microsoft sign-in, GitHub worker, analytics).
If you edit the inline JSON-LD
<script type="application/ld+json">, the CSP hash inscript-srcmust be updated or the browser will block it.
Know what your hostname turns on
Analytics initialises only on
cellucid.com,www.cellucid.comandtheislab.github.io. A deployment on any other hostname — including a staging copy of the production site — reports nothing, by design.If you deploy an internal instance for sensitive data, that is the property you are relying on. Document it for your reviewers: Privacy model.
A deploy never pairs files of two builds from a cache
GitHub Pages answers every file with
Cache-Control: max-age=600, and the headers cannot be changed; a static server without cache headers (python -m http.server) lets the browser guess a freshness of its own. A page could then run a module its browser had cached from the build before a deploy beside one fetched from the build after it, and stop on “does not provide an export named …”.So every script is requested at a URL that names its bytes:
<path>?v=<first 12 hex digits of its SHA-256>.index.htmlcarries an import map listing everyassets/**/*.jsat its versioned URL (a module’s imports, relative or not, are resolved to a URL and then looked up there), and each<script src>of the shipped pages carries the same?v=. A file whose bytes did not change keeps its URL, so it stays cached across deploys; a changed one has a URL no cache has seen.scripts/generate-web-asset-inventory.mjswrites the map, the versions and thescript-srchashes of each page’s inline scripts (the map and the JSON-LD block) with the inventory;npm testrefuses a tree where any is stale (tests/web-asset-inventory-contract.test.mjs), andtests/browser/deploy-skew.spec.mjssimulates deploys in all three engines.Workers. No browser gives a worker an import map (measured 2026-10-25 in Chromium, Firefox and WebKit), so a worker started from its source would ask for each of its imports by plain path, and could meet a cached file of the previous build. So the generator also builds every worker (the AnnData reader, decode, gunzip, spatial-tree, point-raster, figure-export, analysis and benchmark workers, and the two modules the decode workers import by URL) with esbuild (pinned in
package.json) into one file,assets/workers/<name>.<first 12 hex digits of its SHA-256>.js, with a source map to the sources beside it, and writesassets/workers/scripts.js, which names each. A page served under any name butlocalhost,127.0.0.1or[::1]starts every worker from its bundle: one request at an address no other build uses, so a worker never mixes builds. A server on this machine (a development server, the browser tests’ server, the Python and R packages’ servers) serves the files as they are, and there workers start from their sources: nothing is built while developing, andassets/js/workers/worker-script.jsis the only place the two differ (the URL a worker starts from). The bundles are committed beside the sources (GitHub Pages builds nothing), listed in the inventory with their source maps (so the Python and R web caches download and verify them like every other file) and allowed by the policy’sworker-src 'self';npm testrefuses a bundle that is not exactly what the sources build (tests/worker-bundles-contract.test.mjs). A page that outlived its build asks for a bundle the host no longer has: the worker fails to start and the page says Cellucid has been updated with Reload, as for a part loaded on demand.The page bundle. The same reasoning applies to the page’s own graph on a deployed site:
assets/page/<name>.<esbuild content hash>.js(built byscripts/build-page-bundle.mjs, written by the generator after the worker bundles, listed in the inventory with its source maps;npm testrefuses a bundle that is not exactly what the sources build:tests/page-bundle-contract.test.mjs). The import map lists only the files whose name does not already name their bytes.The code cache. GitHub Pages’
max-age=600makes a visit ten minutes after the last revalidate every file. A deployed page registersservice-worker.js(a root file, since a service worker controls only pages below its folder) once it has loaded, with its build id (?build=), and names the files it loaded. The worker keeps every same-origin file underassets/whose address names its bytes (a?v=file only when its bytes hash to its version, since the CDN may answer a new version’s address with old bytes in the minutes after a deploy; a bundle file by its name) and answers later requests for them from the cache. Nothing else goes through it (where the browser supports it,addRoutessends everything else straight to the network; Firefox 153 has noaddRoutes, so there those requests reach the worker, which leaves them to the network). Caches are per build; the current build’s and the newest other’s are kept. A deploy cannot make it serve stale code: a kept file’s address names its bytes, so a new build’s changed file is a new address; the new page registers the worker under its own?build=, the new worker takes over at once (skipWaiting) and, as it activates, removes every build’s cache but its own and the newest other’s (a tab still open on the previous build finds its files there). It is never registered on this machine. Specs run without service workers (playwright.config.mjs);tests/browser/code-cache.spec.mjsandtests/code-cache-contract.test.mjscover it. Playwright’s Firefox runs only the spec’s first visit: under its automation protocol (Juggler) a navigation the worker leaves to the network commits without a controller, so its next visit never starts under the worker. Measured 2026-10-31 with a 20-line worker as well as the app: the same Firefox 153 build, run with no automation (a page that drove its own second visit) or through Playwright’s WebDriver BiDi channel (channel: 'moz-firefox', same executable), starts the next visit under the worker and took none of the 22 startup files and the stylesheet from the network (Chromium and WebKit none either; Firefox under Juggler all 23). A Juggler page is controlled only when the worker answers the navigation itself, which the code cache does not do for a test harness’s sake: every page load would wait on the worker. Firefox reportsworkerStart0 for a file its worker answered, so the spec’s witness for “answered by the worker” holds in Chromium and WebKit only.What a content address cannot reach, and what the app does about it:
A page that outlived its build. GitHub Pages’ CDN ignores the query string (a never seen
?v=on an existing file is answeredx-cache: HIT, measured 2026-10-23), and the origin keeps only the current files. A tab open across a deploy, or anindex.htmlstill in the browser’s cache for up to ten minutes, asks for its own build’s URLs and receives the next build’s bytes. At startup, a module that fails to download, parse, link or run shows the Cellucid could not load card (ui/core/code-load-watch.js), which asks the server for its build id and says Cellucid has been updated when that is not the page’s; Reload loads one build. A part loaded on demand later (app/app-code.jsloadAppCode: the benchmark panel, the figure export renderers, Plotly) that fails says the same in a notification with Reload.
Whether GitHub Pages purges its CDN on a deploy is not measured. If it does not, a page loaded within ten minutes of a deploy can receive a file’s previous bytes at its new URL; a mismatch that fails to link is shown as above.
Dataset stores are not affected in the browser: every request into a Zarr store bypasses the HTTP cache (see Data loading pipeline and caching, “HTTP cache”).
Catch-all route rewrites
Cellucid is a single page, but it is not using a framework router that needs wildcard rewrites.
Do not serve
index.htmlfor missing data files; that turns a missing-file response into a misleading JSON parse error.
Host datasets safely#
Many Cellucid workflows involve hosting datasets separately from the app (e.g. a dataset catalog on GitHub Pages, an institutional HTTP server, or a CDN). This section highlights server behaviors that commonly break loading.
1) CORS: allow the app origin to fetch your dataset files#
If you load a dataset hosted at https://example.org/my_datasets/ from https://www.cellucid.com, your dataset host must send CORS headers like:
Access-Control-Allow-Origin: https://www.cellucid.com
For a development or internal server, you may choose to allow * (public datasets only).
This requirement has no alternate browser-side transport: if the dataset host does not authorize the app origin, host the files on an endpoint whose CORS policy you control.
2) Serve dataset bytes as they are, and honour Range#
Cellucid reads an HDF5 file (.h5ad, .h5mu, .loom, 10x .h5), a Zarr ZIP
or a Zarr store’s chunks in place with HTTP range requests
(Range: bytes=a-b), and decompresses chunks itself (using
DecompressionStream). R objects and MatrixMarket files are read once from
the start (access: 'stream' in data/input-formats.js).
That means:
The host must answer a range request with
206 Partial Contentand exactly those bytes; a host that ignoresRangemakes every read download the whole file.The host should not apply
Content-Encoding: gzipto these files: byte ranges then address the transformed stream, not the file.The file’s size must be known (
HEADwithContent-Length, or the provider’s own listing).
3) Avoid returning HTML for missing JSON/binary files#
Some static hosts return a friendly HTML page for 404s. From the app’s perspective, that looks like:
“JSON parse failed” (
datasets.json, Zarr metadata)“Unexpected token
<”
A Zarr store folder on a web host is read key by key, and an absent key must be
a real 404: Zarr reads a missing chunk as fill values.
Always verify that a missing asset is a real 404 (not 200 with HTML).
4) .cellucid-session files and proxies#
Session files are binary .cellucid-session files (session format 4: the
history of a dataset). Some proxies compress or transform unknown binary types.
A starting view a dataset catalog names is pinned by its exact byte length and
SHA-256, so a host that transforms it gets it refused.
If you host sessions:
Prefer serving them as raw bytes (no transformations).
If your host compresses them, be aware
Content-Lengthmay not reflect decompressed size (Cellucid treats it as a hint, but extreme misconfiguration can still break loads).
Troubleshooting (deployment failures)#
Symptom: “Module script failed to load”#
Likely causes (ordered):
Wrong MIME type for
.js.You deployed under a subpath and module import URLs are wrong.
A caching layer is serving a module of another build (checklist item 5).
How to confirm:
DevTools → Network → click the failing
.jsrequest → check Response Headers and preview.
Fix:
Adjust static host configuration to serve
.jsas JavaScript.A card or notification saying Cellucid has been updated: choose Reload (checklist item 5). If a module still does not fit after a reload in your own checkout, run
node scripts/generate-web-asset-inventory.mjs: anindex.htmlwhose import map names old versions keeps asking for them, and the browser may answer them from its cache.
Symptom: “Dataset loads on my machine but not for others”#
Likely causes:
CORS headers missing on your dataset host.
You accidentally relied on local filesystem access or a local server.
Fix:
Host the dataset on a real HTTP server with explicit CORS.
Test from a clean browser profile/incognito.
Next: Configuration, environment variables, and feature flags.