Skip to content

perf(website): prerender, cache, and optimize printable resume - #41

Open
visomi-dev wants to merge 6 commits into
mainfrom
perf/website-prerender-and-cache-headers
Open

visomi-dev wants to merge 6 commits into
mainfrom
perf/website-prerender-and-cache-headers

Conversation

@visomi-dev

@visomi-dev visomi-dev commented Aug 15, 2026 •

Copy link
Copy Markdown
Owner

Reduces TTFB and origin load by serving every non-form page as static HTML at build time, and gives Cloudflare / browser caches explicit directives so they can do their job without us round-tripping to the SSR runtime.

Prerender

Add export const prerender = true; to the four static pages in each locale:

  • /, /journey/, /projects/, /resume/ (en)
  • /es/, /es/journey/, /es/projects/, /es/resume/ (es)

/contact/ and /es/contact/ stay SSR because they use Astro actions for the form submission. Build now reports "Prerendered 8 static routes" and the output has real dist/apps/web/site/client/{,es/}*/index.html files served by the gateway.

Cache headers (two-layer policy)

Static assets (CSS, JS, images, fonts via the gateway's static handler):

  • Cache-Control: public, max-age=31536000, immutable
  • Weak ETag based on mtime + size
  • Correct Content-Type per extension (text/javascript, text/css, image/webp, font/woff2, ...). The previous version served everything as application/octet-stream, which made the browser refuse to load any module script or stylesheet -- this is what was breaking the WebGL starfield in the dev preview.

Prerendered HTML (also via the static handler):

  • Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400 -- 5 min browser, 1 h CDN, 24 h SWR
  • Weak ETag for cheap 304 round-trips
  • Security headers: X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: DENY, Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()

SSR pages (/contact/, /es/contact/, Astro middleware):

  • Cache-Control: private, no-store, Vary: Cookie -- the form must never be cached.

Implementation

  • New apps/web/site/src/middleware.ts (Astro middleware) applies the security + cache headers on SSR responses and skips the API namespace.
  • Replaced the gateway's express serveStatic for the Astro client folder with a custom servePrerenderedAsset middleware that resolves the file, sends the right Content-Type, emits weak ETag, and picks the right Cache-Control per file kind. Falls back to the original serveStatic for anything the custom one cannot resolve.

Tests

  • New apps/web/site-e2e/src/website-cache.spec.ts: 30 specs covering HTML/asset ETag round-trips, cache-control directives on every prerendered path, the SSR-only contact paths with private, no-store, and the Cache-Control: no-store contract on the es contact page (which had slipped the first iteration).
  • New gateway unit tests for the static handler + ETag + 304.
  • The pre-existing SPA navigation still hits the SSR endpoint test was updated to assert the architecture we actually ship now: the destination HTML still comes from the server (it is a static file served by the gateway, not the SSR runtime) and the contact page still hits the SSR middleware.

Verification

  • nx run site:test -> 22/22 + coverage 80.45%.
  • nx run site:typecheck -> 0 errors.
  • nx run site:lint -> clean.
  • nx run server:test -> 4/4.
  • nx run server:typecheck -> 0 errors.
  • nx run server:lint -> clean.
  • nx run site:build -> 8 prerendered routes OK.
  • nx run server:build -> OK.
  • nx run site-e2e:e2e -> 68/68 (chromium).

Manual headers (dev server, port 8080)

  • / -> Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400, Content-Type: text/html; charset=utf-8.
  • /_astro/background.*.js -> Cache-Control: public, max-age=31536000, immutable, Content-Type: text/javascript; charset=utf-8.
  • /_astro/base-layout.*.css -> Cache-Control: public, max-age=31536000, immutable, Content-Type: text/css; charset=utf-8.
  • /es/contact/ -> Cache-Control: no-store, must-revalidate (SSR middleware).

Printable resume

  • Removed the numbered resume labels and the // 2026 // RESUME print marker.
  • Expanded the summary to the full printable width.
  • Reworked contact details into a compact three-column print grid.
  • Added a dedicated print-only employment heading with company, role, dates, and country on one line without changing the normal HTML layout.
  • Reduced print gaps and line spacing while keeping the PDF readable.
  • Arranged technical skills, freelance work, education, and languages into compact columns.
  • The generated A4 resume now fits in 2 pages.

Resume verification:

  • pnpm exec nx run site-e2e:screenshots-resume-print --skipNxCache -> passed, 2-page A4 PDF.
  • pnpm exec nx lint site -> passed.

Reduces TTFB and origin load by serving every non-form page as static
HTML at build time, and gives Cloudflare / browser caches explicit
directives so they can do their job without us round-tripping to the
SSR runtime.

## Prerender

Add `export const prerender = true;` to the four static pages in
each locale:

- /, /journey/, /projects/, /resume/ (en)
- /es/, /es/journey/, /es/projects/, /es/resume/ (es)

Contact stays SSR because it uses Astro actions for the form
submission. Build now reports "Prerendered 8 static routes" and the
output has real `dist/apps/web/site/client/{,es/}*/index.html`
files served by the gateway.

## Cache headers

Two-layer cache policy:

**Static assets** (CSS, JS, images, fonts via the gateway's static
handler):
- `Cache-Control: public, max-age=31536000, immutable`
- `ETag` (weak, based on mtime + size)
- Correct `Content-Type` per extension (text/javascript, text/css,
  image/webp, font/woff2, ...) -- previous version served everything
  as `application/octet-stream`, which made the browser refuse to
  load any module script or stylesheet.

**Prerendered HTML** (also via the static handler):
- `Cache-Control: public, max-age=300, s-maxage=3600,
  stale-while-revalidate=86400` -- 5 min browser, 1 h CDN, 24 h
  SWR.
- `ETag` for cheap 304 round-trips.
- `X-Content-Type-Options: nosniff`,
  `Referrer-Policy: strict-origin-when-cross-origin`,
  `X-Frame-Options: DENY`,
  `Permissions-Policy: camera=(), microphone=(), geolocation=(),
  interest-cohort=()`.

**SSR pages** (`/contact/`, `/es/contact/`, Astro middleware):
- `Cache-Control: private, no-store, Vary: Cookie` -- the form must
  never be cached.

## Implementation

- New `apps/web/site/src/middleware.ts` (Astro middleware) applies
  the security + cache headers on SSR responses and skips the API
  namespace.
- Replaced the gateway's express `serveStatic` for the Astro client
  folder with a custom `servePrerenderedAsset` middleware that
  resolves the file, sends the right Content-Type, emits weak
  ETag, and picks the right Cache-Control per file kind. Falls back
  to the original `serveStatic` for anything the custom one cannot
  resolve (defense in depth).

## Tests

- New `apps/web/site-e2e/src/website-cache.spec.ts`: 30 specs
  covering HTML/asset ETag round-trips, cache-control directives on
  every prerendered path, the SSR-only contact paths with
  `private, no-store`, and the `Cache-Control: no-store` contract on
  the es contact page (which had slipped the first iteration).
- New gateway unit tests for the static handler + ETag + 304.
- The pre-existing `SPA navigation still hits the SSR endpoint`
  test was updated to assert the architecture we actually ship now:
  the destination HTML still comes from the server (it is a static
  file served by the gateway, not the SSR runtime) and the contact
  page still hits the SSR middleware.

## Verification

- `nx run site:test` -> 22/22 + coverage 80.45%.
- `nx run site:typecheck` -> 0 errors.
- `nx run site:lint` -> clean.
- `nx run server:test` -> 4/4.
- `nx run server:typecheck` -> 0 errors.
- `nx run server:lint` -> clean.
- `nx run site:build` -> 8 prerendered routes OK.
- `nx run server:build` -> OK.
- `nx run site-e2e:e2e` -> 68/68 (chromium).

Manual headers (dev server, port 8080):
- `/` -> `Cache-Control: public, max-age=300, s-maxage=3600,
  stale-while-revalidate=86400`, `Content-Type: text/html;
  charset=utf-8`.
- `/_astro/background.*.js` -> `Cache-Control: public,
  max-age=31536000, immutable`, `Content-Type: text/javascript;
  charset=utf-8`.
- `/_astro/base-layout.*.css` -> `Cache-Control: public,
  max-age=31536000, immutable`, `Content-Type: text/css;
  charset=utf-8`.
- `/es/contact/` -> `Cache-Control: no-store, must-revalidate`
  (SSR middleware).
The pipeline was failing on PR #41 with a SIGSEGV inside
`pnpm exec nx start-ci-run` ("Command was killed with SIGSEGV
(Segmentation fault)"). This workspace has no Nx Cloud token configured,
so the step only emits the "Nx Cloud is not enabled" notice and exits.
The CLI binary segfaults intermittently on fresh ubuntu-latest runners
when no access token is present (a known nx 22.6.3 race), and `bash -e`
in the workflow then aborts the rest of the pipeline before lint /
test / build / typecheck ever run.

Wrap the command in `|| true` so a failure or a SIGSEGV here cannot
prevent the subsequent `pnpm exec nx run-many -t lint test build
typecheck` from running. The actual work happens in the explicit
`run-many` step that follows, so this is purely defensive.

Verified by rerunning the failed workflow: with this change the
`run-many` step successfully ran lint, test, build and typecheck on
all 12 projects.
Same SIGSEGV race as `nx start-ci-run`: `nx fix-ci` segfaults
intermittently on a fresh ubuntu-latest runner when Nx Cloud is not
enabled. The previous patch only covered start-ci-run, so after that
ran cleanly the pipeline still aborted at fix-ci.

Both commands only emit the "Nx Cloud is not enabled" notice in this
workspace, so the step is functionally a no-op here. Keep `|| true` on
both so a flaky CLI signal cannot block lint/test/build/typecheck.

Verified locally that `nx run-many -t lint test build typecheck`
already ran cleanly on the previous run; this is just plugging the
last gap.
@visomi-dev visomi-dev changed the title perf(website): prerender static pages and tier cache headers perf(website): prerender, cache, and optimize printable resume Aug 20, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant