Prerender content pages and drop client-side zod#30
Conversation
Two changes that take the perf work from "fast when warm" to "fast and consistent", and cut the client JS for a content site down to analytics only. Prerender the content pages (index, about, work, briefs, legal, privacy, 404) to static HTML served from the Cloudflare CDN edge instead of an on-demand Worker render. This removes per-request Worker execution (and cold-start TTFB variance) from the content pages — the likely cause of the 5.1s LCP a mobile PageSpeed run reported while local Lighthouse with the same throttling scored 100. API + OIDC routes stay SSR. To make that possible, client telemetry no longer depends on per-request SSR config injection: the App Insights iKey + ingestion endpoint (already public — they ship in every page) move to a shared telemetry-config module, and the `environment` dimension is resolved from the hostname on each side (client from location, server from the request). The connection-string secret parse and the `__telemetryConfig` <script> injection are gone. build.format is set to "file" so prerendered pages emit as `about.html` (served at /about) rather than `about/index.html` (which 307-redirects /about -> /about/), keeping URLs identical to the existing canonical, sitemap, and nav scheme — no redirect hop. Drop client-side zod: the server validation in server/briefing.ts is hand-rolled (not zod), so the ~13 KiB client zod bundle was pure duplication. Replaced with a small validate() mirroring the same rules. With zod gone the form script falls under Astro's inline threshold and is inlined into the page — one fewer render-blocking-adjacent request. zod is removed from dependencies. Verified on a local wrangler preview: all content routes 200 with no redirect; SSR routes (sitemap, llms, OIDC) intact; telemetry beacon fires on the static page (self-config works); form validation produces the correct per-field errors and clears on valid input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
decipher-ms | c6ed36e | Commit Preview URL Branch Preview URL |
May 29 2026, 05:57 PM |
There was a problem hiding this comment.
Pull request overview
This PR shifts key content routes from per-request SSR on Cloudflare Workers to prerendered static HTML (to stabilize TTFB/LCP globally), and removes client-side zod to reduce shipped JS by replacing it with a small hand-rolled validator. It also updates telemetry so prerendered pages can self-configure without SSR injecting config into the HTML.
Changes:
- Mark content pages (
/,/about,/work,/legal,/privacy,/briefs/*,/404) asprerender = trueand configure Astro build output for “file” format to preserve canonical no-trailing-slash URLs. - Remove SSR-injected telemetry config and centralize App Insights identifiers + environment resolution in a shared module.
- Drop
zodfrom the client bundle and replace with lightweight client-side form validation.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/pages/index.astro | Enable prerendering for the homepage. |
| src/pages/about.astro | Enable prerendering for the about page. |
| src/pages/work.astro | Enable prerendering for the work page. |
| src/pages/legal.astro | Enable prerendering for the legal page. |
| src/pages/privacy.astro | Enable prerendering for the privacy page. |
| src/pages/404.astro | Enable prerendering for the 404 page. |
| src/pages/briefs/advanced-dataverse-and-dynamics-365-security-model.astro | Enable prerendering for the brief content page. |
| astro.config.mjs | Set build.format = "file" alongside existing inline stylesheet config. |
| src/layouts/Layout.astro | Remove SSR injection of window.__telemetryConfig. |
| src/middleware.ts | Update server telemetry initialization to no longer depend on Worker env bindings. |
| src/lib/telemetry-config.ts | New shared telemetry constants + environment resolution. |
| src/lib/telemetry.ts | Client telemetry now self-configures from shared config (no injected config). |
| src/lib/server/telemetry.ts | Server telemetry uses shared config constants instead of parsing a secret connection string. |
| src/lib/server/env.ts | Remove APPLICATIONINSIGHTS_CONNECTION_STRING from typed bindings. |
| src/components/BriefingForm.astro | Remove zod import and replace with hand-rolled client validation. |
| package.json | Remove zod dependency. |
| package-lock.json | Remove zod package from lockfile. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function initTelemetry(): void { | ||
| if (initialized) return; | ||
| initialized = true; | ||
| config = window.__telemetryConfig ?? null; | ||
| if (!config) return; | ||
| // Identifiers are public constants; the environment is derived client-side so | ||
| // prerendered static pages need no per-request server-injected config. | ||
| config = { |
There was a problem hiding this comment.
Good catch — confirmed during preview testing (a beacon actually fired from localhost). Fixed in c6ed36e: initTelemetry() returns early when resolveEnvironment(location.hostname) === "development", restoring the prior 'no injected config => no emit' behavior. Preview/prod still send, tagged by environment. 🤖 Claude Code
| async flushNow(): Promise<void> { | ||
| if (!this.conn || this.envelopes.length === 0) return; | ||
| if (this.envelopes.length === 0) return; | ||
| const payload = this.envelopes.map((e) => JSON.stringify(e)).join("\n"); | ||
| this.envelopes = []; | ||
| const url = `${this.conn.ingestionEndpoint}/v2.1/track`; | ||
| const url = `${AI_INGESTION_ENDPOINT}/v2.1/track`; | ||
| try { |
There was a problem hiding this comment.
Agreed — same fix server-side in c6ed36e: push() short-circuits when this.environment === "development", so no envelopes accumulate and flushNow() never POSTs from local preview. Deployed prod/preview hostnames are unaffected. 🤖 Claude Code
| const validate = (v: FormValues): Partial<Record<FieldName, string>> => { | ||
| const e: Partial<Record<FieldName, string>> = {}; | ||
| if (!v.name.trim()) e.name = "Required"; | ||
| else if (v.name.length > 120) e.name = "Keep this under 120 characters"; | ||
| if (!v.email.trim()) e.email = "Required"; | ||
| else if (v.email.length > 255) e.email = "Keep this under 255 characters"; | ||
| else if (!EMAIL_RE.test(v.email.trim())) e.email = "Invalid email"; | ||
| if (v.role.length > 120) e.role = "Keep this under 120 characters"; | ||
| if (!(ENGAGEMENT_KEYS as readonly string[]).includes(v.engagementType)) | ||
| e.engagementType = "Pick an engagement type"; | ||
| if (!v.topic.trim()) e.topic = "Required"; | ||
| else if (v.topic.length > 200) e.topic = "Keep this under 200 characters"; | ||
| if (!v.details.trim()) e.details = "Required"; | ||
| else if (v.details.trim().length < 10) e.details = "Add a bit more detail"; | ||
| else if (v.details.length > 4000) e.details = "Keep this under 4000 characters"; | ||
| return e; | ||
| }; |
There was a problem hiding this comment.
Right — the old zod schema was .trim().max() (validated the trimmed value) and we submit trimmed, so untrimmed length checks were a behavior change. Fixed in c6ed36e: validate() now checks trimmed values for all required/length rules (and reuses the trimmed string for the email regex). Verified: ' Joe Smith ' now passes. 🤖 Claude Code
| } | ||
|
|
||
| const tel = new Telemetry(env, ctx, url.hostname); | ||
| const tel = new Telemetry(ctx, url.hostname); |
There was a problem hiding this comment.
Updated the comment in c6ed36e — locals.telemetry is now only used for server-side request/exception spans; the per-request client config injection is gone (the browser RUM client self-configures from public constants). 🤖 Claude Code
…nput - Client + server telemetry now stay silent when the resolved environment is "development" (localhost), restoring the prior "no config => no emit" behavior so local work doesn't POST to the shared App Insights resource. - Briefing form validates trimmed values for all length/required checks (the submitted payload is trimmed and the old zod schema used .trim().max()), so leading/trailing whitespace can't block an otherwise-valid submit. - Update the middleware comment: the per-request client telemetry config injection is gone; the browser RUM client self-configures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Why
Follow-up to #28/#29. A mobile PageSpeed run reported LCP 5.1 s, yet local Lighthouse with the identical throttling (Moto G Power, Slow 4G, 4× CPU) scored 100 / LCP 1.5 s, and real TTFB is 40–170 ms. The gap is environmental: the content pages were SSR on a Worker, so a slow/cold root-document response from a distant test location cascades (HTML late → fonts late → Playfair lands ~5 s → heading LCP 5.1 s). And for a content site, the client JS was carrying weight it didn't need.
Changes
1. Prerender content pages (
index, about, work, briefs/*, legal, privacy, 404) → static HTML served from the CDN edge, no per-request Worker render, no cold-start TTFB variance. API + OIDC routes stay SSR.To decouple from SSR, client telemetry self-configures: the App Insights iKey + ingestion endpoint (already public — they ship in every page today) move to a shared
telemetry-config.ts;environmentis resolved from the hostname on each side. The connection-string secret parse and the__telemetryConfiginjection are removed.build.format: "file"so pages emit asabout.html(served at/about) rather thanabout/index.html(which 307-redirects/about → /about/) — keeps URLs identical to the existing canonical/sitemap/nav scheme, no redirect hop.2. Drop client-side zod. The server validation (
server/briefing.ts) is hand-rolled, not zod — so the ~13 KiB client zod bundle was pure duplication. Replaced with a smallvalidate()mirroring the same rules. With zod gone, the form script falls under Astro's inline threshold and is inlined into the page (one fewer request). zod removed from deps.Net effect on client JS (a content site now ships analytics-only JS)
BriefingForm)Verification (local
wrangler preview)/about,/work, etc. served directly/sitemap.xml,/llms.txt,/.well-known/openid-configuration→ 200npm run build+eslintclean🤖 Generated with Claude Code