perf: edge-cacheable HTML, lighter first visit, no GitHub wait on /download - #48
Merged
Merged
Conversation
Every page is a function of its URL alone (locale in the path, no session, no cookie), yet each view crossed to the Singapore origin and the SSR process because Laravel's default `no-cache, private` stood. CacheHtmlAtEdge, first in the web group, marks GET/HEAD HTML responses with status 200, 404 or 410 that are not Inertia visits and set no cookie with `public, max-age=0, s-maxage=600, stale-while-revalidate=3600` and keeps `Vary: X-Inertia`. RenderErrorPage applies the same rule, because a URL that matches no route never reaches the web group. Inertia JSON, redirects and 5xx keep their headers. Nothing changes at the edge until a Cache Rule is added; docs/deployment.md spells out the two rules (bypass X-Inertia, cache the rest of tablepro.app outside the platform paths). Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
…ssets servable With pages cached at the edge, three things change about a deploy: - The workflow purges the zone from the runner once deploy.sh succeeded, when CLOUDFLARE_ZONE_ID and CLOUDFLARE_CACHE_PURGE_TOKEN exist; without them it prints a notice and passes. The token never reaches the server. - deploy.sh copies the outgoing release's hashed assets into the new build before the swap, so a page cached before the purge, a stale copy served while revalidating, or an open tab never points at deleted JS or CSS. Carried files are dropped ASSET_RETENTION_HOURS (default 72) after they left a live manifest. - The smoke test still goes through Cloudflare, but with a query string no reader sends, so it never reads a page cached before the release it checks (a cached page would fail the build check and roll back a good deploy). Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
…of the 156 KB PNG /logo.png was a 256px PNG at 16 bits per channel, 156 KB, linked as the tab icon, the apple-touch-icon and the manifest icon, so every first visit downloaded it. It is now an 8-bit palette PNG at the same size with the same Display P3 profile (12,985 bytes, PSNR about 50 dB against the original), and iOS gets a 180px apple-touch-icon.png (9,203 bytes) at the path it also probes on its own. The URL /logo.png is unchanged for the structured data, the error pages and the OG card templates that embed it. Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
…ser is idle gtag/js (about 180 KB) was requested from the document head on every page and was the heaviest request in Lighthouse runs. The head now keeps the inline stub, the consent defaults, the stored answer and `config` in their contract order, and adds the script only after the load event and an idle moment (two seconds after the load where there is no idle callback), the way lib/crisp.ts loads the chat. `gtag()` queues every call in dataLayer until then, consent.ts's included, and the tag replays them in order. What is measured and the consent model are unchanged; a reader who leaves before the page is idle is no longer counted. Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
Pages are lazy chunks, so the browser learned of a page's code, and of the fifteen-odd shared chunks it imports, only after app.tsx had run and asked Inertia for the component: one more round trip before hydration on every first visit. The root template now names the page's own file as a @Vite entry, which sends its modulepreload and those of its imports with the document. A component with no file is skipped rather than failing the manifest lookup (none exists: SeoSmokeTest renders every registry page). Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
…ing a page When the cached release expired, the next /download request called GitHub and then the appcast with 5-second timeouts, so one reader every 15 minutes could wait up to ten seconds, and an edge-cached page would carry that wait to the origin. release:refresh, scheduled every 15 minutes, now does the fetching and stores the release for 30 minutes (twice the schedule) plus the last good copy on disk. MacReleaseService::latest() only reads: the stored copy, else the last good copy, else `unavailable`. A missing copy (after cache:clear, or with the scheduler stopped) asks for one refresh after the response has been sent (defer), under the existing lock and failure marker, so the page stays current without ever waiting on GitHub. Still at most four GitHub calls an hour, healthy or not. The server needs a cron entry for schedule:run (docs/deployment.md, "The scheduler"); the daily sitemap job also starts running with it. Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
Inertia's SSR server binds 0.0.0.0 unless told otherwise, and answers /render and /shutdown to anyone who reaches port 13715, so on a host without a firewall in front of it anyone could stop it and every page would ship without server-rendered HTML. Only PHP on the same host talks to it, at INERTIA_SSR_URL (http://127.0.0.1:13715), so the entry now passes { port, host } with host 127.0.0.1, overridable by INERTIA_SSR_HOST. @inertiajs/react 3.8.0's createServer accepts the option. Claude-Session: https://claude.ai/code/session_01CWocjoxqpDJ9Zo8CGGWALc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every page view crossed from Cloudflare to the origin in Singapore and ran the SSR process. Measured from Vietnam:
The first visit also carried a 156 KB PNG favicon and Google's 177 KB tag, and once every 15 minutes a reader of
/downloadwaited up to 10 s on GitHub.Change
CacheHtmlAtEdge).public, max-age=0, s-maxage=600, stale-while-revalidate=3600.Vary: X-Inertiais kept.RenderErrorPageapplies the same rule to 404s that match no route.localStorage.deploy.ymlpurges Cloudflare (purge_everything) from the runner after a successful deploy. Without the two secrets it prints a notice and passes. The token never reaches the server.deploy.shcarries the outgoing build's hashed assets into the new build and prunes them 72 h after they retire, so cached HTML or a failed purge never points at deleted JS/CSS.?deploy-smoke=<time>, so it never reads the edge cache.logo.pngdrops from 156 KB to 13 KB and looks identical. A 180×180apple-touch-icon.png(9 KB) is added.gtag/jsis added after the load event once the browser is idle, as the chat loader already is. The consent stub and call order are unchanged. A reader who leaves before then is not counted.@viteentry, so its chunk is preloaded with the document, guarded byis_file./download.release:refreshruns every 15 minutes. Requests only read the stored copy, fall back to the last good one, or show "unavailable". A missing copy schedules one deferred refresh after the response, under the existing lock.127.0.0.1(INERTIA_SSR_HOSToverrides it) instead of every interface. Production already calls it athttp://127.0.0.1:13715.Tests
REQUIRE_SSR=1): 1498 passed, 1 skipped. Without SSR: 1341 passed.npm run test:js: 181/181. Typecheck, Pint and build pass.EdgeCacheTest,PageChunkTest,IconsTest,AnalyticsConsentTest,SsrServerTest.DeployScriptTestgains cases for retention, the smoke URL and the purge step.MacReleaseServiceTestandDownloadPageTestare updated.After merge
/etc/cron.d/tablepro-web(done on the server with the deploy). It also starts the dailysitemap:generate.CLOUDFLARE_ZONE_ID, andCLOUDFLARE_CACHE_PURGE_TOKENlimited to Zone → Cache Purge on tablepro.app.docs/deployment.md, "Caching". The bypass rule on thex-inertiaheader comes first, then "eligible for cache" for the rest of tablepro.app minus the platform paths.🤖 Generated with Claude Code