Drawing the Real Night Sky in a BrowserSection 8 of 10

Delivery and the Standalone Pages

How a site with no server-side code is shipped, where the multi-megabyte assets come from, and how eight standalone 3D pages are kept from carrying each other's code in a form a machine can check.


This section is about delivery. Where does the build output live? Where do the multi-megabyte textures and binaries come from? And who checks that the eight , which sit alongside — the night-sky scene that opens at the site root, called that here to keep it apart from those eight — are not quietly dragging each other into the browser? The answer fits into four build settings and four machine checks, which are laid out in a table at the end of this section.

Static Export and Cloudflare Pages

The whole delivery setup fits in the four settings of next.config.mjs (the full file is in the section "The Big Picture: No Server-Side Code"). With nothing running on a server at request time, there is very little to configure in the first place.

Each of the four settings has a concrete consequence.

  • output: 'export' — every route is written out as static HTML. Nothing can branch on a server at request time, so metadata routes such as the sitemap have to declare explicitly that they are produced once, during the build.
  • images: { unoptimized: true } — Next.js image optimisation is a server feature that runs per request. It cannot work in a , so it has to be turned off.
  • trailingSlash: true — output takes the shape about/index.html. Internal links must therefore be written with the trailing slash, /about/; /about returns a 404.
  • env — bakes the variable that decides where big assets are served from into the build output.

Deployment is handled by ' Git integration. When the main branch moves, Cloudflare fetches the repository, builds it, and serves the static-export output directory out/ as-is. There is no continuous-integration setup in the repository, and reproducibility of the build environment is delegated to Cloudflare's build image. Because there is no automatic place to run them, all four checks in this section are decided by a machine but always started by a person. Two of them ride along in the normal test suite, but running that suite is a manual act as well.

Big Assets and R2: One Variable Moves the Origin

Large files are served from Cloudflare . What makes the split worth doing is the total. Pages does cap a single file at 25 MB, but each of these files fits under that, so the per-file cap is not what forces the issue. A local mirror of the files kept on R2 measures about 94 MB in total, most of it the deep field's sheets (each galaxy cutout is 64 across in one set and 32 in the other, 290 sheets per set, 580 in all, some 66 MB) and the Moon globe's normal map, a single image about 4,000 pixels on a side that carries surface relief as shading, at about 9 MB. Packing that into the static-export output would mean re-placing 94 MB on every deploy and carrying the same bytes in the repository. So the local mirror is split in two: most of it sits in r2-assets/, a working directory git does not track, and only part of it in public/, which is in the repository. Of the sprite sheets, public/ holds just the first 15 of each set, 30 files; the other 550 exist only in r2-assets/ and in the bucket.

Only files listed in the asset manifest go to R2; the manifest is a hand-written list of the files that belong in the R2 bucket. The precomputed (four files, about 7.7 MB in total) are not in the manifest, so they are served from the same origin as the rest of Pages. Switching the origin is done by exactly one function.

export function assetUrl(path: string): string {
  const base = process.env.NEXT_PUBLIC_R2_BASE_URL ?? ''
  const normalized = path.startsWith('/') ? path : `/${path}`
  return base ? `${base}${normalized}` : normalized
}
The one function that switches the origin of big assets

With the variable set, the path resolves to the R2 custom domain; without it, to the same-origin /data/stars-full.bin, the full star catalogue. Call sites write no branch at all.

That is where the trap is. Production always sets the variable, so every path handed to assetUrl() resolves to R2 and never falls back to Pages. Forget to upload a file that assetUrl() refers to, and it 404s in production only. A default build leaves assetUrl() same-origin, so the bug is invisible locally and invisible to the browser tests.

The guard is the asset manifest plus two checks that read it.

  • The reference is the asset manifest. It is a hand-maintained array of constants in TypeScript, with two kinds of entry: exact objects, and prefixes meaning "everything under here is on R2". Adding an entry makes uploading that file a required deploy step. Conversely, an entry listed here but missing from the bucket, or differing in size there, fails the pre-deploy check.
  • A check in the normal test suite reads the source of the app code and the build scripts, without running any of it, and picks out every assetUrl() call site, failing if the resolved path is not in the manifest. A call whose argument cannot be read statically does not pass unless it is registered in an allowlist inside that same check file, together with the paths it can produce and why that is safe.
  • A pre-deploy command sends HEAD requests to the live bucket. It fails on missing objects and also on stale ones, where the size does not match the local mirror in r2-assets/ and public/. That catches the other flavour of the bug: regenerating a file that already lives on R2 and forgetting to re-upload it. Since the test is a size comparison, a regenerated file that happens to keep the same byte count slips through. Skip the command, and the 404 in production is what tells you.

The bucket carries a CORS policy allowing the Pages origin to read it. That, too, bit once. An object cached at the edge before the rule took effect keeps being served from cache without the permission header. curl returns 200 with correct bytes; only the browser fails to load it, because curl does not evaluate CORS at all. The fix is purging that URL from the cache, and ever since, every CORS change comes with a look at which URLs were fetched before it.

Bundle Isolation: Checking That Eight Scenes Never Mix

Besides the main sky there are eight standalone 3D pages: the Moon globe, the moonwalk, the deep field, the Mars globe, the , the interstellar flight, the eclipse viewer and the projector. None of them may share 3D scene code with the sky, or with each other. If they mixed, a visitor would download the and loaders of a scene they never opened.

What isolation is about is which chunks come down when a given page is opened.

The chunk boundary is exactly one dynamic() call from next/dynamic, with ssr: false, per page, and that call must live in a module marked use client. Write the same dynamic() inside a and it is not a boundary at all. With no boundary, the scene's chunk gets pulled into every page sharing the layout. That is what happened.

The likely reason is that a call written in a server component is resolved inside the build-time module graph and so never becomes a split point in the code sent to the browser. What is certain, though, is the outcome: the code did mix. The lesson is that identical-looking code gives opposite results depending on where it sits, and that is the easiest thing in this design to get wrong. The only place the mistake shows up is a failure of the isolation check that reads the built bytes, and that check is started by hand, so if nobody runs it nobody finds out.

Enforcement is a script that inspects the build output. It does not analyse the import graph. It reads the JavaScript chunks under out/ as text and looks for marker strings. What matters is not the source dependency graph but the bytes a visitor actually downloads. A tool reading imports would have to predict how the bundler splits chunks, shares them, and removes unused code, and when that prediction is wrong the check stops being trustworthy. The unit of checking is one standalone page's worth, called a group here; with eight pages there are eight groups.

Markers have to be strings that survive the renaming a does to variable names. The name of a sits inside a string literal on the JavaScript side, because that is how it is handed to the shader, so unlike an ordinary variable name it cannot be rewritten. DOM attribute names and the paths of fetched data files also appear as string literals, and so have the same property.

    name: 'moon',
    // uDisplacementScale / uNormalFlipV — moonMaterial.ts's GLSL uniforms,
    // survive minification inside string literals. moon-features — the IAU
    // gazetteer extract fetched by the label overlay.
    markers: ['uDisplacementScale', 'uNormalFlipV', 'moon-features'],
The bundle-isolation script: the marker definition for the Moon group

Choosing them takes care. basis_transcoder, which decodes textures, is shared by several pages, so making it one group's marker would false-positive against the others. The interstellar flight's uLimitingMag is out because the sky's own shader has a uniform of the same name. The bare word projector is out as well, because it does double duty. It is the name of the standalone page called the projector (/projector/), and it has also long been in the icon definitions of the navigation every page mounts, as ProjectorIcon, the glyph for the main sky's — the full-screen view that hides the controls and leaves only the sky. Used as a marker, it would report the main sky page as a violation on day one. Being unique to that group is a hard requirement for a marker. Choosing the strings, though, is a human act: nothing machine-checks that a marker stands for the bulk of that scene, so a weak choice could pass while something has in fact leaked.

Each group is checked in two directions: a negative one, which requires that no marker appears where it must not, and a positive one, which requires that every marker still exists somewhere in the build output. The negative side looks at the pages that should be isolated from the group — the main sky, the site's about page, and the seven standalone 3D pages outside that group — reading which chunks each of them loads off that page's HTML, from the URLs in its script tags and its preload hints. Without the positive side, renaming a single shader variable would turn the negative claims into the vacuous statement that a string nobody uses appears nowhere, and the guard would silently stop guarding.

The script . If a page turns out to reference zero chunks, it stops rather than passing: either the build output is broken or the framework changed how it emits script tags, and either way the guard can see nothing. If any marker has vanished from the build output, it stops with "Markers rotted" and prints how to choose a replacement.

The exception to isolation is limited to modules that contain no at all. A module that never touches 3D drawing can be shared, because the only thing that then flows into the other chunk is that small module: the scene graph, the shaders and the texture loading all stay behind the lazy boundary — and that weight behind the boundary is exactly what isolation exists to keep out.

There are two instances of it. First, the React shell around a standalone page — its readouts and control panel — imports that scene's clock singleton and nothing else from the scene tree. The orrery's panel, for example, reads the scene clock so it can show which date and time in the solar system is on screen. Second, the GLSL strings and numeric constants that define how a star is drawn live in one module which both the main sky and the interstellar flight import. Until it is handed to a shader, GLSL is only text, and the constants are only numbers, so that module depends on no three.js at all. The sky the interstellar flight draws from the Sun's position is meant to be indistinguishable from the sky page's own star field: one astronomical unit, the distance from the Sun to the Earth, is roughly a 270,000th of the distance to the nearest star, so the pattern of stars looks the same from either end of it. It stays the same only if the two pages literally share the same formulas and the same tuned constants; two copies would quietly drift apart.

The One Thing Every Page Mounts

Whether the eight pages stay isolated depends on what the one component every page mounts imports. The shared navigation menu is mounted on every route. So a single import there of the sky's stores, of its 3D scene tree, or of would push all of that into all eight bundles.

So the menu takes the display language as a plain lang prop and looks its strings up in a dedicated label table. The main sky keeps its i18n in a React context, and that context reads the language out of the sky store; having the menu use it would drag that store onto every page for exactly the reason above. The language is settled differently on the two kinds of page. The main sky page remembers the language last chosen and opens in it, so one URL there can come up in either language. A standalone page has separate URLs for its Japanese and English versions, so which language it renders in follows from the URL alone, and the prop is simply passed down from there. The one exception is the language switch, which genuinely does need sky state: on the main sky page the display language lives in a Zustand store and switching writes to it, so the menu supplies only a slot for that control and the sky page injects the real one.

Two checks hold the rule. One runs in the normal test suite: it reads every file of the menu and fails on a forbidden import. The other is the bundle-isolation script described above, which catches the same violation minutes later in the built bytes. The first is fast but sees only import statements, not what the bundler actually placed in which chunk; the second is slow but looks at the very bytes a visitor is served.

The four machine checks of this section, side by side:

CheckWhat it looks atWhen it runs
Manifest matchevery assetUrl() call site, for a path the manifest does not listthe normal test suite (npm test)
Navigation import checkevery file of the shared menu, for a forbidden importthe normal test suite (npm test)
Live bucket probeR2 itself, via HEAD: missing objects and stale ones whose size differsa separate command, run locally before pushing to main
Bundle isolationthe built JavaScript as text, for markers present and absenta separate command, after the build and before pushing to main

Delivery design here came down to turning things that break only in production into things a machine can find. A forgotten upload became a scan of the call sites in the source plus a HEAD against the bucket; a collapsed bundle boundary became a byte-level read of the build output. The isolation check in particular is built to fail, rather than pass quietly, when the check itself stops working — when a marker has vanished from the output, or when a page turns out to load no chunks at all.