feat: optional gzip upload for the s3 deploy target - #53
Merged
Conversation
S3 returns an object as exactly the bytes it stores; unlike GitHub Pages it will not compress on the fly. Every text asset therefore goes over the wire uncompressed, and the one that hurts is search/search_index.json, which every visitor downloads in full the moment they open search. On this documentation site that is 529 KB instead of 99 KB -- 5.4x the traffic for the same index, and the ratio holds as the site grows (a 13 MB index measured 5.7x). `deploy.gzip` stores those assets compressed. The deploy runs two syncs: one for everything else, then one for the text types from a staging directory, uploaded with Content-Encoding: gzip. Staging rather than gzipping in place keeps the build output serveable locally. Filters are mirrored across the two passes so every type excluded by the first is re-included by the second -- anything missed would not be uploaded at all -- and so `--delete` in either pass only considers the objects that pass owns. Off by default: S3 returns the stored bytes and the gzip header regardless of whether the client sent Accept-Encoding, which every browser handles but a script reading an object directly may not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The search index is the only asset fetched with XHR; CSS, JS and images all load through tags the browser follows across origins without a CORS check. So when a bucket sits behind something that redirects to a presigned S3 URL on another origin, the page loads perfectly and only search breaks -- with no visible error beyond a CORS entry in the network panel and a response from Server: AmazonS3 carrying no Access-Control-Allow-Origin. Reported as "search stops working once the index gets too big"; the index in question was 786 KB, so size was not the cause. Worth writing down because the symptom points nowhere near the actual fix, which is a bucket CORS rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
What & why
Chasing a report that search stops working on a site served from S3 once the index gets large.
First, what it is not: I drove Material's real search worker against a 13 MB index / 10,501 docs, and against a single 400k-word page. Both work — queries return results, worker initialises. There is no functional cliff in the index or in the search code.
What is real is the delivery. S3 returns an object as exactly the bytes it stores and will not compress on the fly — GitHub Pages does this automatically. So the same site ships far more data from S3, and
search/search_index.jsonis the file that hurts, because every visitor downloads it in full the moment they open search:The existing deploy is a plain
aws s3 sync, which sets noContent-Encoding, so there was no way to serve that index compressed at all.The change
deploy.gzip(opt-in) stores text assets compressed. The deploy becomes two syncs:Compressed copies are staged in a temp directory rather than gzipping in place, so the build output stays serveable locally. Extensions and relative paths are preserved so the AWS CLI still infers the same Content-Type.
Off by default. S3 returns the stored bytes and the gzip header regardless of whether the client sent
Accept-Encoding: gzip. Browsers handle it; a script reading an object straight out of the bucket may not.Type of change
Checklist
dotnet format Netdocs.slnx --verify-no-changespassesdotnet build Netdocs.slnx -c Releasesucceedsdotnet test Netdocs.slnx -c Releasepasses (516, +6 new)docs-site/docs/**if behavior changedVerification
Beyond the unit tests, I ran a real deploy against a stubbed
awson PATH that records its argv, so the actual code path is exercised without touching AWS:gzip: true→ exactly the two invocations above, 44 assets staged.gzip: false→ a single sync, byte-identical to today's behaviour.One test pins that every extension excluded by pass 1 is re-included by pass 2 — a type filtered out of both would silently never be uploaded, which is the failure mode worth guarding.
That stub run also caught a regression I introduced: the non-gzip path had lost its
Deployed to '<dest>'log line in the refactor. Restored, and confirmed on every path.Notes for reviewers
InternalsVisibleToadded toNetdocs.Corefor the test project — the staging and argument helpers are implementation details of the deploy target, not public API, and this matches whatNetdocs.Pluginsalready does.Worth knowing if you're behind CloudFront: its automatic compression only applies up to an object-size limit, so a growing search index can quietly stop being compressed. Storing it already compressed sidesteps that. If the S3 symptom turns out to be a hard error rather than slow transfer, that points somewhere else entirely and I'd chase it separately — the response headers on the deployed index would say which.
🤖 Generated with Claude Code
Root cause, for the record
The reported bug was not index size. A network trace showed the index request being 302-redirected to a presigned S3 URL on another origin, and the redirected response coming back from
Server: AmazonS3with noAccess-Control-Allow-Origin— so the browser discarded it.Content-Lengthwas 786 KB; size was never the problem.Only search breaks because the index is the one asset fetched with XHR, which is CORS-checked. CSS, JS and images load through
<link>/<script>tags, which follow cross-origin redirects without any CORS check — so the page looks perfectly healthy while search silently does nothing.The fix is a bucket CORS rule, not a code change. The final commit here documents it in the S3 publishing section, since the symptom points nowhere near the cause.
This PR still stands on its own — 786 KB uncompressed vs ~145 KB gzipped for that same index — but it is an efficiency change, not the bug fix.