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.
- Import three.js from two hosts. The engine calls
import()oncdn.jsdelivr.netand, only if that fails,unpkg.com. Both must be inscript-src. Allowing only jsDelivr looks fine until the day jsDelivr is unreachable and the fallback is blocked too. - Run an inline module script. The standalone page's
entire engine is inside
<script type="module">in the document. - Apply an inline stylesheet, and load a
data:image — the photograph, plus adata:favicon on themed pages.
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.
- Probe the file.
--probe 0,0.5,1opens the page headlessly, scrolls to each fraction, screenshots it, and flags a frame that is blank or that landed at the wrong assembly value — it reads the engine's published--assemblyback and compares it to what you asked for. Note what it cannot do: the probe opens your local file overfile://, so it never exercises your host's headers and will never catch a CSP or MIME mistake. Probe the effect, then load the deployed URL to check the deployment. - Turn WebGL off and reload. The page mounts the
photograph as a still, publishes assembly as
1, and setsdata-forge-fallbackon<html>. Confirm the still is framed the way you want and that anything paced against the assembly reads over a finished image. It is a design state, not an error. - Open it on a phone. Instance counts a desktop GPU
shrugs off are not always comfortable on a handset. Size the grid with
--emit jsonbefore you ship, not after. - Check the console on the deployed URL. A clean console is the whole test. Refused to load means CSP; a MIME complaint means server types; could not load data:image… means the texture; three.js did not load from any known host means both CDNs are blocked, usually by a corporate proxy or an over-narrow policy.
- Confirm the request list once, live. The network panel on the deployed page should show your document and one three.js file. That is the promise the page makes; ten seconds confirms your deployment kept it.
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.