Skip to content

Commit 8ca7395

Browse files
committed
perf(build): serve hashed assets from /_app with immutable caching
1 parent 505e857 commit 8ca7395

7 files changed

Lines changed: 278 additions & 8 deletions

File tree

‎DEPLOY.md‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ against `api.github.com` with no server-side component.
1515

1616
**Host the `dist/` build output on any static platform:** GitHub Pages, Netlify, Vercel,
1717
S3 + CloudFront, or any CDN that serves SPAs with `index.html` fallback for client-side
18-
routing.
18+
routing. Note: `public/.nojekyll` is copied into `dist/client/.nojekyll` during `pnpm run build`
19+
— keep this file when deploying to GitHub Pages so Jekyll does not strip the `_app/` directory
20+
containing hashed JS and CSS bundles.
1921

2022
**What works without a backend:**
2123
- All dashboard features (Issues, PRs, Actions tabs)
@@ -214,6 +216,20 @@ The OAuth App access token is a permanent credential (no expiry). It is stored i
214216

215217
The two tunnel endpoints (`/api/error-reporting` and `/api/csp-report`) receive untrusted browser data and forward it to Sentry. They are hardened with layered fail-fast guards (IP rate limit → Origin check → DSN validation). See `hack/docs/security-runbook.md` for the full threat model, per-endpoint rate limit values, Origin check behavior, CSP field sanitization details, Content-Length pre-check semantics, and fan-out amplification analysis.
216218

219+
### Static asset caching
220+
221+
- **Hashed bundles (`/_app/*`):** Vite outputs content-hashed JS and CSS bundles to `dist/client/_app/`. `public/_headers` configures `Cache-Control: public, max-age=31536000, immutable` (one year) on `/_app/*` while inheriting the global security headers from `/*`.
222+
- **Entry HTML and unhashed public assets (`index.html`, `/assets/*`):** `index.html` and unhashed images under `public/assets/*` (favicon, logos, social preview) have no custom `Cache-Control` rule in `public/_headers` and use the Cloudflare Workers Static Assets platform default (`Cache-Control: public, max-age=0, must-revalidate`), ensuring browsers revalidate `index.html` on every load.
223+
- **One-time cache miss on initial `/_app/` rollout:** Moving hashed bundles from `/assets/` to `/_app/` causes a one-time cache miss on the deployment that introduces `/_app/`.
224+
- **Stale-chunk 404 handling (`stale_asset_404`):** Under SPA `not_found_handling`, missing non-navigation requests (such as `<script>` or `import()` fetches for a chunk from a superseded deploy) invoke the Worker and `env.ASSETS.fetch(request)` returns the `200 text/html` SPA fallback. When `url.pathname.startsWith("/_app/")`, `assetResponse.status === 200`, and `Content-Type` starts with `text/html`, the Worker logs `stale_asset_404` and returns `404 Not Found` (`text/plain; charset=utf-8`) with `Cache-Control: no-store` and `SECURITY_HEADERS` so neither the browser nor CDN caches HTML under an immutable chunk URL.
225+
- **Accepted navigation edge case:** A direct browser *navigation* (`Sec-Fetch-Mode: navigate`) to a non-existent `/_app/*` URL is answered by Cloudflare's static asset router with `index.html` before the Worker runs and inherits the `/_app/*` immutable header from `_headers`. This is harmless (no application route or asset reference ever navigates to a missing `/_app/*` path) and avoids routing every `/_app/*` asset hit through the Worker.
226+
- **GitHub Pages `.nojekyll` requirement:** `public/.nojekyll` (emitted to `dist/client/.nojekyll`) disables Jekyll processing on GitHub Pages static-only deployments so the `_`-prefixed `_app/` directory is not dropped.
227+
- **Post-deploy verification:**
228+
```sh
229+
curl -sI https://<domain>/_app/<hashed-file> | grep -i cache-control
230+
# Expected: cache-control: public, max-age=31536000, immutable
231+
```
232+
217233
## Local Development
218234

219235
Copy `.dev.vars.example` to `.dev.vars` and fill in your values. Wrangler picks up `.dev.vars` automatically for local `wrangler dev` runs.
@@ -262,6 +278,41 @@ Configure these rules in the Cloudflare dashboard under **Security → WAF**.
262278

263279
### Custom Rules
264280

281+
#### Rule 1: Path Allowlist
282+
283+
**Rule name:** Path Allowlist (block unknown paths)
284+
**Where:** Security → WAF → Custom Rules
285+
**Action:** Block
286+
287+
**Previous expression (pre-`/_app/` rollout, synthesized from `scripts/waf-smoke-test.sh`):**
288+
```
289+
not (
290+
http.request.uri.path in {"/" "/login" "/oauth/callback" "/onboarding" "/dashboard" "/settings" "/privacy" "/index.html"} or
291+
starts_with(http.request.uri.path, "/assets/") or
292+
starts_with(http.request.uri.path, "/api/")
293+
)
294+
```
295+
296+
**Updated expression (allows `/_app/*` hashed chunks and static/metadata routes on `GET`/`HEAD`):**
297+
```
298+
not (
299+
(
300+
http.request.method in {"GET" "HEAD"} and (
301+
http.request.uri.path in {"/" "/app" "/landing" "/landing.html" "/login" "/oauth/callback" "/onboarding" "/dashboard" "/settings" "/privacy" "/index.html" "/version.json" "/robots.txt" "/sitemap.xml" "/manifest.webmanifest" "/favicon.ico"} or
302+
starts_with(http.request.uri.path, "/_app/") or
303+
starts_with(http.request.uri.path, "/assets/")
304+
)
305+
) or
306+
starts_with(http.request.uri.path, "/api/")
307+
)
308+
```
309+
310+
**Notes:**
311+
- Blocks vulnerability scanners and probes for unmapped paths (`/wp-admin`, `/.env`, `/.git/config`, arbitrary paths) at the edge before the Worker or static asset router runs.
312+
- `scripts/waf-smoke-test.sh` (`pnpm test:waf`) validates this rule before every commit (`prek`) and deploy (`deploy.yml`).
313+
314+
#### Rule 3: Origin Gate
315+
265316
**Rule name:** Block API requests without valid Origin
266317
**Where:** Security → WAF → Custom Rules
267318
**Expression:**

‎public/.nojekyll‎

Whitespace-only changes.

‎public/_headers‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@
66
Permissions-Policy: geolocation=(), microphone=(), camera=()
77
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
88
X-Frame-Options: DENY
9+
10+
/_app/*
11+
Cache-Control: public, max-age=31536000, immutable

‎scripts/waf-smoke-test.sh‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# e.g. pnpm test:waf https://my-tracker.example.com
77
#
88
# Rules validated:
9-
# 1. Path Allowlist — blocks all paths except known SPA routes, /assets/*, /api/*
9+
# 1. Path Allowlist — blocks all paths except known SPA/static routes, /_app/*, /assets/*, /api/*, and public metadata files (/version.json, /robots.txt, /sitemap.xml, /manifest.webmanifest, /favicon.ico)
1010
# 2. Scanner User-Agents — challenges empty/malicious User-Agent strings
1111
# 3. Origin Gate — blocks /api/* requests without valid Origin header
1212
# Rate limit rule exists but is not tested here (triggers a 10-minute IP block).
@@ -30,7 +30,7 @@ run_test() {
3030
[[ "$actual" != "000" && "$actual" != "502" && "$actual" != "503" ]] && break
3131
sleep "$attempt"
3232
done
33-
if [[ "$actual" == "$expected" ]]; then
33+
if [[ ",$expected," == *",$actual,"* ]]; then
3434
printf ' PASS [%s] %s\n' "$actual" "$label"
3535
else
3636
printf ' FAIL [%s] %s (expected %s)\n' "$actual" "$label" "$expected"
@@ -62,6 +62,7 @@ TESTS=(
6262
"200|GET /settings|${BASE}/settings"
6363
"200|GET /privacy|${BASE}/privacy"
6464
"307|GET /index.html (html_handling redirect)|${BASE}/index.html"
65+
"403,200,404|GET /_app/nonexistent.js|${BASE}/_app/nonexistent.js"
6566
"200|GET /assets/nonexistent.js|${BASE}/assets/nonexistent.js"
6667
"200|GET /api/health (with Origin)|-H|Origin: ${BASE}|${BASE}/api/health"
6768
"400|POST /api/oauth/token (no body)|-X|POST|-H|Origin: ${BASE}|${BASE}/api/oauth/token"
@@ -92,9 +93,9 @@ TESTS=(
9293
"403|GET /eval-stdin.php|${BASE}/eval-stdin.php"
9394
"403|GET /.aws/credentials|${BASE}/.aws/credentials"
9495
"403|GET /.ssh/id_rsa|${BASE}/.ssh/id_rsa"
95-
"403|GET /robots.txt|${BASE}/robots.txt"
96-
"403|GET /sitemap.xml|${BASE}/sitemap.xml"
97-
"403|GET /favicon.ico|${BASE}/favicon.ico"
96+
"403,200|GET /robots.txt|${BASE}/robots.txt"
97+
"403,200|GET /sitemap.xml|${BASE}/sitemap.xml"
98+
"403,200|GET /favicon.ico|${BASE}/favicon.ico"
9899
"403|GET /random/garbage/path|${BASE}/random/garbage/path"
99100
# Rule 2: Scanner User-Agents — normal UAs
100101
"200|Normal browser UA|-H|User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36|${BASE}/"

‎src/worker/index.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ function errorResponse(
107107
});
108108
}
109109

110-
const SECURITY_HEADERS: Record<string, string> = {
110+
export const SECURITY_HEADERS: Record<string, string> = {
111111
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
112112
"X-Content-Type-Options": "nosniff",
113113
"Referrer-Policy": "strict-origin-when-cross-origin",
@@ -1726,7 +1726,23 @@ export default Sentry.withSentry(
17261726
}
17271727

17281728
// Forward non-API requests to static assets
1729-
return env.ASSETS.fetch(request);
1729+
const assetResponse = await env.ASSETS.fetch(request);
1730+
if (
1731+
url.pathname.startsWith("/_app/") &&
1732+
assetResponse.status === 200 &&
1733+
(assetResponse.headers.get("content-type") ?? "").startsWith("text/html")
1734+
) {
1735+
log("warn", "stale_asset_404", { pathname: url.pathname }, request);
1736+
return new Response(request.method === "HEAD" ? null : "Not Found", {
1737+
status: 404,
1738+
headers: {
1739+
...SECURITY_HEADERS,
1740+
"Cache-Control": "no-store",
1741+
"Content-Type": "text/plain; charset=utf-8",
1742+
},
1743+
});
1744+
}
1745+
return assetResponse;
17301746
},
17311747
}
17321748
);

‎tests/worker/static-assets.test.ts‎

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import worker, { SECURITY_HEADERS, type Env } from "../../src/worker/index";
3+
import { collectLogs, findLog, ALLOWED_ORIGIN } from "./helpers";
4+
5+
function makeEnv(overrides: Partial<Env> = {}): Env {
6+
return {
7+
ASSETS: { fetch: async () => new Response("asset") },
8+
GITHUB_CLIENT_ID: "test_client_id",
9+
GITHUB_CLIENT_SECRET: "test_client_secret",
10+
ALLOWED_ORIGIN,
11+
SENTRY_DSN: "https://abc123@o123456.ingest.sentry.io/7890123",
12+
SESSION_KEY: "dGVzdC1zZXNzaW9uLWtleQ==",
13+
SEAL_KEY: "dGVzdC1zZWFsLWtleQ==",
14+
TURNSTILE_SECRET_KEY: "test-turnstile-secret",
15+
PROXY_RATE_LIMITER: { limit: vi.fn().mockResolvedValue({ success: true }) },
16+
...overrides,
17+
};
18+
}
19+
20+
describe("Worker static asset handling for /_app/*", () => {
21+
let consoleSpy: {
22+
info: ReturnType<typeof vi.spyOn>;
23+
warn: ReturnType<typeof vi.spyOn>;
24+
error: ReturnType<typeof vi.spyOn>;
25+
};
26+
27+
beforeEach(() => {
28+
consoleSpy = {
29+
info: vi.spyOn(console, "info").mockImplementation(() => {}),
30+
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
31+
error: vi.spyOn(console, "error").mockImplementation(() => {}),
32+
};
33+
});
34+
35+
afterEach(() => {
36+
vi.restoreAllMocks();
37+
});
38+
39+
it("returns 404 with no-store, SECURITY_HEADERS, and logs stale_asset_404 on GET /_app/* when ASSETS returns 200 text/html", async () => {
40+
const env = makeEnv({
41+
ASSETS: {
42+
fetch: async () =>
43+
new Response("<!doctype html><html><body>SPA</body></html>", {
44+
status: 200,
45+
headers: { "Content-Type": "text/html" },
46+
}),
47+
},
48+
});
49+
const req = new Request("https://gh.gordoncode.dev/_app/missing-abc123.js", {
50+
method: "GET",
51+
});
52+
53+
const resp = await worker.fetch(req, env);
54+
55+
expect(resp.status).toBe(404);
56+
expect(await resp.text()).toBe("Not Found");
57+
expect(resp.headers.get("Cache-Control")).toBe("no-store");
58+
expect(resp.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
59+
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
60+
expect(resp.headers.get(key)).toBe(value);
61+
}
62+
63+
const logs = collectLogs(consoleSpy);
64+
const staleLog = findLog(logs, "stale_asset_404");
65+
expect(staleLog).toBeDefined();
66+
expect(staleLog?.level).toBe("warn");
67+
expect(staleLog?.entry.pathname).toBe("/_app/missing-abc123.js");
68+
});
69+
70+
it("returns 404 on GET /_app/* when ASSETS returns 200 text/html; charset=utf-8", async () => {
71+
const env = makeEnv({
72+
ASSETS: {
73+
fetch: async () =>
74+
new Response("<!doctype html><html><body>SPA</body></html>", {
75+
status: 200,
76+
headers: { "Content-Type": "text/html; charset=utf-8" },
77+
}),
78+
},
79+
});
80+
const req = new Request("https://gh.gordoncode.dev/_app/missing-abc123.js", {
81+
method: "GET",
82+
});
83+
84+
const resp = await worker.fetch(req, env);
85+
86+
expect(resp.status).toBe(404);
87+
expect(await resp.text()).toBe("Not Found");
88+
expect(resp.headers.get("Cache-Control")).toBe("no-store");
89+
expect(resp.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
90+
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
91+
expect(resp.headers.get(key)).toBe(value);
92+
}
93+
});
94+
95+
it("returns 404 with empty body on HEAD /_app/* when ASSETS returns 200 text/html", async () => {
96+
const env = makeEnv({
97+
ASSETS: {
98+
fetch: async () =>
99+
new Response("<!doctype html><html><body>SPA</body></html>", {
100+
status: 200,
101+
headers: { "Content-Type": "text/html" },
102+
}),
103+
},
104+
});
105+
const req = new Request("https://gh.gordoncode.dev/_app/missing-abc123.js", {
106+
method: "HEAD",
107+
});
108+
109+
const resp = await worker.fetch(req, env);
110+
111+
expect(resp.status).toBe(404);
112+
expect(resp.body).toBeNull();
113+
expect(await resp.text()).toBe("");
114+
expect(resp.headers.get("Cache-Control")).toBe("no-store");
115+
expect(resp.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
116+
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
117+
expect(resp.headers.get(key)).toBe(value);
118+
}
119+
});
120+
121+
it("passes through 200 application/javascript response for /_app/index-abc.js", async () => {
122+
const env = makeEnv({
123+
ASSETS: {
124+
fetch: async () =>
125+
new Response("console.log('ok');", {
126+
status: 200,
127+
headers: {
128+
"Content-Type": "application/javascript",
129+
"Cache-Control": "public, max-age=31536000, immutable",
130+
},
131+
}),
132+
},
133+
});
134+
const req = new Request("https://gh.gordoncode.dev/_app/index-abc.js", {
135+
method: "GET",
136+
});
137+
138+
const resp = await worker.fetch(req, env);
139+
140+
expect(resp.status).toBe(200);
141+
expect(await resp.text()).toBe("console.log('ok');");
142+
expect(resp.headers.get("Content-Type")).toBe("application/javascript");
143+
expect(resp.headers.get("Cache-Control")).toBe(
144+
"public, max-age=31536000, immutable"
145+
);
146+
expect(findLog(collectLogs(consoleSpy), "stale_asset_404")).toBeUndefined();
147+
});
148+
149+
it("passes through 200 text/html SPA fallback for non-/_app/ routes like /dashboard", async () => {
150+
const html = "<!doctype html><html><body><div id='root'></div></body></html>";
151+
const env = makeEnv({
152+
ASSETS: {
153+
fetch: async () =>
154+
new Response(html, {
155+
status: 200,
156+
headers: { "Content-Type": "text/html; charset=utf-8" },
157+
}),
158+
},
159+
});
160+
const req = new Request("https://gh.gordoncode.dev/dashboard", {
161+
method: "GET",
162+
});
163+
164+
const resp = await worker.fetch(req, env);
165+
166+
expect(resp.status).toBe(200);
167+
expect(await resp.text()).toBe(html);
168+
expect(resp.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
169+
expect(findLog(collectLogs(consoleSpy), "stale_asset_404")).toBeUndefined();
170+
});
171+
172+
it("passes through 304 Not Modified response from ASSETS for /_app/x.js", async () => {
173+
const env = makeEnv({
174+
ASSETS: {
175+
fetch: async () =>
176+
new Response(null, {
177+
status: 304,
178+
headers: { ETag: '"abc123"' },
179+
}),
180+
},
181+
});
182+
const req = new Request("https://gh.gordoncode.dev/_app/x.js", {
183+
method: "GET",
184+
});
185+
186+
const resp = await worker.fetch(req, env);
187+
188+
expect(resp.status).toBe(304);
189+
expect(resp.headers.get("ETag")).toBe('"abc123"');
190+
expect(findLog(collectLogs(consoleSpy), "stale_asset_404")).toBeUndefined();
191+
});
192+
});

‎vite.config.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,11 @@ export default defineConfig({
88
build: {
99
outDir: "dist",
1010
},
11+
environments: {
12+
client: {
13+
build: {
14+
assetsDir: "_app",
15+
},
16+
},
17+
},
1118
});

0 commit comments

Comments
 (0)