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 standalone 3D page — In Asterarium, a 3D page that shares no scene code with the main sky or with the other such pages, and there are eight of them: the Moon globe, the moonwalk, the Mars globe, the deep field, the orrery, the interstellar flight, the eclipse viewer and the projector. Each one ships as its own bundle of code, so a visitor downloads only the page they opened., which sit alongside In this article, the name for the night-sky scene that opens on the site's front page. It draws the stars, constellation lines, the Milky Way, the Sun, Moon and planets through atmospheric scattering, seen from an observer standing on the ground. The name is there to tell it apart from the eight standalone 3D pages, separate pages such as the Moon globe and the projector which share no 3D code with it or with each other. — 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 Building the whole site into HTML and JavaScript files ahead of time, so nothing is assembled per request; delivery is only the handing out of files that already exist., so it has to be turned off.trailingSlash: true— output takes the shapeabout/index.html. Internal links must therefore be written with the trailing slash,/about/;/aboutreturns a 404.env— bakes the variable that decides where big assets are served from into the build output.
Deployment is handled by A hosting service that serves static files from edge locations worldwide, building and publishing from a connected Git repository.' 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 Cloudflare's object storage, the place for large files such as textures and binaries. Unlike most comparable services it charges nothing for data leaving the bucket.. 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 A point that the GPU, the chip built for rendering, draws as a square of a size given in pixels. Throwing away everything outside a circle turns it into a round star, which is the cheap way to draw a whole sky of point-like objects. This article uses the word in three senses: that point, the three.js panel that carries an image and always faces the camera (used for labels), and a sprite sheet, an image packing many small pictures into one (used for the deep-field galaxies). sheets (each galaxy cutout is 64 pixel — One of the small squares a screen is divided into; a picture is the whole array of them. Colour is decided one pixel at a time, so the cost of drawing follows roughly from how many of them have to be filled. 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 atmospheric scattering — Sunlight bouncing off the molecules and particles of the air and spreading in every direction. Blue light scatters most, which is why the daytime sky is blue, and a low Sun, whose light crosses far more air, turns red. lookup table — A precomputed array of values that is read instead of recomputed. On the GPU it is held as an image, turning an expensive calculation into a single fetch. (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
}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/andpublic/. 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 A mechanical model that shows the planets circling the Sun. Asterarium borrows the name for a standalone page that looks down on the planetary orbits and where each planet is now., 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 shader — A small program that runs on the GPU, the chip built for rendering, to compute where a vertex goes or what colour a pixel takes. It runs once for every vertex or every pixel it is handed. 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 A React component rendered at build time or on a server rather than in the browser. When the whole site is written out ahead of time, it runs exactly once, during the build. 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 minification — The step that shrinks JavaScript for release, rewriting long names into short ones and stripping whitespace. What ships therefore carries short invented names in place of the ones the source was written with. It does not touch the inside of a string literal, a piece of text in quotes, so a word written there survives intact. does to variable names. The name of a The language in which WebGL shaders, the small programs that run on the GPU, are written. It has C-like syntax and built-in vector arithmetic. A value handed into a shader, the small program running on the GPU, that stays constant for the whole draw. The current time or the direction of the Sun are typical. 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'],Choosing them takes care. basis_transcoder, which decodes A container format holding textures already compressed into a form the GPU, the chip built for rendering, can use directly. What it carries can be turned into whichever compression a given device supports, so one file serves them all. 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 In Asterarium, a full-screen state that hides every control and shows nothing but the sky. The interface fades out after a few seconds without input and comes back when the viewer moves. — 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 fail closed — A design stance of refusing rather than allowing whenever the outcome is uncertain. For a test it means never writing an assertion loose enough to pass on a blank screen.. 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 The standard JavaScript library layered over WebGL, the browser 3D interface. It expresses a picture as scenes, cameras, geometries and materials instead of raw draw calls. 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 frame — One image of a moving picture. Smooth motion needs roughly 60 of them a second, which leaves about 16 milliseconds to build each one. A coordinate frame is a different use of the same word. 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 A small state-management library for React: it builds the store, the single place where shared state lives. Each part of the interface declares which slice of that state it uses, so it is redrawn only when that slice changes. 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:
| Check | What it looks at | When it runs |
|---|---|---|
| Manifest match | every assetUrl() call site, for a path the manifest does not list | the normal test suite (npm test) |
| Navigation import check | every file of the shared menu, for a forbidden import | the normal test suite (npm test) |
| Live bucket probe | R2 itself, via HEAD: missing objects and stale ones whose size differs | a separate command, run locally before pushing to main |
| Bundle isolation | the built JavaScript as text, for markers present and absent | a 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.