chore(deploy): retain previous-deploy immutable chunks in R2#2365
chore(deploy): retain previous-deploy immutable chunks in R2#2365vladjerca wants to merge 5 commits into
Conversation
After every deploy, SvelteKit emits new content-hashed chunks under _app/immutable/. Existing clients (cached HTML, in-flight tabs, iOS WebViews) still reference the previous deploy's hashed filenames, and the CF ASSETS binding 404s them. Earlier client-side recovery only fires once a chunk has already loaded, which leaves cold-start clients stuck. This change keeps every deploy's immutable assets alive: - New R2 bucket binding `R2_IMMUTABLE` (`trakt-web-immutable`) - CI step between build and deploy syncs `.svelte-kit/cloudflare/_app/immutable/` to R2 additively (HEAD-skip on hit; content-hashed keys make this safe) - New SK `handle` hook intercepts `/_app/immutable/*` paths that miss the ASSETS binding and serves them from R2 with immutable cache headers - Sync step uses `continue-on-error: true` so transient R2 failures do not block deploys (intentional tradeoff; add alerting follow-up) Required one-time setup before merge: - Create `trakt-web-immutable` R2 bucket - Add GitHub Action secrets `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` (R2 token scoped to object R/W on the bucket)
|
|
Overall Grade |
Security Reliability Complexity Hygiene |
Code Review Summary
| Analyzer | Status | Updated (UTC) | Details |
|---|---|---|---|
| JavaScript | May 17, 2026 3:02p.m. | Review ↗ | |
| Code coverage | May 17, 2026 3:02p.m. | Review ↗ |
Important
AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.
… oldest Bounds R2 retention pool storage growth. Each deploy now writes a small `releases/<ISO-ts>_<sha>.json` manifest listing every immutable key that deploy depends on. CI runs a prune step after sync that keeps the most recent 10 manifests, unions their keys, and deletes anything under `_app/immutable/` that no kept manifest references. Pruned manifests are deleted as well. - Content-hashed chunks dedupe across manifests (same key = same content), so the union approach never deletes a chunk still referenced by a retained release. - KEEP_RELEASES configurable (default 10). DRY_RUN supported for ad-hoc inspection. - Sync and prune share `_internal/r2.ts` (sigv4 + S3 ops) so we avoid an aws-sdk dependency. Spec covers manifest key naming + content-type map. - CI step uses `continue-on-error: true` so transient prune failures do not block the deploy; the next run will catch up.
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses the issue of SPA boot failures caused by stale asset references after deployments. By persisting immutable chunks in an R2 bucket and implementing a server-side fallback mechanism, the application ensures that clients referencing assets from previous builds can still successfully load them, improving reliability for users with cached content or long-lived sessions. Highlights
Ignored Files
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
Pure count-based retention is brittle under deploy bursts: 10 fast deploys in an hour shrinks the recovery pool to ~1 hour, breaking long-lived SPA tabs whose cached HTML still references chunks from a deploy 2 hours ago. Retention now keeps the union of two windows: 1. KEEP_RELEASES newest manifests by count (default 10) 2. Every manifest younger than MIN_AGE_HOURS (default 24) - Slow week: rule 1 dominates → ~10 days of coverage. - Deploy burst: rule 2 dominates → every deploy in the past day stays. Implementation: - Added `manifestKeyToDate(key)` that round-trips the encoded ISO timestamp in the manifest filename. Returns null for unparseable keys; stray objects are always pruned because we trust no provenance. - Lifted env reading and `r2FromEnv()` instantiation into `main()` so the module is importable by unit tests (previously crashed vitest workers via Deno.exit at import time when env vars were missing). - New unit tests cover the count/age union, the burst scenario, and unparseable-name rejection. - CI step now passes `MIN_AGE_HOURS: '24'` alongside `KEEP_RELEASES: '10'`.
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to sync and serve immutable assets from a Cloudflare R2 bucket, ensuring that clients with cached HTML can still resolve assets after a new deployment. Key changes include a new Deno script for additive R2 synchronization, a SvelteKit server hook for asset fallback, and updated project configurations. The review feedback focuses on optimizing header processing in the sync script to improve performance and extracting duplicated R2 path encoding logic into a reusable helper function.
I am having trouble creating individual review comments. Click here to see my feedback.
projects/client/.scripts/sync-immutable-to-r2.ts (125-135)
The current implementation for generating canonical headers is inefficient because it performs an O(N) lookup using 'find' inside a 'map' operation, resulting in O(N^2) complexity for header processing. Normalizing headers into a single object ensures that keys used for lookup match the keys used for storage and improves performance.
const normalizedHeaders = Object.fromEntries(
Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v.trim()]),
);
const sortedHeaderNames = Object.keys(normalizedHeaders).sort();
const canonicalHeaders =
sortedHeaderNames
.map((h) => h + ':' + normalizedHeaders[h])
.join('\n') + '\n';
const signedHeaders = sortedHeaderNames.join(';');References
- When working with maps or objects, ensure that the keys used for lookup match the keys used for storage to prevent incorrect data retrieval.
projects/client/.scripts/sync-immutable-to-r2.ts (137-139)
The logic for constructing the encoded R2 key and the canonical URI is duplicated across 'sigv4Headers', 'headObject', and 'putObject'. Extracting this into a helper function improves maintainability and ensures consistency in how keys are escaped.
const getEncodedPath = (key: string) => '/' + BUCKET + '/' + key.split('/').map(encodeURIComponent).join('/');
const canonicalUri = getEncodedPath(key);References
- Use factory functions or helpers to create request handlers for similar queries to avoid code duplication.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a system for syncing and serving immutable assets from Cloudflare R2 to support clients with stale HTML. It includes a minimal R2 client, scripts for asset synchronization and pruning, and a SvelteKit server hook for asset fallback. Review feedback pointed out that the R2 client fails to unescape keys from the S3 List API and needs more robust XML escaping for multi-object delete operations.
- Drop non-null assertions on RELEASE_SHA by validating inside main() so the local const narrows. Threads the r2 client through uploadOne() instead of relying on module-level state. - Replace the O(N^2) header lookup with an O(N) normalized-object pass for SigV4 canonical header building. The old code did a find() inside a map() and used a non-null assertion to silence the result. - Extract keyPath() so head/put/getText share one place to encode the bucket-relative URL. - XML-escape object keys on the multi-object delete body (covers all five predefined entities, not just & and <) and XML-unescape Key / NextContinuationToken on list responses. ListObjectsV2 returns those entities encoded; without unescaping, a key with & would round-trip as & and miss the keep_set lookup during prune.
The internal r2.ts had drifted into a grab bag of unrelated concerns (SigV4 client, manifest naming, content-type table, XML escape). This splits the file along its natural seams so each piece is testable on its own and the R2 client stays focused on the wire protocol. - xml.ts: predefined-entity escape/unescape. New xml.spec.ts covers round-trip + order-of-replacement (the & pass must run last on decode). - manifest.ts: release manifest key naming + the IMMUTABLE_PREFIX / RELEASE_MANIFEST_PREFIX constants. manifest.spec.ts moved over from r2.spec.ts. - content-type.ts: extension-to-MIME table. content-type.spec.ts moved over and picks up a no-extension fallback case + a case-insensitive case. - r2.ts: now only the R2Client + r2FromEnv. Imports the xml helpers from xml.ts. Renamed the inline byte iterator (`b` → `byte`) flagged by DeepSource. - Renamed `d` → `parsed` in manifestKeyToDate and `t` → `when` in the prune retention specs to clear the remaining JS-C1002 findings. - Updated import paths in prune-immutable-r2.ts, prune-immutable-r2.spec.ts, and sync-immutable-to-r2.ts.
|
@gemini-code-assist thanks for the review. Addressed both suggestions from the first review body and the two inline comments on the second:
Bonus while I was in there: split the R2 helpers into focused modules ( |
|
Thanks for the update, @vladjerca. The refactoring into focused modules ( Since you've addressed the previous review comments and cleared the static analysis findings, this looks ready for final verification. Good luck with the deployment and testing steps! |
Summary
After every deploy, SvelteKit emits new content-hashed chunks under
_app/immutable/. Existing clients (cached HTML, in-flight tabs, iOS WebViews) still reference the previous deploy's hashed filenames, and the CF ASSETS binding 404s them. Earlier client-side recovery only fires once a chunk has loaded, leaving cold-start clients stuck.This PR keeps recent deploys' immutable assets alive in R2 and serves them as a fallback on cache miss:
R2_IMMUTABLE(bucket name:trakt-web-immutable).svelte-kit/cloudflare/_app/immutable/to R2 additively (HEAD-skip on hit; content-hashed keys make this safe), then writes a release manifest atreleases/<ISO-ts>_<sha>.jsonlisting the keys this deploy depends onKEEP_RELEASESmanifests (default 10)MIN_AGE_HOURS(default 24)Then unions kept manifests' keys, deletes orphan
_app/immutable/*objects, and deletes pruned manifests. Idempotent. SupportsDRY_RUN=1.handlehook (src/lib/features/asset-fallback/) intercepts/_app/immutable/*paths that miss the ASSETS binding and serves them from R2 withcache-control: public, immutable, max-age=31536000.scripts/_internal/r2.ts(no aws-sdk dep, plain Denofetch())continue-on-error: trueso transient R2 failures do not block deploysWhy count + age?
Pure count-based retention breaks under deploy bursts. 10 fast deploys in an hour shrinks the recovery pool to ~1 hour, and a user whose HTML was cached 6 hours ago will request a chunk that's already evicted. The age window guarantees ≥24h coverage regardless of deploy cadence.
Stray objects under
releases/(manual uploads, partial writes) whose key does not parse as an ISO timestamp are always pruned — we trust no provenance.Flow on stale-chunk hit post-deploy
/_app/immutable/chunks/<old-hash>.jshandleAssetFallbackruns first in the hook sequence_app/immutable/chunks/<old-hash>.jsRemaining steps for @seferturan to wrap up
wrangler r2 bucket create trakt-web-immutabletrakt-web-immutableonlytrakt/trakt-web:R2_ACCESS_KEY_IDR2_SECRET_ACCESS_KEY(
CLOUDFLARE_ACCOUNT_IDalready present)Sync Immutable Chunks to R2shows non-zero uploads;Prune Old Releases from R2reportsnothing to doearly onPrunestep reports orphan deletes and that the bucket size stops growingKEEP_RELEASES/MIN_AGE_HOURSenv on the CI stepcontinue-on-errorso silent degradation is possible)Test plan
deno fmt/deno lintclean for new filesvitest runcoversasset-fallbackhandle + R2 helpers + count/age retention selection (20 tests passing)DRY_RUN=1 deno task prune:immutableagainst the bucket to inspect what would be prunedContext
Related to the iOS OAuth login break investigated in trakt-api#790 and trakt-web bug where stale chunks across deploys cause SPA boot failure. Companion to trakt-website kill-switch SW (separate PR) and the earlier client-side
hooks.client.tsrecovery (already onmainasd4a8ea682).