Skip to content

Commit 68f0058

Browse files
NateIsernclaude
andcommitted
fix(web): the assets Cloudflare Pages silently refused to upload
Metro emits every asset that lives inside a dependency under `dist/assets/node_modules/…`, and wrangler's Pages upload skips any path with a `node_modules` segment: measured on the first deploy of wallet.fairco.in, 27 of 45 files never left the machine — the icon font, Bloom's four `.woff2` faces, expo-router's chrome, maplibre's heading glyph, and `wa-sqlite.wasm`, which IS the storage engine on web. Nothing failed. The SPA rewrite caught all 27 paths and answered `200 text/html`, so the fonts fell back, the wasm fetch received an HTML body, and the deployment read as healthy from every angle a status code can see. A control file in a sibling directory of the same upload landed, which is what pins the cause on the `node_modules` name rather than on depth or path length. So the tree moves to `assets/vendor/…` and `_redirects` rewrites the emitted paths onto it — the bundles stay byte-identical, and `_headers` now covers `/assets/*` with the same one-year immutable rule the hashed JS gets. `pack:pages` then refuses to finish unless every referenced asset resolves and the rewrite precedes the catch-all; both checks were mutation-tested by hiding a file and by reordering the rules. The Electron build is deliberately untouched: it serves the export off disk through its own `app://` protocol with no rewrite engine, so it must keep the paths Metro emitted. Only the deploy path repackages. The deploy smoke now asserts a dependency asset comes back as `font/*`. That is the assertion whose absence let this ship: the bundle-hash and deep-link checks both passed while a third of the site was missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent aa18f89 commit 68f0058

5 files changed

Lines changed: 178 additions & 3 deletions

File tree

.github/workflows/deploy-web.yml

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ jobs:
5454
- name: Build the web bundle
5555
run: bun run export:web
5656

57+
- name: Repackage the export for Pages
58+
# Wrangler's Pages upload skips every `node_modules` path, and the SPA
59+
# rewrite answers the skipped assets with index.html, so the loss is
60+
# invisible from outside. `pack:pages` moves that tree and proves every
61+
# referenced asset resolves.
62+
run: bun run pack:pages
63+
5764
- name: Validate the static hosting contract
5865
# `_redirects` and `_headers` are copied out of `public/` by the export.
5966
# If either goes missing the deploy is still green — and every deep link
@@ -83,15 +90,24 @@ jobs:
8390
run: |
8491
set -euo pipefail
8592
bundle="$(basename dist/_expo/static/js/web/entry-*.js)"
93+
# A dependency asset the bundles reference by its ORIGINAL emitted
94+
# path. It must come back as a font, not as index.html: that is the
95+
# one assertion that fails when the `node_modules` tree goes missing.
96+
# Globbed from the RENAMED tree, requested under the path the
97+
# bundles actually reference — `pack:pages` has already moved
98+
# `assets/node_modules` away by the time this runs.
99+
font_rel="$(cd dist/assets/vendor && echo @expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/MaterialCommunityIcons.*.ttf)"
100+
font="assets/node_modules/$font_rel"
86101
for attempt in $(seq 1 20); do
87102
body="$(curl --fail --silent --show-error https://wallet.fairco.in/ || true)"
88103
deep="$(curl --output /dev/null --silent --write-out '%{http_code}' https://wallet.fairco.in/pockets || true)"
89-
if [[ "$body" == *"$bundle"* && "$deep" == "200" ]]; then
90-
echo "wallet.fairco.in serves $bundle and rewrites deep links."
104+
font_type="$(curl --output /dev/null --silent --write-out '%{content_type}' "https://wallet.fairco.in/$font" || true)"
105+
if [[ "$body" == *"$bundle"* && "$deep" == "200" && "$font_type" == font/* ]]; then
106+
echo "wallet.fairco.in serves $bundle, rewrites deep links, and serves $font as $font_type."
91107
exit 0
92108
fi
93109
if [[ "$attempt" -eq 20 ]]; then
94-
echo "::error::wallet.fairco.in did not converge on $bundle (deep link: $deep)."
110+
echo "::error::wallet.fairco.in did not converge on $bundle (deep link: $deep, font: $font_type)."
95111
exit 1
96112
fi
97113
sleep 15

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"electron": "electron electron/main.js",
1515
"electron:build": "bun run export:web && electron-builder",
1616
"export:web": "expo export --platform web",
17+
"pack:pages": "bun scripts/pack-pages.ts",
1718
"typecheck": "tsc --noEmit",
1819
"test": "bun test src",
1920
"lint": "expo lint",

public/_headers

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
/_expo/static/*
55
Cache-Control: public, max-age=31536000, immutable
66

7+
# Metro content-hashes every asset it copies out of the app and its
8+
# dependencies too, so the same reasoning covers /assets — including the
9+
# `assets/vendor` tree that `scripts/pack-pages.ts` renames for Pages.
10+
/assets/*
11+
Cache-Control: public, max-age=31536000, immutable
12+
713
# The entry document is the one file whose URL never changes, so it must be
814
# revalidated on every load — otherwise a browser keeps pointing at the bundle
915
# hashes of a previous deploy.

public/_redirects

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
# Metro emits assets that live inside dependencies under
2+
# `assets/node_modules/…`, and wrangler's Pages upload skips every
3+
# `node_modules` path — the icon font, Bloom's web fonts and `wa-sqlite.wasm`
4+
# never reach the CDN. `scripts/pack-pages.ts` moves that tree to
5+
# `assets/vendor/…` and this rule points the emitted paths at it, so the bundles
6+
# stay byte-identical. It MUST precede the catch-all below, which would
7+
# otherwise answer these paths with index.html.
8+
/assets/node_modules/* /assets/vendor/:splat 200
9+
110
# Expo Router exports this app with `web.output: "single"` — one index.html and
211
# client-side routing for every path. Cloudflare Pages serves static files and
312
# 404s anything without one, so a deep link (/pockets, /transaction/<txid>) or a

scripts/pack-pages.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Repackage a web export so Cloudflare Pages can actually serve it.
3+
*
4+
* Metro emits every asset that lives inside a dependency under
5+
* `dist/assets/node_modules/…` — the icon font, Bloom's four `.woff2` faces,
6+
* expo-router's chrome, and `wa-sqlite.wasm`, which IS the storage engine on
7+
* web. Wrangler's Pages upload walks the directory and skips any `node_modules`
8+
* segment outright: measured on this project, 27 of 45 files never left the
9+
* machine, and a control file in a sibling directory did.
10+
*
11+
* Nothing errors when that happens. The SPA rewrite in `public/_redirects`
12+
* catches every one of those paths and answers `200 text/html`, so the fonts
13+
* silently fall back, the wasm fetch gets an HTML body, and the deployment
14+
* looks perfect from the outside.
15+
*
16+
* So the tree moves to `assets/vendor/…` — a name the uploader has no opinion
17+
* about — and `_redirects` rewrites `/assets/node_modules/*` onto it, which
18+
* leaves the emitted bundles untouched. Then this script proves the result:
19+
* nothing under a `node_modules` path survives in the output, and every asset
20+
* path the bundles reference resolves to a real file.
21+
*
22+
* Run after `expo export`, on the deploy path only. The Electron build serves
23+
* the export straight off disk through its own `app://` protocol, with no
24+
* rewrite engine in front of it, so it must keep the paths Metro emitted.
25+
*/
26+
27+
import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
28+
import { readdir } from "node:fs/promises";
29+
import { join, resolve, sep } from "node:path";
30+
31+
const REFERENCE_PATTERN = /assets\/node_modules\/([A-Za-z0-9@._/-]+)/g;
32+
const SCANNED_EXTENSIONS = [".js", ".css", ".html", ".json"];
33+
34+
const distDir = resolve(process.argv[2] ?? "dist");
35+
const vendored = join(distDir, "assets", "vendor");
36+
const emitted = join(distDir, "assets", "node_modules");
37+
38+
async function filesUnder(directory: string): Promise<string[]> {
39+
const entries = await readdir(directory, { withFileTypes: true });
40+
const found: string[] = [];
41+
for (const entry of entries) {
42+
const path = join(directory, entry.name);
43+
if (entry.isDirectory()) {
44+
found.push(...(await filesUnder(path)));
45+
} else {
46+
found.push(path);
47+
}
48+
}
49+
return found;
50+
}
51+
52+
function fail(message: string): never {
53+
console.error(`pack-pages: ${message}`);
54+
process.exit(1);
55+
}
56+
57+
if (!existsSync(join(distDir, "index.html"))) {
58+
fail(`${distDir} holds no index.html — run \`expo export --platform web\` first.`);
59+
}
60+
61+
if (existsSync(emitted)) {
62+
if (existsSync(vendored)) {
63+
fail(
64+
`both ${emitted} and ${vendored} exist — a previous run was interrupted, ` +
65+
"so delete the export and rebuild rather than merging two trees.",
66+
);
67+
}
68+
renameSync(emitted, vendored);
69+
} else if (!existsSync(vendored)) {
70+
fail(
71+
"the export carries no dependency assets at all, under either name — the " +
72+
"asset layout changed, and this script's assumption with it.",
73+
);
74+
}
75+
76+
const files = await filesUnder(distDir);
77+
78+
const stragglers = files.filter((file) =>
79+
file.slice(distDir.length).split(sep).includes("node_modules"),
80+
);
81+
if (stragglers.length > 0) {
82+
fail(
83+
`${stragglers.length} file(s) still sit under a node_modules path and would ` +
84+
`never be uploaded, starting with ${stragglers[0]}`,
85+
);
86+
}
87+
88+
const referenced = new Set<string>();
89+
for (const file of files) {
90+
if (!SCANNED_EXTENSIONS.some((extension) => file.endsWith(extension))) continue;
91+
const contents = readFileSync(file, "utf8");
92+
for (const match of contents.matchAll(REFERENCE_PATTERN)) {
93+
referenced.add(match[1]);
94+
}
95+
}
96+
97+
if (referenced.size === 0) {
98+
fail(
99+
"no bundle references an `assets/node_modules/` path — either the export " +
100+
"stopped emitting them (making this step and the rewrite rule dead) or the " +
101+
"reference format changed, which would make this check vacuous.",
102+
);
103+
}
104+
105+
const unresolved = [...referenced].filter((relative) => {
106+
const target = join(vendored, relative);
107+
return !existsSync(target) || !statSync(target).isFile();
108+
});
109+
if (unresolved.length > 0) {
110+
fail(
111+
`${unresolved.length} referenced asset(s) have no file under assets/vendor, ` +
112+
`starting with ${unresolved[0]}`,
113+
);
114+
}
115+
116+
const redirects = join(distDir, "_redirects");
117+
if (!existsSync(redirects)) {
118+
fail("_redirects is missing from the export — the rewrite cannot be in force.");
119+
}
120+
const rules = readFileSync(redirects, "utf8")
121+
.split("\n")
122+
.map((line) => line.trim())
123+
.filter((line) => line.length > 0 && !line.startsWith("#"));
124+
const rewriteIndex = rules.findIndex((rule) =>
125+
/^\/assets\/node_modules\/\*\s+\/assets\/vendor\/:splat\s+200$/.test(rule),
126+
);
127+
const catchAllIndex = rules.findIndex((rule) =>
128+
/^\/\*\s+\/index\.html\s+200$/.test(rule),
129+
);
130+
if (rewriteIndex === -1) {
131+
fail("_redirects carries no /assets/node_modules/* -> /assets/vendor/:splat rewrite.");
132+
}
133+
if (catchAllIndex !== -1 && catchAllIndex < rewriteIndex) {
134+
fail(
135+
"the SPA catch-all precedes the asset rewrite in _redirects, so it wins and " +
136+
"every dependency asset answers with index.html.",
137+
);
138+
}
139+
140+
console.log(
141+
`pack-pages: ${referenced.size} referenced dependency assets resolve under ` +
142+
`assets/vendor, across ${files.length} files.`,
143+
);

0 commit comments

Comments
 (0)