Putting a forged page on the internet

A forged page is one HTML file. The styles are inline, the image is base64 inside the document, and the engine is an inline <script type="module">. It makes exactly one external request — three.js — and that request goes to a pinned version on jsDelivr, falling back to unpkg. Nothing else. There is no build step to run on the server and no runtime to install.

That makes deployment mostly trivial and then unusually specific. What goes wrong does so because the page is a module script, or because its one dependency sits on someone else's origin. Both produce the same symptom — a blank screen — so this guide starts with telling them apart.

First, look at what you actually built

Before you copy the file anywhere, read it. Two greps answer every question this guide is about:

grep -oE "https?://[^\"')]+" landing.html | sort -u
grep -oE "IMAGE_SRC +=.{0,34}" landing.html

The first should print exactly two lines — the jsDelivr and unpkg URLs for three@0.160.0/build/three.module.js. If it prints anything else, something in your pipeline added a request the forge did not. The second should print IMAGE_SRC = 'data:image/…: the photograph is embedded and the file is genuinely portable.

It will not print a data URL if you forged from an http(s) source: a local path is embedded as base64, a URL is only referenced. That page makes two external requests and needs the image to stay hosted and CORS-readable — the engine sets crossOrigin = "anonymous" on the texture, so a host that omits Access-Control-Allow-Origin gives you a page that loads, starts, and never draws. For the single-file property, forge from a local file.

Static hosts

Anything that serves bytes will do; the only consistent requirement is naming the file index.html if you want it at the root of a path.

mkdir -p dist && cp landing.html dist/index.html

# Netlify — drag dist/ onto app.netlify.com/drop, or:
npx netlify-cli deploy --dir=dist --prod

# Vercel
npx vercel deploy --prod dist

# GitHub Pages — commit to a repo with Pages enabled
cp landing.html docs/index.html && git add docs && git commit -m "hero" && git push

# S3 behind CloudFront
aws s3 cp dist/index.html s3://my-bucket/index.html --content-type text/html

# a server you already have
scp landing.html deploy@example.com:/var/www/example.com/public/hero.html

On S3, pass --content-type explicitly: an object uploaded without it is served as application/octet-stream, which the browser downloads instead of rendering.

The MIME trap, and how to recognise it

This is the failure that wastes an evening. It only bites the module build — --emit js — whose default filename is <image>-scroll.mjs.

Plenty of static servers have no mapping for .mjs. They fall back to a default type, often text/plain or application/octet-stream, and the browser refuses to execute a module script whose response is not a JavaScript MIME type. As far as your code is concerned it refuses silently: no exception, nothing rendered, and a perfectly healthy 200 in the network panel.

You recognise it by the console message — it names the MIME type and the word "module" — and you confirm it in one command:

curl -sI https://example.com/hero-scroll.mjs | grep -i content-type

Anything that is not text/javascript (or application/javascript) is the bug. Fix it at the server — nginx takes types { text/javascript mjs; }, Apache takes AddType text/javascript .mjs — or sidestep it entirely by writing the module out as .js, which every host already knows:

node cli.mjs hero.jpg --emit js --out hero-scroll.js

The standalone HTML page is immune to this, because its module script is inline and never fetched.

Content-Security-Policy

This is the one that catches people who do everything else right. If your site sends a CSP — many platforms add one by default, and any security review will add a strict one — the page has to be allowed three things it genuinely does.

A header that works:

Content-Security-Policy:
  default-src 'none';
  script-src 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com;
  style-src 'unsafe-inline';
  img-src data:;
  base-uri 'none'

If 'unsafe-inline' will not pass review, hash the inline script and the inline style and put those hashes in its place; the host allowlist still has to stay, because the import is a separate fetch from a separate origin. And if you set script-src-elem anywhere in your policy, list both hosts there too — it takes precedence over script-src for script fetches, and allowing the CDN in one directive while forgetting the other is a common way to get a blank page that looks correctly configured.

Two things you do not need: blob: and connect-src. The engine creates no object URLs, spawns no workers, and makes no fetch or XMLHttpRequest of any kind. It does write CSS custom properties at runtime — that is how --assembly is published — but that goes through the CSSOM, which CSP does not police, so no extra style-src allowance is required for it.

The symptom is distinctive: the console carries an explicit Refused to load line naming the blocked URL and the violated directive. A silent console over a blank page is a MIME or WebGL problem, not a CSP one.

Self-hosting three.js

For an air-gapped deployment, or a policy that permits no third-party origin at all, you want the library on your own domain. Be clear about what is on offer: there is no flag for this. The CLI has no option to point the emitted page at a different three.js host; the two URLs are written into the output as a literal array. Do not go looking for --three.

What works is a substitution in your deploy pipeline, applied after forging and re-applied every time you re-forge:

curl -sL https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js \
  -o dist/vendor/three.module.js

sed -e 's|https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js|/vendor/three.module.js|' \
    -e 's|https://unpkg.com/three@0.160.0/build/three.module.js|/vendor/three.module.js|' \
    landing.html > dist/index.html

Serve that file with a JavaScript MIME type — it is an ES module, so the previous section applies to it — and narrow the policy to script-src 'unsafe-inline' 'self'. Keep the version pinned to 0.160.0; the engine is written against that release. And note the trade: two independent origins have become one, with no fallback left if yours is down. A reverse proxy on a same-origin path, if you have one, keeps the substitution to a single URL and leaves upstream caching intact.

Caching headers

The artefact is unusually cacheable because it has no parts: the image is inside the HTML, so there is no second asset to version or to drift out of sync. That leaves two sensible postures.

If the page lives at a stable URL you will overwrite — a hero you update when the product photo changes — treat it like any HTML document and let revalidation do the work:

Cache-Control: public, max-age=0, must-revalidate

The revalidation matters here, because the file is a few hundred kilobytes rather than a few: serve it with ETag so an unchanged page costs a 304 and not a re-download of the embedded photograph. Check that compression is on, too — base64 is text and compresses well, and a host skipping gzip or brotli is the biggest single thing you can fix about the page's transfer size.

If instead you deploy under a content-addressed name — /hero-8f3a1c.html, regenerated rather than edited — the file is genuinely immutable and should say so:

Cache-Control: public, max-age=31536000, immutable

Never put the long-lived header on a URL you intend to overwrite. A forged page is exactly the kind of file people cache for a year and then spend an afternoon failing to update.

Pre-flight

Five minutes, in this order, before you send anyone the link.

If you are mounting the module in a site you already have rather than shipping the standalone page, the layering contract is in the embedding guide, and everything above about MIME types and CSP applies to the host page instead. For sizing the grid to the devices you are deploying to, see the performance guide. The exact semantics of every flag named here are in the documentation.