Skip to content

feat: optional gzip upload for the s3 deploy target - #53

Merged
XtremeOwnageDotCom merged 2 commits into
mainfrom
fix/s3-gzip-text-assets
Sep 2, 2026
Merged

feat: optional gzip upload for the s3 deploy target#53
XtremeOwnageDotCom merged 2 commits into
mainfrom
fix/s3-gzip-text-assets

Conversation

@XtremeOwnageDotCom

@XtremeOwnageDotCom XtremeOwnageDotCom commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What & why

Note: this does not fix the reported "search stops working on S3" bug. That turned out to be
CORS, not size — see Root cause, for the record at the bottom. This PR
is a real but separate improvement, and the last commit documents the actual cause.

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.json is the file that hurts, because every visitor downloads it in full the moment they open search:

raw stored gzipped
this docs site 529 KB 99 KB 5.4×
13 MB synthetic index 13,108,995 B 2,287,060 B 5.7×

The existing deploy is a plain aws s3 sync, which sets no Content-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:

aws s3 sync <site>    s3://bucket --delete --exclude *.json --exclude *.css ...
aws s3 sync <staging> s3://bucket --delete --exclude * --include *.json ... --content-encoding gzip

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

  • Bug fix
  • New feature
  • Documentation
  • Refactor / chore

Checklist

  • dotnet format Netdocs.slnx --verify-no-changes passes
  • dotnet build Netdocs.slnx -c Release succeeds
  • dotnet test Netdocs.slnx -c Release passes (516, +6 new)
  • Added/updated tests for the change
  • Updated docs under docs-site/docs/** if behavior changed

Verification

Beyond the unit tests, I ran a real deploy against a stubbed aws on 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.
  • Staged files verified to be real gzip that decompresses back to the original bytes.

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

InternalsVisibleTo added to Netdocs.Core for the test project — the staging and argument helpers are implementation details of the deploy target, not public API, and this matches what Netdocs.Plugins already 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: AmazonS3 with no Access-Control-Allow-Origin — so the browser discarded it. Content-Length was 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.

XtremeOwnageDotCom and others added 2 commits September 1, 2026 22:11
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>
@XtremeOwnageDotCom
XtremeOwnageDotCom merged commit 608fcec into main Sep 2, 2026
1 check passed
@XtremeOwnageDotCom
XtremeOwnageDotCom deleted the fix/s3-gzip-text-assets branch September 2, 2026 03:39
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