Merge pull request #224 from carlosframework/gzip-assets
Edge response compression (gzip)
7 files changed,
+3231
−1
CLAUDE.md+12 −0docs/superpowers/plans/2026-08-16-woodstar-cutover-runbook.md+398 −0docs/superpowers/plans/2026-08-18-edge-response-compression.md+1456 −0docs/superpowers/specs/2026-08-18-edge-response-compression-design.md+184 −0internal/edge/compress.go+351 −0internal/edge/compress_test.go+807 −0internal/edge/edge.go+23 −1
diff --git a/CLAUDE.md b/CLAUDE.md| index d2f8faccb..170c321a7 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -121,6 +121,18 @@ read as a single piece rather than commit by commit. |
| production route. It refuses any row carrying a column the record cannot |
| express (`Backing`, `Tunnel`, `Caps`) or owned by another pass (`Platform`, |
| `Canary`). |
| +- **A ResponseWriter wrapper that edits headers needs its own header map.** |
| + Wrappers in `internal/edge` embed the writer below, so `Header()` is ONE |
| + shared map from handler to connection — and `maybeStore` caches |
| + `rec.Header().Clone()`. The gzip wrapper (`internal/edge/compress.go`) keeps |
| + a private map and writes `Content-Encoding` only to the real writer's; on the |
| + shared map it would have stored an identity body labelled gzip and served it |
| + to every later visitor. Two corollaries it paid for: a header set AFTER |
| + `WriteHeader` is a TRAILER, so the wrapper must sync late additions down or |
| + it silently eats them; and canonical for `ETag` is `Etag`, so a raw map-key |
| + comparison spelled `"ETag"` matches nothing. Ordering is load-bearing too: |
| + recorder ABOVE the compressor (cache stores identity), usage counter BELOW it |
| + (tenants are billed for wire bytes), wrap site AFTER the upgrade branch. |
| - **Console security gates are ordered**: session → CSRF → owner (byte-identical |
| 404) → `requireFreshAuth` LAST. A non-member must never get a redirect that |
| confirms an account exists. |
diff --git a/docs/superpowers/plans/2026-08-16-woodstar-cutover-runbook.md b/docs/superpowers/plans/2026-08-16-woodstar-cutover-runbook.md| new file mode 100644 |
| index 000000000..cfb4fa573 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-16-woodstar-cutover-runbook.md |
| @@ -0,0 +1,398 @@ |
| +# Woodstar → Carloku Cutover Runbook (Plan D) |
| + |
| +> **For agentic workers:** this is an OPERATIONAL runbook, not a code |
| +> plan. Execute phases in order in a session with SSM (profile |
| +> `keymail`) and SSH to the old box. Phases D0–D3 are safe any time; |
| +> **D4 (cutover) is HELD for Paul's explicit go** — never start it on |
| +> your own authority. Checkbox syntax tracks progress. |
| + |
| +**Goal:** `woodstar.app`, `status.woodstar.app`, and working |
| +`*.woodstar.app` TLS + routing serve from the Carloku flagship box, |
| +per spec `docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md` |
| +(§7 is the contract this runbook executes). |
| + |
| +## Fixed values |
| + |
| +| Thing | Value | |
| +|---|---| |
| +| Flagship box | `i-092c0c1eea75723cb` / `99.81.104.219` (aws profile `keymail`) | |
| +| Old prod box | `ec2-user@52.30.232.124` (SSH only, no SSM; Route53-confirmed live 2026-08-16 — the Caddyfile's `34.241.196.33` is stale) | |
| +| Devbox (NOT migrating) | `dev.woodstar.app` = `34.253.20.182` | |
| +| woodstar.app zone | Route53 `Z07702842ZC4MUKZR07JB`, **keymail account** | |
| +| Delegation zone | `acme.oncarlos.com` (oncarlos.com zone `Z04313755ASMHAWD536L`, Route53, keymail account — edge publishes TXT here via `CARLOS_DNS_PROVIDER=route53` suffix-walk) | |
| +| Delegation CNAME | `_acme-challenge.woodstar.app` → `_acme-challenge.woodstar.app.acme.oncarlos.com` — **DONE 2026-08-16** (stale Caddy TXT leftovers deleted in the same change batch) | |
| +| Old-box cert headroom | apex + wildcard both expire **2026-10-21**; old-box renewal is blocked by the delegation CNAME from its ~Sept 21 renewal window — cutover must land before then, rollback keeps existing certs | |
| +| W0 build sha | **`e7406bb` — the last pre-#77 main commit** (SHIPPED + PROMOTED + ADOPTED 2026-08-17, superseding the first `1aa534e` ship). REHEARSAL CATCH: #77 moved the `/.well-known/atproto-did` handler out of apbridge into atbridge, so `1aa534e`'s six-service topology 404s every Bluesky handle verification — `e7406bb` keeps #66 (fan-out) + #76 and matches live prod behavior exactly. The PDS rollout (D5) re-ships newer main WITH atbridge | |
| +| Account | `woodstar` = sqid **`bdm`** on console.carloku.com (Paul owner) — settled 2026-08-17 | |
| +| Channel | `canary/woodstar` (the canary/keymail pattern; all seven apps ride it) | |
| +| Old-box SSH | `ssh -i ~/.ssh/woodstar-canary.pem ec2-user@52.30.232.124` (key per woodstar `scripts/status-publish.sh`) | |
| +| Ports (loopback only) | relay 8451, media 8452, indexer 8453, notify 8454, unfurl 8455, apbridge 8456 | |
| +| Data | `/data/woodstar` 0700 owner `woodstar` (`/data` stays 0711) | |
| +| App env file | `/etc/carlos/apps/woodstar.env` 0600 root: `WOODSTAR_APPLE_APP_ID=J5NDF5PCD8.app.woodstar.ios` — in place BEFORE the flip or iOS passkeys break | |
| +| Backup prefixes | `s3://carlos-data-ie-271376211898/woodstar/{relay,indexer,apbridge,media}` (host role already RW); old bucket `woodstar-backups-271376211898` frozen as pre-migration history | |
| +| Replicated DBs | relay.db, indexer.db, apbridge.db — **unfurl.db deliberately excluded** (cache, repo rule); `apbridge.seed` = federation identity flat FILE, copied not litestreamed | |
| +| Apps (7) | `woodstar-relay/-media/-indexer/-notify/-unfurl/-apbridge` (kind binary, linux-arm64) + `woodstar-status` (kind static) | |
| + |
| +## Bluesky-native (#77) ruling — 2026-08-17 |
| + |
| +woodstar #77 (merged the morning of 2026-08-17) added `atbridge`, |
| +Woodstar's own AT Proto PDS, replacing Bridgy: a seventh service |
| +(:7077), its own `atbridge.db` + `atbridge.seed`, a `pds.woodstar.app` |
| +exact host, an apex `/at/*` leg, and wildcard |
| +`/.well-known/atproto-did` moving apbridge→atbridge — **on the |
| +devbox/canary only**. Probed live 2026-08-17: |
| +`pds.woodstar.app/xrpc/_health` 302s to `woodstar.app/pds` (the |
| +wildcard profile redirect), i.e. prod has NO pds host and NO atbridge; |
| +prod's atproto-did still answers from apbridge (Bridgy-era did:plc). |
| + |
| +**Ruling (spec §11 applied — parity with live behavior, no new |
| +features ride the migration):** the cutover moves the SIX-service |
| +topology exactly as prod runs it today. The W0 fan-out's |
| +atproto-did→apbridge routing is therefore CORRECT parity, and no |
| +`-atbridge-upstream` leg is needed for the migration. The PDS prod |
| +rollout is woodstar's own post-migration work, done ON CARLOS through |
| +the product surface (new app `woodstar-atbridge`, `pds.woodstar.app` |
| +exact route, fan-out `-atbridge-upstream` + atproto-did repoint as a |
| +woodstar PR, `atbridge.db` litestream + `atbridge.seed` backup) — see |
| +D5. D1.1 verifies the six-unit assumption on the box. |
| + |
| +## Standing constraints (spec §7, restated) |
| + |
| +- No DNS A-record changes and no touching running woodstar services |
| + until D4; **the flip is Paul's explicit go**. |
| +- Rehearsal reads COPIES only — never point litestream at the live |
| + `prod/` prefixes (lineage fork); never run old-box and flagship |
| + stacks against the same replication prefixes at once. |
| +- No `carlos bootstrap` on the live flagship. |
| +- **Founder trap:** never start units against an empty `/data/woodstar` |
| + reachable on the real hostname — first registrant becomes founder. |
| + Restore is explicit; there is no restore-on-boot. |
| +- Public hostnames attach via the product surface (#82/#151 doors) — |
| + no `carlos add` over SSM for them. |
| + |
| +--- |
| + |
| +## D0 — Platform preconditions (no prod impact beyond two unit restarts) |
| + |
| +State at runbook time: track live (box binary `c42ec0c`, console |
| +`3f28e9f`, both contain Plans B+C); delegation CNAME done. |
| + |
| +- [x] **D0.1 — set the delegation zone on the flagship** — DONE |
| + 2026-08-17 (Paul ran it; both units restarted `active`): |
| + |
| +```bash |
| +cd ~/github.com/carlosframework/platform-infrastructure && \ |
| +aws --profile keymail ssm send-command --instance-ids i-092c0c1eea75723cb \ |
| + --document-name AWS-RunShellScript --parameters "$(jq -n '{commands: [ |
| + "grep -q CARLOS_ACME_DELEGATION_ZONE /etc/carlos/host.env || echo CARLOS_ACME_DELEGATION_ZONE=acme.oncarlos.com >> /etc/carlos/host.env", |
| + "systemctl restart carlos-edge.service console.service", |
| + "sleep 2; systemctl is-active carlos-edge.service console.service" |
| + ]}')" |
| +``` |
| + |
| + Both `console.service` and `carlos-edge.service` source |
| + `/etc/carlos/host.env` (verified 2026-08-16), so the one line covers |
| + door and issuance. Inert until a claim opts in. |
| + |
| +- [x] **D0.2 — verify:** DONE 2026-08-17 — console 200 |
| + (`x-carlos-version: 5bef643`), edge boot line reads |
| + `wildcard certs on (static [], apps domain "oncarlos.com", |
| + delegation zone "acme.oncarlos.com")`, no invalid-zone line, all six |
| + existing `*.oncarlos.com` wildcards reloaded from cache. |
| + |
| +## D1 — Old-box pre-flight (SSH, read-only, any time) |
| + |
| +**EXECUTED 2026-08-17.** Results (the authoritative input to D2.4): |
| + |
| +- **Units (six + one timer; NO atbridge — #77 ruling premise |
| + confirmed on the box). ExecStart verbatim, all `User=woodstar`, |
| + `WorkingDirectory=/opt/woodstar/data`:** |
| + - relay: `/opt/woodstar/bin/relay -addr 127.0.0.1:7071 -db /opt/woodstar/data/relay.db -name woodstar -media http://127.0.0.1:7072` + unit-level `Environment=WOODSTAR_APPLE_APP_ID=J5NDF5PCD8.app.woodstar.ios` (no -passkey-rp, no -imports — defaults) |
| + - media: `/opt/woodstar/bin/media -addr 127.0.0.1:7072 -dir /opt/woodstar/data/media` |
| + - indexer: `/opt/woodstar/bin/indexer -addr 127.0.0.1:7073 -db /opt/woodstar/data/indexer.db -relay http://127.0.0.1:7071` |
| + - notify: `/opt/woodstar/bin/notify -addr 127.0.0.1:7074 -relay http://127.0.0.1:7071` |
| + - unfurl: `/opt/woodstar/bin/unfurl -addr 127.0.0.1:7075 -db /opt/woodstar/data/unfurl.db` |
| + - apbridge: `/opt/woodstar/bin/apbridge -addr 127.0.0.1:7076 -db /opt/woodstar/data/apbridge.db -seed /opt/woodstar/data/apbridge.seed -domain woodstar.app -relay http://127.0.0.1:7071 -media http://127.0.0.1:7072` (NO Bridgy-era flags on prod) |
| + - `woodstar-media-backup.timer`: hourly root `aws s3 sync /opt/woodstar/data/media s3://woodstar-backups-271376211898/prod/media` |
| +- **Sizes:** data 250M total; RSS relay 31M, apbridge 16M, others |
| + ≤7M (~63M total) — nano-scale verdict re-confirmed. |
| +- **Data dir:** relay/indexer/apbridge DBs (+wal/shm+litestream |
| + dirs), apbridge.seed (64 B, 0600), unfurl.db, media/ (563 files), |
| + imports/ (empty, relay default). Nothing unexpected. |
| +- **Litestream:** exactly relay/indexer/apbridge → `prod/{name}`, |
| + retention 72h; WAL uploads fresh (apbridge same-morning). |
| +- **Caddy actuals:** live Caddyfile HAS the `/unfurl*` + `/img*` |
| + legs, NO pds block (sha256 64da1c56…) — matches W0 fan-out parity. |
| +- **`apbridge.seed` had NO backup — FIXED 2026-08-17:** copied to |
| + `s3://woodstar-backups-271376211898/prod/apbridge.seed` (verified |
| + in-bucket, 64 B). |
| +- **Media backup:** in sync — 563 files local = 563 in bucket, timer |
| + last ran 08:02Z same morning. |
| + |
| +Original checklist (for a re-run; all over |
| +`ssh -i ~/.ssh/woodstar-canary.pem ec2-user@52.30.232.124`): |
| + |
| +- [x] **D1.1 — units:** `systemctl cat woodstar-*.service` (expect |
| + six). Capture EVERY ExecStart flag and Environment line verbatim — |
| + especially apbridge's `-domain`/`-seed` + Bridgy-era flags and any |
| + relay `-passkey-rp`/`-media`/`-imports` values. Six units are the |
| + target (the #77 ruling above: NO `woodstar-atbridge.service` on |
| + prod — if one appears, the ruling's premise is stale; stop and |
| + re-probe). Any other seventh unit or unspec'd flag is a FINDING — |
| + stop and record it before D2.4. |
| +- [x] **D1.2 — sizes:** per-service RSS (`systemctl status`), total |
| + `du -sh /opt/woodstar/data` — re-confirm the t4g.nano-scale verdict. |
| +- [x] **D1.3 — data dir:** `ls -la /opt/woodstar/data` — expect |
| + relay.db, indexer.db, apbridge.db, apbridge.seed, unfurl.db, media/. |
| + Anything else is a finding. |
| +- [x] **D1.4 — Caddyfile actuals vs repo reference:** diff the box's |
| + live Caddyfile against `infra/prod-Caddyfile`; specifically whether |
| + `/unfurl*` and `/img*` handles exist live (repo copy lacks both — |
| + §0 said the repo copy is stale). |
| +- [x] **D1.5 — litestream actuals:** `/etc/litestream.yml` — which DBs |
| + replicate, to which prefixes; **does `apbridge.seed` have ANY backup |
| + today?** If not: copy it to |
| + `s3://woodstar-backups-271376211898/apbridge/apbridge.seed` NOW as |
| + its own pre-flight fix (it is the federation identity), before |
| + anything else proceeds. |
| +- [x] **D1.6 — backup freshness:** newest object timestamps under the |
| + old bucket's prefixes; note the media backup mechanism (script/timer) |
| + and cadence. |
| + |
| +## D2 — Flagship tenant build-out (pre-flip, real hostnames NOT routed yet) |
| + |
| +- [x] **D2.1 — account:** `woodstar` (`bdm`) on console.carloku.com, |
| + Paul owner — existed already, confirmed 2026-08-17. |
| +- [x] **D2.2 — apps:** all seven created 2026-08-17 under `bdm` |
| + (six binary + `woodstar-status` static). |
| +- [x] **D2.3 — build + ship:** DONE 2026-08-17 — six binaries + the |
| + repo's `status/` site, all at `1aa534e`, shipped and promoted to |
| + `canary/woodstar`. |
| +- [ ] **D2.4 — box-side (SSM, root): SCRIPT STAGED, needs Paul's run** |
| + (classifier blocks remote mutations for the session — the D0.1 |
| + pattern). The complete idempotent script is at |
| + `s3://carlos-flagship-271376211898/ops/woodstar/d24-setup.sh`: |
| + user + `/data/woodstar` 0700 + env file + six hardened units |
| + (keymail-relay pattern), installed but NOT enabled/started, with |
| + **ExecStartPre founder-trap guards** — relay refuses to start |
| + without `relay.db`, indexer without `indexer.db`, apbridge without |
| + `apbridge.seed` + `apbridge.db` — so no adoption restart or stray |
| + start can ever boot an empty registration-open relay. ExecStart |
| + binaries are the ADOPTED paths |
| + (`/opt/carlos/live/woodstar-<svc>.bdm.oncarlos.com` — symlinks |
| + already on the box, placed by adoption at `1aa534e`). Run: |
| + |
| + ```bash |
| + aws --profile keymail ssm send-command --instance-ids i-092c0c1eea75723cb \ |
| + --document-name AWS-RunShellScript --parameters "$(jq -n '{commands: [ |
| + "aws s3 cp s3://carlos-flagship-271376211898/ops/woodstar/d24-setup.sh /root/d24-setup.sh && bash /root/d24-setup.sh" |
| + ]}')" |
| + ``` |
| + |
| + Unit stamping (`carlos route … -unit woodstar-<svc>.service`, run as |
| + carlos on the box, all six routes) moves to **D4.2** with the start |
| + sequence — stamping is safe earlier thanks to the guards, but it |
| + buys nothing before restore. Original spec text for reference: |
| + |
| + ExecStart flags = D1.1 verbatim, transposed: paths to |
| + `/data/woodstar`, ports to the 845x map, PLUS relay fan-out flags: |
| + |
| + ``` |
| + -addr 127.0.0.1:8451 -db /data/woodstar/relay.db -name woodstar |
| + -media http://127.0.0.1:8452 -media-upstream http://127.0.0.1:8452 |
| + -indexer-upstream http://127.0.0.1:8453 -notify-upstream http://127.0.0.1:8454 |
| + -unfurl-upstream http://127.0.0.1:8455 -apbridge-upstream http://127.0.0.1:8456 |
| + -domain woodstar.app |
| + ``` |
| + |
| + **`-media` AND `-media-upstream` are BOTH required** (importer vs |
| + fan-out — final-review catch). apbridge keeps |
| + `-domain woodstar.app -seed /data/woodstar/apbridge.seed` plus |
| + whatever Bridgy-era flags D1.1 captured, verbatim. Units installed |
| + but **NOT started** (founder trap) — except as D3 rehearses on |
| + copies. |
| +- [x] **D2.5 — routes via product surface: DONE 2026-08-17**, and the |
| + shape turned out fully member-facing — no bucket hand-writes at all: |
| + - Six `kind:service` records via `carlos instances enable` (domain |
| + pinned `bdm.oncarlos.com`, channel `canary/woodstar`) + |
| + `carlos instances create -host woodstar-<svc>.bdm.oncarlos.com |
| + -addr 127.0.0.1:845x`. Routes materialized in seconds; edge |
| + already terminates TLS for them (502 until units run); **adoption |
| + delivered all six binaries to |
| + `/opt/carlos/live/woodstar-<svc>.bdm.oncarlos.com` at `1aa534e` |
| + with zero hand-copying.** |
| + - `status.woodstar.app`: first-route static attach via the door |
| + (API POST `{"host":…,"kind":"static","channel":"canary/woodstar"}` |
| + — CLI attach has no `-kind` by design). Materialized AND |
| + **already Serving** the shipped status site. Precedence invariant |
| + (exact-host route before any wildcard alias) satisfied. |
| + - `woodstar.app` attached as alias → |
| + `woodstar-relay.bdm.oncarlos.com`; DNS honestly `elsewhere` |
| + (points at old box until D4). |
| +- [x] **D2.6 — wildcard, pre-flip cert proof: DONE 2026-08-17.** |
| + Capabilities enabled through the attach door |
| + (`wildcard:true, catchall:true`); CNAME gate passed; list shows |
| + `WILDCARD yes / CATCHALL yes / DELEGATION delegated`; edge published |
| + the TXT at `_acme-challenge.woodstar.app.acme.oncarlos.com` and |
| + **`*.woodstar.app` ISSUED ~30 s after enable** — handshake-verified |
| + against 99.81.104.219: subject `CN=*.woodstar.app`, expires |
| + 2026-11-15. Per-host certs for `woodstar.app`/`status.woodstar.app` |
| + correctly wait for post-flip HTTP-01 — do NOT TLS-probe those two |
| + hosts via `--resolve` pre-flip (each probe burns a doomed ACME |
| + order against the box-wide budget). |
| + |
| +## D3 — Rehearsal (copies only, tear down after) |
| + |
| +**FIRST RUN 2026-08-17 (at `1aa534e`):** restore 11 s + integrity `ok` |
| +× 3 + relay serving in 1 s = **freeze-to-serving 12 s**. Loopback |
| +probes: feed/`info`/AASA/real-media-file/search all 200; webfinger + |
| +`/users/paul` 200 `application/activity+json`; `/subscribe` WS **101**; |
| +`/img/x` 404 `text/plain` (unfurl service answering — NOT the SPA-HTML |
| +failure shape); `/stream/` 401 (notify alive, auth-gated); |
| +`/unfurl?url=…` curl-timeout — the loopback egress lock refusing the |
| +external fetch, by design. Host behaviors: www → 301 apex ✓, paul → |
| +302 `/paul` ✓, **atproto-did → 404 ✗ — THE RUN'S CATCH** (see the W0 |
| +build sha row: #77 removed the handler from apbridge; sha moved to |
| +`e7406bb`, re-shipped + re-adopted). Rehearsal MUST re-run at |
| +`e7406bb` and show atproto-did 200 before D4. |
| + |
| +**SECOND RUN 2026-08-17 (at `e7406bb`) — ALL GREEN, D3 COMPLETE:** |
| +teardown + fresh restore 10 s, integrity `ok` × 3, freeze-to-serving |
| +12 s again. **atproto-did → 200 `did:plc:f53efijrjuwujt7ppnp5gzf3` — |
| +the IDENTICAL DID live prod serves**; www 301 URI-preserved |
| +(`/x?y=1` carried); paul 302; `/proc/<pid>/exe` confirmed |
| +`/opt/carlos/versions/e7406bb/bdm/woodstar-apbridge`. **Edge-hop |
| +probes via `rehearsal.bdm.oncarlos.com`** (route since deleted): feed |
| +200 HTML, real media file 200, search 200, SSE endpoint answering |
| +(401 auth-gate through two hops), **WebSocket `/subscribe` 101 |
| +THROUGH THE EDGE**, webfinger 200 `application/activity+json`. |
| +**Catch-all + precedence proven through the live edge**: |
| +`paul.woodstar.app --resolve` handshakes on the wildcard cert and |
| +routes to the prod relay upstream (502 until D4 starts units, as |
| +expected); Host `status.woodstar.app` returns the STATUS SITE's HTML, |
| +not the SPA — exact-host beats wildcard. Deferred to D4.5: woodstar's |
| +WebKit `console-check.mjs` (needs node+browser, devbox tooling) — |
| +covered by Paul's browser pass. Cutover window estimate from two |
| +rehearsals: **~15 s restore+start** + backup-freeze overhead (D4.1 |
| +dominates: final media pass + 30 s WAL wait). |
| + |
| +- [x] **D3.1** server-side CopyObject of the old bucket's backups into |
| + `s3://carlos-data-ie-271376211898/woodstar-rehearsal/`. |
| +- [x] **D3.2** on the flagship: `litestream restore` the three DBs from |
| + the rehearsal prefix + media sync + seed-file copy into |
| + `/data/woodstar-rehearsal`; `PRAGMA integrity_check` on all three. |
| +- [x] **D3.3** transient units (systemd-run, rehearsal ports 8461-8466, |
| + same flags re-pathed); apbridge with `-bridgy` and `-bsky-api` |
| + pointed at throwaway values so rehearsal never dials the real |
| + federation; an sslip.io rehearsal route through the edge. |
| +- [x] **D3.4 — the fan-out path check through the edge** (degraded- |
| + not-broken is the named failure shape; never skip): feed renders |
| + restored posts; `/media/*` images load; `/img/*` unfurl card images |
| + load (the leg the spec almost lost); `/api/search` returns; |
| + `/stream/*` SSE streams; `/subscribe` WS connects (SSE/WS through |
| + TWO proxied hops edge→relay→notify — verified, not assumed); |
| + `/unfurl` answers; webfinger + `/users/*` answer from the bridge; |
| + woodstar's own WebKit `console-check.mjs` passes. |
| +- [x] **D3.5** verify exact-host-beats-wildcard precedence with the |
| + rehearsal routes; record freeze-to-serving time (the cutover window |
| + estimate); tear down units, data, routes. |
| + |
| +## D4 — Cutover (**HELD: starts only on Paul's explicit go**; quiet hour; TTLs already 300) |
| + |
| +**EXECUTED 2026-08-17 evening on Paul's "Go ahead with the full |
| +transition." Timeline and catches:** |
| + |
| +- **D4.1 freeze 18:26:15–18:26:52Z** (37 s): six units + media timer |
| + stopped, final media pass, 35 s WAL wait, `litestream.service` |
| + stopped. Losslessness proven by mtimes: every on-disk WAL write |
| + (relay/indexer 10:36:25, apbridge 18:13:05) matched its uploaded |
| + segment to the second; the 18:26:52 `.db` mtimes are litestream's |
| + shutdown checkpoint of those same frames. |
| +- **CATCH 1 — old-bucket read denied:** `carlos-flagship-host` has no |
| + policy on `woodstar-backups-271376211898` (the spec's "host role |
| + already has RW" held only for carlos-data-ie). First restore attempt |
| + aborted cleanly at step one. Fix: session-side |
| + `aws s3 sync` of the frozen `prod/` into |
| + `carlos-data-ie…/woodstar-cutover/` (box-readable), restore |
| + repointed. No IAM change. |
| +- **D4.2 restore run: 10 s, integrity `ok` × 3**, five units up — |
| + relay failed: its `EnvironmentFile=/etc/carlos/apps/woodstar.env` |
| + had been SWEPT (see catch 2). Unit stamps landed for all six routes |
| + (`try-restart` wiring confirmed by the verb's own output). |
| +- **CATCH 2 — env-file sweep:** the route-scoped config-delivery pass |
| + (PR #206) owns `/etc/carlos/apps` and removed the hand-written |
| + `woodstar.env` (dir mtime 18:04). `carlos env set -app |
| + woodstar-relay WOODSTAR_APPLE_APP_ID=…` was set through the door |
| + (kept as the durable record) but did NOT render a file for this |
| + route shape — **follow-up gap to file**: config delivery for |
| + kind:service unit-stamped routes. Interim fix = unit-level |
| + `Environment=` line, which is exactly what the old box ran. |
| +- **CATCH 3 — root-owned WAL sidecars:** with the relay down through |
| + catches 1–2, the litestream restart (root) opened `relay.db` first |
| + and minted root-owned `-wal`/`-shm`; the woodstar user then got |
| + SQLITE_CANTOPEN (14). Fix: re-chown `relay.db*` + restart. **Rule |
| + for any re-run: start ALL app units and see them active BEFORE |
| + restarting carlos-litestream.** |
| +- **D4.3 smoke:** loopback info 200 + AASA + DID; edge smoke via |
| + `woodstar-relay.bdm.oncarlos.com` (feed 200) and the wildcard-cert |
| + catch-all (`paul.woodstar.app --resolve` → 302 + DID) — never a TLS |
| + probe of the two per-host names pre-flip. |
| +- **D4.4 flip 21:05Z:** both A records UPSERTed → 99.81.104.219; |
| + INSYNC in ~10 s; per-host certs minted on first hit |
| + (`CN=woodstar.app` exp 2026-11-15). **Full public battery green:** |
| + feed/AASA/media/search 200, webfinger `activity+json`, WS 101, |
| + DID `did:plc:f53efijrjuwujt7ppnp5gzf3` identical, www 301 |
| + URI-preserved, status page serving. |
| +- Old box: units stopped, disk intact, frozen 30 days (spec §7 |
| + rollback posture). Rollback script staged at |
| + `…/ops/woodstar/d4-rollback.sh` (flagship half) + two A records |
| + back — unused. |
| + |
| +- [x] **D4.1** old box (SSH): stop the six `woodstar-*` units → one |
| + final media-backup pass → wait ~30 s for litestream's final WAL |
| + frames → stop litestream → copy `apbridge.seed` off-box → verify |
| + newest backup timestamps are post-stop. |
| +- [x] **D4.2** flagship: litestream restore the three DBs from the OLD |
| + bucket's live prefixes + media sync + seed file into |
| + `/data/woodstar`; `chown -R woodstar`; `PRAGMA integrity_check`; |
| + start the six units; enable flagship litestream config + media timer |
| + against the **fresh `carlos-data-ie` prefixes** (order: stop-old → |
| + restore-new → start-new; the two stacks never share prefixes). |
| +- [x] **D4.3** smoke BEFORE DNS: loopback `/info` 200; AASA correct at |
| + `/.well-known/apple-app-site-association`; webfinger answers from |
| + the bridge; `curl --resolve woodstar.app:443:99.81.104.219` through |
| + the edge. |
| +- [x] **D4.4** flip Route53 zone `Z07702842ZC4MUKZR07JB`: |
| + `woodstar.app` A and `*.woodstar.app` A → `99.81.104.219`. Watch |
| + per-host certs for the two named hosts mint on first post-flip hit. |
| +- [ ] **D4.5 — Paul's browser pass:** passkey sign-in, post, image |
| + upload + display, search, live notification, DM, link-card unfurl, |
| + iOS AASA validation, status page, a `paul.woodstar.app` profile |
| + redirect + its `/.well-known/atproto-did`, and federation (webfinger |
| + from a real Fediverse server / Bridgy Fed still resolving). |
| + |
| +## Rollback (any point before Paul blesses D4.5) |
| + |
| +Stop flagship units + its litestream; restart old-box units + |
| +litestream; flip the two A records back to `52.30.232.124`. Writes |
| +accepted by the flagship during the window live only in its lineage — |
| +keep the window short. Old box afterwards: units stopped, disk intact, |
| +frozen 30 days. (Old-box certs stay valid to 2026-10-21 regardless of |
| +the delegation CNAME.) |
| + |
| +## D5 — Post-cutover (spec §9 + the #77 ruling) |
| + |
| +- [ ] Promote the W0 sha to stable after the 72 h supply-chain bake. |
| +- [ ] **PDS prod rollout (woodstar's own project, on CARLOS):** app |
| + `woodstar-atbridge` + `pds.woodstar.app` exact route via the door; |
| + a woodstar PR adding the relay fan-out `-atbridge-upstream` (`/at/*` |
| + leg) and moving wildcard atproto-did apbridge→atbridge; |
| + `atbridge.db` into litestream + `atbridge.seed` into the backup set. |
| + Wildcard DNS already covers the pds hostname. |
| +- [ ] Repoint status publishing: `carlos ship -kind static` + promote |
| + replaces `scripts/status-publish.sh`. |
| +- [ ] Note woodstar `infra/` tofu drift; retire the old prod instance |
| + after the 30-day freeze; zone, bucket, devbox, IAM stay. |
| +- [ ] Update woodstar's AGENTS.md "The boxes" section and the |
| + `infra/prod-Caddyfile` reference-copy header (describes a box that |
| + no longer serves). |
diff --git a/docs/superpowers/plans/2026-08-18-edge-response-compression.md b/docs/superpowers/plans/2026-08-18-edge-response-compression.md| new file mode 100644 |
| index 000000000..06dda8858 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-18-edge-response-compression.md |
| @@ -0,0 +1,1456 @@ |
| +# Edge Response Compression (gzip) Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Compress eligible responses at the CARLOS edge with stdlib gzip, at a single client-write boundary that covers cache hits, proxied misses, static trees and the status host. |
| + |
| +**Architecture:** One `gzipWriter` wrapper inserted in `Edge.ServeHTTP` after the upgrade branch and before the cache check, giving the writer stack `real conn → countingWriter → gzipWriter → recordingResponseWriter → handler`. The recorder above it keeps recording identity bytes (the response cache keeps storing identity bodies, one variant); the usage meter's counter below it counts compressed bytes. The wrapper owns a **private header map** so the compression headers it adds never reach the recorder — that is what stops the cache storing an identity body labelled `Content-Encoding: gzip`. |
| + |
| +**Tech Stack:** Go, stdlib only (`compress/gzip`, `sync.Pool`). No brotli, no zstd, no new module dependency. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-18-edge-response-compression-design.md` |
| + |
| +## Global Constraints |
| + |
| +- **Stdlib only.** `compress/gzip` at `gzip.DefaultCompression`, writers from a `sync.Pool`. No new dependency in `go.mod`. |
| +- **Placement is load-bearing.** The wrap site is in `Edge.ServeHTTP`, strictly **after** the `isUpgradeRequest` branch and strictly **before** the `cacheable` computation. Upgrades must keep today's exact writer stack (see `purge_header.go:222` on the WebSocket-breakage class this package has already paid for). |
| +- **The wrapper owns a private `http.Header`.** Every other writer wrapper in this package embeds the writer below and shares one header map; `maybeStore` caches `rec.Header().Clone()` (edge.go:958). Compression headers go on the **real** writer's map only. |
| +- **Defer order.** `defer gw.finish()` is registered *after* the usage meter's `defer m.Record(...)`, so LIFO closes the gzip stream before the byte count is read. |
| +- **Threshold:** `minCompressSize = 1024`. A response whose `Content-Length` is present and below it is never compressed. |
| +- **Kill switch:** `CARLOS_EDGE_GZIP` — the values `off`, `false`, `0`, `no` (case-insensitive, trimmed) disable compression. Anything else, including unset, leaves it on. Read once in `New`. |
| +- **Missing `Content-Type` means identity, always.** Never sniff. |
| +- **Build gate before any commit that touches Go:** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l` clean for touched files. Sandbox env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. |
| +- **Every change lands through a PR.** Never merge to `main` locally. |
| + |
| +--- |
| + |
| +### Task 1: Accept-Encoding negotiation |
| + |
| +The request-side half of eligibility: does this client actually want gzip? Pure functions, no writer involved, so they can be tested exhaustively without a server. |
| + |
| +**Files:** |
| +- Create: `internal/edge/compress.go` |
| +- Test: `internal/edge/compress_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `joinHeader(h http.Header, name string) string` — already in `edge.go`, returns every line of a field comma-joined (a client may legally split `Accept-Encoding` across lines). |
| +- Produces: |
| + - `func acceptsGzip(h http.Header) bool` |
| + - `func qValue(params string) float64` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Create `internal/edge/compress_test.go`: |
| + |
| +```go |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "testing" |
| +) |
| + |
| +// TestAcceptsGzip pins the q-value rules. The two that matter in production |
| +// are `gzip;q=0` (a client explicitly refusing gzip — some corporate proxies |
| +// and old curl invocations send it) and a bare `*`, which admits gzip only |
| +// when its own q is non-zero. A specific token always outranks the wildcard, |
| +// so `gzip;q=0, *` is a refusal however generous the wildcard is. |
| +func TestAcceptsGzip(t *testing.T) { |
| + for _, tc := range []struct { |
| + ae string |
| + want bool |
| + }{ |
| + {"", false}, |
| + {"gzip", true}, |
| + {"GZIP", true}, |
| + {"x-gzip", true}, |
| + {"gzip, deflate, br", true}, |
| + {" gzip ; q=0.5 ", true}, |
| + {"gzip;q=0", false}, |
| + {"gzip;q=0.0", false}, |
| + {"gzip;q=0, *", false}, |
| + {"identity", false}, |
| + {"deflate, br", false}, |
| + {"*", true}, |
| + {"*;q=0", false}, |
| + {"deflate, *;q=0.1", true}, |
| + } { |
| + h := http.Header{} |
| + if tc.ae != "" { |
| + h.Set("Accept-Encoding", tc.ae) |
| + } |
| + if got := acceptsGzip(h); got != tc.want { |
| + t.Errorf("acceptsGzip(%q) = %v, want %v", tc.ae, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestAcceptsGzipReadsEveryLine: Accept-Encoding may arrive on several lines |
| +// and that is identical in meaning to one comma-joined line (RFC 9110 §5.3). |
| +// Reading it with Header.Get would see only the first, which is the exact bug |
| +// joinHeader exists to prevent for Vary and Cache-Control. |
| +func TestAcceptsGzipReadsEveryLine(t *testing.T) { |
| + h := http.Header{} |
| + h.Add("Accept-Encoding", "deflate") |
| + h.Add("Accept-Encoding", "gzip") |
| + if !acceptsGzip(h) { |
| + t.Fatal("gzip on a second Accept-Encoding line was invisible — read every line via joinHeader, not Get") |
| + } |
| +} |
| + |
| +func TestQValue(t *testing.T) { |
| + for _, tc := range []struct { |
| + params string |
| + want float64 |
| + }{ |
| + {"", 1}, |
| + {"q=1", 1}, |
| + {"q=0", 0}, |
| + {"q=0.5", 0.5}, |
| + {" Q = 0.25 ", 0.25}, |
| + {"q=bogus", 1}, // malformed q is ignored, not treated as a refusal |
| + {"charset=utf-8", 1}, |
| + } { |
| + if got := qValue(tc.params); got != tc.want { |
| + t.Errorf("qValue(%q) = %v, want %v", tc.params, got, tc.want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestAcceptsGzip|TestQValue' -count=1` |
| +Expected: FAIL — `undefined: acceptsGzip`, `undefined: qValue`. |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +Create `internal/edge/compress.go`: |
| + |
| +```go |
| +// Edge response compression: one gzip ResponseWriter wrapper applied at the |
| +// client-write boundary in Edge.ServeHTTP, so every serving class — cache |
| +// hits, proxied misses, static trees, the status host — compresses through a |
| +// single site. Apps ship identity bytes; transport is the platform's problem. |
| +// |
| +// The wrapper sits BELOW recordingResponseWriter and ABOVE countingWriter: |
| +// the cache keeps storing identity bodies (one variant, compressed after |
| +// retrieval on every delivery) and the usage meter counts what actually |
| +// crossed the wire. |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// acceptsGzip reports whether r's Accept-Encoding admits gzip. Parsed as the |
| +// q-valued token list RFC 9110 §12.5.3 defines rather than a substring search: |
| +// "gzip;q=0" is a client REFUSING gzip, and answering it with a compressed |
| +// body is a broken response, not a missed optimization. |
| +// |
| +// A specific token outranks the wildcard, so the first gzip token decides — |
| +// "gzip;q=0, *" is a refusal. The wildcard only speaks when gzip is unnamed. |
| +// |
| +// Read through joinHeader, not Header.Get: a field may legally arrive on |
| +// several lines, and Get returns only the first (the same class of bug |
| +// joinHeader's doc comment describes for Vary and Cache-Control). |
| +func acceptsGzip(h http.Header) bool { |
| + var wildcard, wildcardOK bool |
| + for _, part := range strings.Split(joinHeader(h, "Accept-Encoding"), ",") { |
| + token, params, _ := strings.Cut(strings.TrimSpace(part), ";") |
| + switch strings.ToLower(strings.TrimSpace(token)) { |
| + case "gzip", "x-gzip": |
| + return qValue(params) > 0 |
| + case "*": |
| + wildcard, wildcardOK = true, qValue(params) > 0 |
| + } |
| + } |
| + if wildcard { |
| + return wildcardOK |
| + } |
| + return false |
| +} |
| + |
| +// qValue reads the q parameter out of one Accept-Encoding element's parameter |
| +// string ("q=0.5", "q=0"). Absent or malformed means q=1: a client whose |
| +// header we cannot fully parse is treated as accepting, which is the same |
| +// default the stdlib's own negotiation takes. |
| +func qValue(params string) float64 { |
| + for _, p := range strings.Split(params, ";") { |
| + name, val, ok := strings.Cut(strings.TrimSpace(p), "=") |
| + if !ok || !strings.EqualFold(strings.TrimSpace(name), "q") { |
| + continue |
| + } |
| + f, err := strconv.ParseFloat(strings.TrimSpace(val), 64) |
| + if err != nil { |
| + return 1 |
| + } |
| + return f |
| + } |
| + return 1 |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestAcceptsGzip|TestQValue' -count=1` |
| +Expected: PASS (`ok github.com/carlosframework/platform/internal/edge`) |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/edge/compress.go internal/edge/compress_test.go |
| +git add internal/edge/compress.go internal/edge/compress_test.go |
| +git commit -m "edge: Accept-Encoding gzip negotiation |
| + |
| +q-valued token list, not a substring search: gzip;q=0 is a client |
| +refusing gzip, and a specific token outranks the wildcard." |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: The eligibility predicate |
| + |
| +The response-side half: given the headers a handler produced, is this response worth compressing and safe to compress? Also pure — the request-side questions (Accept-Encoding, HEAD) are settled once at wrapper construction in Task 3, so this function reads headers and status only. |
| + |
| +**Files:** |
| +- Modify: `internal/edge/compress.go` |
| +- Test: `internal/edge/compress_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `hasCCDirective(cc, want string) bool` and `joinHeader` from `edge.go`. |
| +- Produces: |
| + - `const minCompressSize = 1024` |
| + - `func compressible(h http.Header, status int) bool` |
| + - `func compressibleType(ct string) bool` |
| + - `func weakenETag(h http.Header)` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/edge/compress_test.go`: |
| + |
| +```go |
| +// TestCompressibleEligibility walks the response-side gate. Each false case |
| +// is a response that would be WRONG or pointless to compress, not merely |
| +// unprofitable — a 206 range body, an already-encoded body, an origin that |
| +// said no-transform. |
| +func TestCompressibleEligibility(t *testing.T) { |
| + base := func() http.Header { |
| + h := http.Header{} |
| + h.Set("Content-Type", "text/css") |
| + return h |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + mutate func(http.Header) |
| + status int |
| + want bool |
| + }{ |
| + {"plain css", func(http.Header) {}, 200, true}, |
| + {"html error page", func(h http.Header) { h.Set("Content-Type", "text/html; charset=utf-8") }, 404, true}, |
| + {"json api", func(h http.Header) { h.Set("Content-Type", "application/json") }, 200, true}, |
| + {"svg", func(h http.Header) { h.Set("Content-Type", "image/svg+xml") }, 200, true}, |
| + {"long body", func(h http.Header) { h.Set("Content-Length", "4096") }, 200, true}, |
| + {"unknown length", func(h http.Header) { h.Del("Content-Length") }, 200, true}, |
| + |
| + {"no content type", func(h http.Header) { h.Del("Content-Type") }, 200, false}, |
| + {"png", func(h http.Header) { h.Set("Content-Type", "image/png") }, 200, false}, |
| + {"event stream", func(h http.Header) { h.Set("Content-Type", "text/event-stream") }, 200, false}, |
| + {"already encoded", func(h http.Header) { h.Set("Content-Encoding", "gzip") }, 200, false}, |
| + {"no-transform", func(h http.Header) { h.Set("Cache-Control", "max-age=60, no-transform") }, 200, false}, |
| + {"short body", func(h http.Header) { h.Set("Content-Length", "512") }, 200, false}, |
| + {"exactly at threshold", func(h http.Header) { h.Set("Content-Length", "1024") }, 200, true}, |
| + {"partial content", func(h http.Header) { h.Set("Content-Range", "bytes 0-99/4096") }, 206, false}, |
| + {"no content", func(http.Header) {}, 204, false}, |
| + {"reset content", func(http.Header) {}, 205, false}, |
| + {"not modified", func(http.Header) {}, 304, false}, |
| + } { |
| + h := base() |
| + tc.mutate(h) |
| + if got := compressible(h, tc.status); got != tc.want { |
| + t.Errorf("%s: compressible(%v, %d) = %v, want %v", tc.name, h, tc.status, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestCompressibleTypeSuffixes: a media type we have never heard of still |
| +// compresses when its structured suffix says it is text underneath. |
| +func TestCompressibleTypeSuffixes(t *testing.T) { |
| + for _, ct := range []string{ |
| + "application/manifest+json", "application/atom+xml", "application/ld+json", |
| + "application/vnd.api+json", "text/plain", "application/javascript", |
| + "application/wasm", "application/xml", "text/css;charset=UTF-8", |
| + } { |
| + if !compressibleType(ct) { |
| + t.Errorf("compressibleType(%q) = false, want true", ct) |
| + } |
| + } |
| + for _, ct := range []string{ |
| + "", "image/png", "video/mp4", "application/octet-stream", |
| + "application/zip", "font/woff2", "text/event-stream", |
| + } { |
| + if compressibleType(ct) { |
| + t.Errorf("compressibleType(%q) = true, want false", ct) |
| + } |
| + } |
| +} |
| + |
| +// TestWeakenETag: a gzipped body is a different representation, so its |
| +// validator must not claim byte-equality with the identity one. Weakening |
| +// keeps conditional requests working — the stdlib's If-None-Match check |
| +// compares weakly for GET — while dropping the strong-equality claim a |
| +// Range request would rely on. |
| +func TestWeakenETag(t *testing.T) { |
| + for _, tc := range []struct{ in, want string }{ |
| + {`"abc123"`, `W/"abc123"`}, |
| + {`W/"abc123"`, `W/"abc123"`}, // already weak: untouched |
| + {"", ""}, // absent: nothing to weaken |
| + } { |
| + h := http.Header{} |
| + if tc.in != "" { |
| + h.Set("ETag", tc.in) |
| + } |
| + weakenETag(h) |
| + if got := h.Get("ETag"); got != tc.want { |
| + t.Errorf("weakenETag(%q) -> %q, want %q", tc.in, got, tc.want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestCompressible|TestWeakenETag' -count=1` |
| +Expected: FAIL — `undefined: compressible`, `undefined: compressibleType`, `undefined: weakenETag`. |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +Append to `internal/edge/compress.go`: |
| + |
| +```go |
| +// minCompressSize is the Content-Length below which compressing is not worth |
| +// the CPU: gzip's own header and trailer are 18 bytes, and a sub-kilobyte |
| +// response often lands in one packet either way. A response whose length is |
| +// UNKNOWN (streamed, chunked) is compressed regardless — refusing those would |
| +// exempt most proxied HTML, which is the bulk of what there is to save. |
| +const minCompressSize = 1024 |
| + |
| +// compressible reports whether a response with these headers and this status |
| +// should be gzipped. The request-side questions — does the client accept |
| +// gzip, is this a HEAD — are settled once at wrapper construction |
| +// (newGzipWriter), so this reads the response alone. |
| +// |
| +// Every false below is a response that would be WRONG or pointless to |
| +// compress, not merely unprofitable: |
| +// |
| +// - 204/205 have no body; 304 has none and must keep the validators of the |
| +// representation it refers to; 206 is a byte range of a larger body, and |
| +// compressing a range makes the offsets it names meaningless. |
| +// - An existing Content-Encoding means the origin already encoded it. |
| +// Compressing again produces a body no client can decode without knowing |
| +// we did it. |
| +// - Cache-Control: no-transform is the origin forbidding exactly this |
| +// (RFC 9110 §5.5). |
| +// |
| +// A MISSING Content-Type is refused rather than sniffed. Sniffing is the |
| +// stdlib's job and it runs on the bytes we would have already compressed — |
| +// it would read gzip's magic number and label the response application/x-gzip. |
| +func compressible(h http.Header, status int) bool { |
| + switch status { |
| + case http.StatusNoContent, http.StatusResetContent, |
| + http.StatusPartialContent, http.StatusNotModified: |
| + return false |
| + } |
| + if h.Get("Content-Range") != "" { |
| + return false |
| + } |
| + if h.Get("Content-Encoding") != "" { |
| + return false |
| + } |
| + if hasCCDirective(joinHeader(h, "Cache-Control"), "no-transform") { |
| + return false |
| + } |
| + if n, err := strconv.Atoi(h.Get("Content-Length")); err == nil && n < minCompressSize { |
| + return false |
| + } |
| + return compressibleType(h.Get("Content-Type")) |
| +} |
| + |
| +// compressibleType reports whether a media type is text underneath. text/* is |
| +// the bulk of it; the structured suffixes (+json, +xml) catch types nobody has |
| +// enumerated yet, which is why they are here rather than a longer switch. |
| +// |
| +// text/event-stream is the one text type excluded: it is an open stream a |
| +// client consumes event by event, and buffering it inside a compressor is a |
| +// hang, not a saving. |
| +func compressibleType(ct string) bool { |
| + mt, _, _ := strings.Cut(ct, ";") |
| + mt = strings.ToLower(strings.TrimSpace(mt)) |
| + if mt == "" || mt == "text/event-stream" { |
| + return false |
| + } |
| + if strings.HasPrefix(mt, "text/") || |
| + strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "+xml") { |
| + return true |
| + } |
| + switch mt { |
| + case "application/json", "application/javascript", "application/x-javascript", |
| + "application/xml", "application/wasm": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// weakenETag downgrades a strong validator to a weak one, in place. A gzipped |
| +// body is a different representation of the same resource, so an ETag that |
| +// claims byte-for-byte equality with the identity body is a lie — and the one |
| +// thing that lie breaks is a Range request served against the wrong bytes. |
| +// Conditional requests keep working: If-None-Match compares weakly for GET, |
| +// so a client returning W/"abc" still matches the origin's "abc" and gets its |
| +// 304. |
| +func weakenETag(h http.Header) { |
| + if et := h.Get("ETag"); strings.HasPrefix(et, `"`) { |
| + h.Set("ETag", `W/`+et) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestCompressible|TestWeakenETag|TestAcceptsGzip|TestQValue' -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/edge/compress.go internal/edge/compress_test.go |
| +git add internal/edge/compress.go internal/edge/compress_test.go |
| +git commit -m "edge: gzip eligibility predicate |
| + |
| +Header-only, decided once. A missing Content-Type is refused rather |
| +than sniffed: sniffing runs on bytes we would have already compressed |
| +and would label the response application/x-gzip." |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: The gzipWriter wrapper |
| + |
| +The wrapper itself, tested directly against `httptest.NewRecorder()` — no Edge, no proxy, so each invariant fails for exactly one reason. The private header map is the whole point of this task. |
| + |
| +**Files:** |
| +- Modify: `internal/edge/compress.go` |
| +- Test: `internal/edge/compress_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `acceptsGzip`, `compressible`, `weakenETag` from Tasks 1-2. |
| +- Produces: |
| + - `type gzipWriter struct` — embeds `http.ResponseWriter`, implements `Header`, `WriteHeader`, `Write`, `Flush`, `Hijack`, and `finish()`. |
| + - `func newGzipWriter(w http.ResponseWriter, r *http.Request) *gzipWriter` — returns **nil** when this request can never be compressed. |
| + - `func (g *gzipWriter) finish()` — flushes and returns the pooled writer; the caller defers it. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/edge/compress_test.go` (add `bufio`, `compress/gzip`, `io`, `net`, `net/http/httptest`, `strings` to its imports): |
| + |
| +```go |
| +// hijackableRecorder is a recorder that also implements http.Hijacker, so the |
| +// forwarding test has something to forward TO. httptest.ResponseRecorder |
| +// deliberately does not implement it. |
| +type hijackableRecorder struct { |
| + *httptest.ResponseRecorder |
| + hijacked bool |
| +} |
| + |
| +func (h *hijackableRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { |
| + h.hijacked = true |
| + return nil, nil, nil |
| +} |
| + |
| +// gzipReq builds a request that accepts gzip. |
| +func gzipReq(method, url string) *http.Request { |
| + r := httptest.NewRequest(method, url, nil) |
| + r.Header.Set("Accept-Encoding", "gzip") |
| + return r |
| +} |
| + |
| +// TestGzipWriterKeepsCompressionHeadersOffTheCallerMap is THE test this design |
| +// exists for. Every writer wrapper in this package embeds the writer below, so |
| +// Header() returns one shared map all the way down — and maybeStore caches |
| +// rec.Header().Clone(). If the compression headers landed on that shared map, |
| +// the cache would store an IDENTITY body labelled Content-Encoding: gzip and |
| +// serve it to every later visitor as an undecodable response. |
| +func TestGzipWriterKeepsCompressionHeadersOffTheCallerMap(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + if g == nil { |
| + t.Fatal("newGzipWriter returned nil for a gzip-accepting GET") |
| + } |
| + g.Header().Set("Content-Type", "text/css") |
| + g.Header().Set("ETag", `"abc123"`) |
| + g.Header().Set("Content-Length", "4000") |
| + g.WriteHeader(http.StatusOK) |
| + body := strings.Repeat("a{color:red}\n", 400) |
| + io.WriteString(g, body) |
| + g.finish() |
| + |
| + if got := g.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("caller-visible Content-Encoding = %q, want empty — the recorder above would cache an identity body labelled gzip", got) |
| + } |
| + if got := g.Header().Get("ETag"); got != `"abc123"` { |
| + t.Errorf("caller-visible ETag = %q, want the strong tag untouched (the cached body IS the identity one)", got) |
| + } |
| + if got := g.Header().Get("Content-Length"); got != "4000" { |
| + t.Errorf("caller-visible Content-Length = %q, want it untouched", got) |
| + } |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Errorf("client Content-Encoding = %q, want gzip", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "" { |
| + t.Errorf("client Content-Length = %q, want it dropped — it described the identity body", got) |
| + } |
| + if got := rec.Header().Get("Vary"); got != "Accept-Encoding" { |
| + t.Errorf("client Vary = %q, want Accept-Encoding", got) |
| + } |
| + if got := rec.Header().Get("ETag"); got != `W/"abc123"` { |
| + t.Errorf("client ETag = %q, want the weakened form", got) |
| + } |
| + |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("client body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read gzip body: %v", err) |
| + } |
| + if string(got) != body { |
| + t.Fatalf("decompressed body differs from what the handler wrote") |
| + } |
| + if rec.Body.Len() >= len(body) { |
| + t.Fatalf("compressed size %d >= identity size %d", rec.Body.Len(), len(body)) |
| + } |
| +} |
| + |
| +// TestGzipWriterNilForRequestsThatCanNeverCompress: construction is where the |
| +// request-side questions are settled, once. A nil wrapper means ServeHTTP |
| +// leaves the writer stack exactly as it is today. |
| +func TestGzipWriterNilForRequestsThatCanNeverCompress(t *testing.T) { |
| + if g := newGzipWriter(httptest.NewRecorder(), httptest.NewRequest("GET", "http://app.test/", nil)); g != nil { |
| + t.Error("no Accept-Encoding must yield a nil wrapper") |
| + } |
| + if g := newGzipWriter(httptest.NewRecorder(), gzipReq("HEAD", "http://app.test/big.css")); g != nil { |
| + t.Error("HEAD must yield a nil wrapper — there is no body to compress, and its truthful Content-Length is the whole value of the response") |
| + } |
| +} |
| + |
| +// TestGzipWriterPassesThroughIneligibleResponses: an ineligible response must |
| +// be byte-identical to the unwrapped one, headers included. |
| +func TestGzipWriterPassesThroughIneligibleResponses(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/logo.png")) |
| + g.Header().Set("Content-Type", "image/png") |
| + g.Header().Set("Content-Length", "9") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, "PNG-BYTES") |
| + g.finish() |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("Content-Encoding = %q on an image, want empty", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "9" { |
| + t.Errorf("Content-Length = %q, want it preserved on a pass-through", got) |
| + } |
| + if got := rec.Body.String(); got != "PNG-BYTES" { |
| + t.Errorf("body = %q, want the identity bytes", got) |
| + } |
| +} |
| + |
| +// TestGzipWriterSilentHandlerStillDeliversHeaders: the handler set headers and |
| +// returned without ever writing. Those headers live in the PRIVATE map, so |
| +// without a sync in finish() the stdlib would emit a bare 200 with none of |
| +// them — X-Carlos-Version included. |
| +func TestGzipWriterSilentHandlerStillDeliversHeaders(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + g.Header().Set("X-Carlos-Version", "abc123") |
| + g.finish() |
| + |
| + if got := rec.Header().Get("X-Carlos-Version"); got != "abc123" { |
| + t.Fatalf("X-Carlos-Version = %q, want abc123 — a handler that wrote nothing still lost every header it set", got) |
| + } |
| +} |
| + |
| +// TestGzipWriter1xxDoesNotLatch mirrors recordingResponseWriter.WriteHeader: a |
| +// 103 Early Hints block precedes the final status. Latching it would freeze |
| +// the compression decision on a status that has no body, and the real response |
| +// would then be delivered uncompressed — or worse, with the hints lost, since |
| +// they live in the private map. |
| +func TestGzipWriter1xxDoesNotLatch(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + g.Header().Set("Link", "</tokens.css>; rel=preload") |
| + g.WriteHeader(http.StatusEarlyHints) |
| + |
| + if got := rec.Header().Get("Link"); got != "</tokens.css>; rel=preload" { |
| + t.Errorf("early-hints Link = %q, want it forwarded — it was set on the private map", got) |
| + } |
| + |
| + g.Header().Del("Link") |
| + g.Header().Set("Content-Type", "text/html") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, strings.Repeat("<p>hi</p>", 200)) |
| + g.finish() |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Errorf("Content-Encoding = %q after a 103, want gzip — the 1xx latched the decision", got) |
| + } |
| + if got := rec.Header().Get("Link"); got != "" { |
| + t.Errorf("Link = %q, want empty — the final header block replaces the informational one", got) |
| + } |
| +} |
| + |
| +// TestGzipWriterFlushReachesTheClient: a flushed response must actually leave |
| +// the compressor, not sit in its buffer until the handler returns. SSE never |
| +// compresses (content type), but a streamed HTML page does. |
| +func TestGzipWriterFlushReachesTheClient(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/stream")) |
| + g.Header().Set("Content-Type", "text/html") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, strings.Repeat("<p>chunk</p>", 100)) |
| + g.Flush() |
| + |
| + if rec.Body.Len() == 0 { |
| + t.Fatal("Flush left the bytes inside the compressor — a streaming response would hang until the handler returned") |
| + } |
| + if !rec.Flushed { |
| + t.Fatal("Flush did not reach the writer below") |
| + } |
| + g.finish() |
| +} |
| + |
| +// TestGzipWriterForwardsHijack: the upgrade path returns before this wrapper |
| +// is ever built, so this is not a hot path — it exists so the wrapper is never |
| +// itself the reason a hijack fails, matching the convention countingWriter and |
| +// recordingResponseWriter already follow. |
| +func TestGzipWriterForwardsHijack(t *testing.T) { |
| + rec := &hijackableRecorder{ResponseRecorder: httptest.NewRecorder()} |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + if _, _, err := g.Hijack(); err != nil { |
| + t.Fatalf("Hijack: %v", err) |
| + } |
| + if !rec.hijacked { |
| + t.Fatal("Hijack did not reach the writer below") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run TestGzipWriter -count=1` |
| +Expected: FAIL — `undefined: newGzipWriter`. |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +Append to `internal/edge/compress.go`, and extend its imports to |
| +`bufio`, `compress/gzip`, `fmt`, `io`, `net`, `net/http`, `strconv`, `strings`, `sync`: |
| + |
| +```go |
| +// gzipPool recycles compressors. We compress on every delivery, cache hits |
| +// included, so a fresh gzip.Writer per response would be ~1.2 MB/s of garbage |
| +// on a busy edge for no reason. |
| +var gzipPool = sync.Pool{New: func() any { |
| + w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression) |
| + return w |
| +}} |
| + |
| +// gzipWriter compresses an eligible response on its way to the client. |
| +// |
| +// It owns a PRIVATE header map. Every other ResponseWriter wrapper in this |
| +// package embeds the writer below, so Header() returns one shared map from the |
| +// handler all the way down to the connection — and maybeStore caches |
| +// rec.Header().Clone(). Were the compression headers set on that shared map, |
| +// the cache would store an identity body labelled Content-Encoding: gzip and |
| +// hand it to every later visitor. So callers above read and write hdr, and the |
| +// edits that describe the COMPRESSED representation go on the real writer's |
| +// map alone. |
| +// |
| +// Deliberately does NOT implement io.ReaderFrom: io.Copy (and so |
| +// http.ServeFile) falls back to Write, which is what compresses. |
| +type gzipWriter struct { |
| + http.ResponseWriter |
| + hdr http.Header |
| + gz *gzip.Writer // nil unless this response is being compressed |
| + wroteHdr bool |
| +} |
| + |
| +// newGzipWriter returns a wrapper for r, or nil when this request can never |
| +// produce a compressed response — no wrapper at all, so the writer stack is |
| +// exactly what it is today. The request-side questions are settled here, once: |
| +// everything after depends only on the response. |
| +// |
| +// HEAD is excluded rather than compressed-with-no-body: there is nothing to |
| +// compress, and a HEAD's truthful Content-Length is the entire value of the |
| +// response. The accepted cost is that a HEAD advertises the identity length |
| +// while a GET of the same URL returns gzip with no length at all. |
| +func newGzipWriter(w http.ResponseWriter, r *http.Request) *gzipWriter { |
| + if r.Method == http.MethodHead || !acceptsGzip(r.Header) { |
| + return nil |
| + } |
| + return &gzipWriter{ResponseWriter: w, hdr: http.Header{}} |
| +} |
| + |
| +// Header returns the PRIVATE map. See the type's doc comment: this is what |
| +// keeps the response cache storing identity bodies. |
| +func (g *gzipWriter) Header() http.Header { return g.hdr } |
| + |
| +// syncHeader replaces the real writer's header block with the private one. |
| +// Wholesale, not a merge: a handler that DELETED a header between blocks (the |
| +// shape the purge-on-103 test exercises) must see that deletion reach the |
| +// client, which a merge would silently undo. |
| +func (g *gzipWriter) syncHeader() { |
| + real := g.ResponseWriter.Header() |
| + for k := range real { |
| + delete(real, k) |
| + } |
| + for k, v := range g.hdr { |
| + real[k] = v |
| + } |
| +} |
| + |
| +func (g *gzipWriter) WriteHeader(status int) { |
| + // 1xx blocks (103 Early Hints, forwarded by httputil.ReverseProxy) precede |
| + // the final status. Sync and forward without latching — the same shape |
| + // recordingResponseWriter.WriteHeader uses, for the same reason: latching |
| + // one would decide compression on a status that has no body. |
| + if status < 200 { |
| + g.syncHeader() |
| + g.ResponseWriter.WriteHeader(status) |
| + return |
| + } |
| + if g.wroteHdr { |
| + g.ResponseWriter.WriteHeader(status) // superfluous; let the stdlib log it |
| + return |
| + } |
| + g.wroteHdr = true |
| + g.syncHeader() |
| + if compressible(g.hdr, status) { |
| + h := g.ResponseWriter.Header() |
| + h.Set("Content-Encoding", "gzip") |
| + h.Del("Content-Length") // it described the identity body |
| + h.Add("Vary", "Accept-Encoding") |
| + weakenETag(h) |
| + g.gz = gzipPool.Get().(*gzip.Writer) |
| + g.gz.Reset(g.ResponseWriter) |
| + } |
| + g.ResponseWriter.WriteHeader(status) |
| +} |
| + |
| +func (g *gzipWriter) Write(p []byte) (int, error) { |
| + if !g.wroteHdr { |
| + g.WriteHeader(http.StatusOK) |
| + } |
| + if g.gz != nil { |
| + return g.gz.Write(p) |
| + } |
| + return g.ResponseWriter.Write(p) |
| +} |
| + |
| +// finish closes the compressor and returns it to the pool. ServeHTTP defers |
| +// it, AFTER the usage meter's defer, so LIFO runs this one first and the |
| +// metered byte count includes gzip's trailer. |
| +// |
| +// The no-header case is not a no-op: a handler that returned without ever |
| +// writing set its headers on the private map, and nothing has copied them |
| +// down. Without this sync the stdlib emits a bare 200 with none of them. |
| +func (g *gzipWriter) finish() { |
| + if !g.wroteHdr { |
| + g.syncHeader() |
| + return |
| + } |
| + if g.gz == nil { |
| + return |
| + } |
| + g.gz.Close() |
| + g.gz.Reset(io.Discard) // don't pin this request's writer inside the pool |
| + gzipPool.Put(g.gz) |
| + g.gz = nil |
| +} |
| + |
| +// Flush pushes the compressor's buffered bytes out and then flushes the writer |
| +// below — needed so a streamed response reaches the client incrementally |
| +// instead of buffering until the handler returns. |
| +func (g *gzipWriter) Flush() { |
| + if g.gz != nil { |
| + g.gz.Flush() |
| + } |
| + if f, ok := g.ResponseWriter.(http.Flusher); ok { |
| + f.Flush() |
| + } |
| +} |
| + |
| +// Hijack forwards to the writer below, when it supports it — the same |
| +// optional-interface pattern countingWriter and recordingResponseWriter use. |
| +// ServeHTTP returns on the upgrade path before this wrapper is constructed, so |
| +// this is not expected on the hot path; it exists so the wrapper is never |
| +// itself the reason a hijack fails. |
| +func (g *gzipWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { |
| + hj, ok := g.ResponseWriter.(http.Hijacker) |
| + if !ok { |
| + return nil, nil, fmt.Errorf("edge: underlying ResponseWriter does not support http.Hijacker") |
| + } |
| + return hj.Hijack() |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestGzip|TestCompressible|TestAcceptsGzip|TestQValue|TestWeakenETag' -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/edge/compress.go internal/edge/compress_test.go |
| +git add internal/edge/compress.go internal/edge/compress_test.go |
| +git commit -m "edge: gzip ResponseWriter with a private header map |
| + |
| +The wrapper owns its own http.Header: every other wrapper here embeds |
| +the writer below, so Header() is one shared map and maybeStore caches |
| +rec.Header().Clone() — setting Content-Encoding on it would store an |
| +identity body labelled gzip and serve it to every later visitor." |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: Wire it into Edge.ServeHTTP |
| + |
| +The wrap site, the kill switch, and the two invariants that dictated the ordering: the cache stores identity, the meter counts compressed. |
| + |
| +**Files:** |
| +- Modify: `internal/edge/edge.go` — the `Edge` struct (near line 138), `New` (line 181), `ServeHTTP` (after the `isUpgradeRequest` block, ~line 806) |
| +- Modify: `internal/edge/compress.go` — add `gzipEnabled()` |
| +- Test: `internal/edge/compress_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `newGzipWriter`, `(*gzipWriter).finish` from Task 3; `EnableCache(*Cache)`, `NewCache(maxEntries, maxBodyBytes int) *Cache`, `EnableUsage(*UsageMeter)`, `(*Cache).get(CacheKey)`, `openReg(t)` from `edge_test.go`. |
| +- Produces: |
| + - `Edge.gzipOn bool` field |
| + - `func gzipEnabled() bool` |
| + - `func edgeWithBackend(t *testing.T, h http.Handler) *Edge` (test helper, used again in Task 5) |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/edge/compress_test.go` (imports gain `github.com/carlosframework/platform/internal/registry`): |
| + |
| +```go |
| +// edgeWithBackend builds an Edge with one proxied route, "app.test", pointed |
| +// at h. Driven with httptest.NewRecorder + httptest.NewRequest rather than a |
| +// real client: http.Transport adds its own Accept-Encoding and transparently |
| +// decompresses what comes back, which would hide exactly what these tests are |
| +// checking. |
| +func edgeWithBackend(t *testing.T, h http.Handler) *Edge { |
| + t.Helper() |
| + backend := httptest.NewServer(h) |
| + t.Cleanup(backend.Close) |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.test", Kind: "service", Status: "ready", |
| + Addr: backend.Listener.Addr().String(), |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + return e |
| +} |
| + |
| +// cssBackend answers every request with a cacheable stylesheet of n bytes. |
| +func cssBackend(n int) http.Handler { |
| + body := strings.Repeat("a{color:red}\n", n/13+1)[:n] |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + w.Header().Set("Cache-Control", "max-age=600") |
| + io.WriteString(w, body) |
| + }) |
| +} |
| + |
| +// TestEdgeCompressesProxiedAssets is the payoff: an app ships identity bytes |
| +// and the edge puts them on the wire compressed. |
| +func TestEdgeCompressesProxiedAssets(t *testing.T) { |
| + e := edgeWithBackend(t, cssBackend(4096)) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("Content-Encoding = %q, want gzip", got) |
| + } |
| + if got := rec.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") { |
| + t.Errorf("Vary = %q, want it to name Accept-Encoding", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read gzip body: %v", err) |
| + } |
| + if len(got) != 4096 { |
| + t.Fatalf("decompressed %d bytes, want 4096", len(got)) |
| + } |
| +} |
| + |
| +// TestEdgeCacheStoresIdentityBodies is the invariant the whole writer ordering |
| +// exists to hold. The recorder sits ABOVE the compressor, so what lands in the |
| +// cache is the identity body with the app's own headers — one variant, not one |
| +// per encoding — and a client that cannot decode gzip is still served correctly |
| +// from that same entry. |
| +func TestEdgeCacheStoresIdentityBodies(t *testing.T) { |
| + e := edgeWithBackend(t, cssBackend(4096)) |
| + e.EnableCache(NewCache(10, 1<<20)) |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) // miss |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("miss: Content-Encoding = %q, want gzip", got) |
| + } |
| + |
| + stored, ok := e.cache.get(CacheKey{Host: "app.test", Method: "GET", Path: "/tokens.css"}) |
| + if !ok { |
| + t.Fatal("the response was not cached — this test cannot say anything about what was stored") |
| + } |
| + if ce := stored.Header.Get("Content-Encoding"); ce != "" { |
| + t.Errorf("cached entry carries Content-Encoding %q: an identity body labelled gzip, served to every later visitor as an undecodable response", ce) |
| + } |
| + if v := stored.Header.Get("Vary"); strings.Contains(v, "Accept-Encoding") { |
| + t.Errorf("cached entry carries Vary %q: the edge's own Vary belongs below the recorder, not in the cache key", v) |
| + } |
| + if len(stored.Body) != 4096 { |
| + t.Errorf("cached body is %d bytes, want the 4096-byte identity body", len(stored.Body)) |
| + } |
| + |
| + hit := httptest.NewRecorder() |
| + e.ServeHTTP(hit, gzipReq("GET", "http://app.test/tokens.css")) |
| + if got := hit.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("cache hit: Content-Encoding = %q, want gzip — a hit is a delivery like any other", got) |
| + } |
| + zr, err := gzip.NewReader(hit.Body) |
| + if err != nil { |
| + t.Fatalf("cache hit body is not gzip: %v", err) |
| + } |
| + if b, err := io.ReadAll(zr); err != nil || len(b) != 4096 { |
| + t.Fatalf("cache hit decompressed to %d bytes (err %v), want 4096", len(b), err) |
| + } |
| + |
| + plain := httptest.NewRecorder() |
| + e.ServeHTTP(plain, httptest.NewRequest("GET", "http://app.test/tokens.css", nil)) |
| + if got := plain.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("identity client got Content-Encoding %q from the shared entry", got) |
| + } |
| + if plain.Body.Len() != 4096 { |
| + t.Errorf("identity client got %d bytes, want the 4096-byte identity body", plain.Body.Len()) |
| + } |
| +} |
| + |
| +// TestEdgeMetersCompressedBytes: a tenant is billed for what crossed the wire. |
| +// The counter sits BELOW the compressor and finish() runs before Record — the |
| +// equality below is what pins that defer order, since gzip buffers until Close |
| +// and a reversed order would meter a fraction of the bytes. |
| +func TestEdgeMetersCompressedBytes(t *testing.T) { |
| + e := edgeWithBackend(t, cssBackend(8192)) |
| + m := NewUsageMeter() |
| + e.EnableUsage(m) |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + |
| + row, ok := m.Drain()["app.test"] |
| + if !ok { |
| + t.Fatal("no usage row for app.test") |
| + } |
| + if row.BytesOut != int64(rec.Body.Len()) { |
| + t.Fatalf("metered %d bytes, client received %d — finish() must run before Record (defer order) or the trailer is missed", row.BytesOut, rec.Body.Len()) |
| + } |
| + if row.BytesOut >= 8192 { |
| + t.Fatalf("metered %d bytes for an 8192-byte identity body — the meter is counting bytes the edge did not send", row.BytesOut) |
| + } |
| +} |
| + |
| +// TestEdgeGzipKillSwitch: CARLOS_EDGE_GZIP=off is the rollback that needs no |
| +// redeploy, so it must produce today's bytes exactly. |
| +func TestEdgeGzipKillSwitch(t *testing.T) { |
| + t.Setenv("CARLOS_EDGE_GZIP", "off") |
| + e := edgeWithBackend(t, cssBackend(4096)) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("Content-Encoding = %q with the kill switch off, want empty", got) |
| + } |
| + if got := rec.Header().Get("Vary"); strings.Contains(got, "Accept-Encoding") { |
| + t.Errorf("Vary = %q with the kill switch off — nothing may hint at a variant that is never produced", got) |
| + } |
| + if rec.Body.Len() != 4096 { |
| + t.Errorf("body = %d bytes, want the 4096-byte identity body", rec.Body.Len()) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run TestEdge -count=1` |
| +Expected: FAIL — `TestEdgeCompressesProxiedAssets` reports `Content-Encoding = "" want gzip` (nothing is wired yet). |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +3a. Append to `internal/edge/compress.go` (imports gain `os`): |
| + |
| +```go |
| +// gzipEnabled reads the CARLOS_EDGE_GZIP kill switch. Compression is ON unless |
| +// the variable names a false value — the rollback path is an env change and a |
| +// restart, not a platform binary roll. Matches the CARLOS_* env idiom in |
| +// certcache.go and dns.go. |
| +func gzipEnabled() bool { |
| + switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_EDGE_GZIP"))) { |
| + case "off", "false", "0", "no": |
| + return false |
| + } |
| + return true |
| +} |
| +``` |
| + |
| +3b. In `internal/edge/edge.go`, add to the `Edge` struct (beside the `cache` field, ~line 138): |
| + |
| +```go |
| + // gzipOn is the CARLOS_EDGE_GZIP kill switch, read once in New. When |
| + // false, ServeHTTP never constructs a gzipWriter and the writer stack is |
| + // byte-for-byte what it was before compression existed (compress.go). |
| + gzipOn bool |
| +``` |
| + |
| +3c. In `New` (edge.go:181), set it: |
| + |
| +```go |
| + e := &Edge{reg: reg, act: act, probers: newProberSet(), gzipOn: gzipEnabled()} |
| +``` |
| + |
| +3d. In `ServeHTTP`, immediately after the `if isUpgradeRequest(r) { ... }` block and before the `cacheable := ...` line: |
| + |
| +```go |
| + // Response compression (compress.go). Placed AFTER the upgrade branch, so |
| + // an upgrading request keeps the exact writer stack it has always had — |
| + // purge_header.go's contract records what wrapping the raw ResponseWriter |
| + // on that path has already cost this package — and BEFORE the cache check, |
| + // so a cache hit compresses on replay like any other delivery. |
| + // |
| + // The recorders constructed below wrap THIS writer, so they record |
| + // identity bytes and the cache keeps storing identity bodies; the usage |
| + // meter's countingWriter is below it, so the metered count is the |
| + // compressed one. This defer is registered after the meter's, so LIFO |
| + // closes the gzip stream — trailer included — before Record reads it. |
| + if e.gzipOn { |
| + if gw := newGzipWriter(w, r); gw != nil { |
| + w = gw |
| + defer gw.finish() |
| + } |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Run test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run TestEdge -count=1` |
| +Expected: PASS |
| + |
| +Then the whole package: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -count=1` |
| +Expected: PASS. If an existing test fails asserting an exact `Content-Length` or byte count on a compressible body of 1024+ bytes, that is the feature working — update the test to decompress or to assert the identity length via a non-gzip request, and say so in the commit message. Do **not** weaken the feature to keep an assertion. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/edge/compress.go internal/edge/edge.go internal/edge/compress_test.go |
| +git add internal/edge/compress.go internal/edge/edge.go internal/edge/compress_test.go |
| +git commit -m "edge: compress responses at the client-write boundary |
| + |
| +One wrap site in ServeHTTP after the upgrade branch and before the |
| +cache check, so cache hits, proxied misses, static trees and the status |
| +host all pass through it. The cache keeps storing identity bodies and |
| +the usage meter counts the compressed byte count. |
| + |
| +CARLOS_EDGE_GZIP=off disables it without a binary roll." |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: Regression battery, full gate, PR |
| + |
| +Everything the wrap site could plausibly have broken, plus the serving classes that reach it through a different door. Then the gate and the PR. |
| + |
| +**Files:** |
| +- Test: `internal/edge/compress_test.go` |
| +- Modify: `CLAUDE.md` (one bullet under "Rules this repo has paid for") |
| + |
| +**Interfaces:** |
| +- Consumes: `edgeWithBackend`, `gzipReq`, `cssBackend` from Task 4; `upgradeEchoBackend(t, tag) string`, `openReg(t)` and `testEdgeWithStore(t, rt)` from `edge_test.go`; `testKeys(t)` and `shipSite(t, store, app, version, files)` from `static_test.go`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/edge/compress_test.go` (imports gain `bufio`, `context`, `fmt`, `net`, `path/filepath`, `time`, and `github.com/carlosframework/platform/internal/release`): |
| + |
| +```go |
| +// TestUpgradeIsUntouchedByCompression: the wrap site is after the upgrade |
| +// branch precisely so a handshake keeps the writer stack it has always had. |
| +// A gzip-accepting client (every browser) must still get its 101, and bytes |
| +// must still flow both ways after the switch. |
| +func TestUpgradeIsUntouchedByCompression(t *testing.T) { |
| + addr := upgradeEchoBackend(t, "ws") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ws.test", Kind: "service", Status: "ready", Addr: addr, |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + c, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://")) |
| + if err != nil { |
| + t.Fatalf("dial edge: %v", err) |
| + } |
| + defer c.Close() |
| + if err := c.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { |
| + t.Fatalf("set deadline: %v", err) |
| + } |
| + if _, err := fmt.Fprint(c, "GET /socket HTTP/1.1\r\nHost: ws.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nAccept-Encoding: gzip\r\n\r\n"); err != nil { |
| + t.Fatalf("write handshake: %v", err) |
| + } |
| + br := bufio.NewReader(c) |
| + resp, err := http.ReadResponse(br, nil) |
| + if err != nil { |
| + t.Fatalf("read handshake response: %v", err) |
| + } |
| + if resp.StatusCode != http.StatusSwitchingProtocols { |
| + t.Fatalf("handshake status = %d, want 101 — an Accept-Encoding header must not divert an upgrade through the compressor", resp.StatusCode) |
| + } |
| + if _, err := fmt.Fprint(c, "ping\n"); err != nil { |
| + t.Fatalf("write post-upgrade frame: %v", err) |
| + } |
| + line, err := br.ReadString('\n') |
| + if err != nil { |
| + t.Fatalf("read post-upgrade frame: %v", err) |
| + } |
| + if line != "ws:ping\n" { |
| + t.Fatalf("post-upgrade echo = %q, want %q — bytes must flow both ways after the switch", line, "ws:ping\n") |
| + } |
| +} |
| + |
| +// TestAlreadyEncodedUpstreamPassesThrough: an app that compresses for itself |
| +// must not be compressed again into a body nobody can decode. |
| +func TestAlreadyEncodedUpstreamPassesThrough(t *testing.T) { |
| + var raw []byte |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var buf strings.Builder |
| + zw := gzip.NewWriter(&buf) |
| + io.WriteString(zw, strings.Repeat("already compressed\n", 200)) |
| + zw.Close() |
| + raw = []byte(buf.String()) |
| + w.Header().Set("Content-Type", "text/plain") |
| + w.Header().Set("Content-Encoding", "gzip") |
| + w.Write(raw) |
| + })) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/pre.txt")) |
| + |
| + if got := rec.Header().Values("Content-Encoding"); len(got) != 1 || got[0] != "gzip" { |
| + t.Fatalf("Content-Encoding = %v, want exactly one gzip", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body did not survive as single-encoded gzip: %v", err) |
| + } |
| + body, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read body: %v", err) |
| + } |
| + if !strings.HasPrefix(string(body), "already compressed") { |
| + t.Fatalf("body = %.40q, want the upstream's own gzip decoded in ONE pass", body) |
| + } |
| +} |
| + |
| +// TestEventStreamIsNotCompressed: SSE is an open stream consumed event by |
| +// event. Buffering it inside a compressor is a hang, not a saving. |
| +func TestEventStreamIsNotCompressed(t *testing.T) { |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/event-stream") |
| + w.WriteHeader(http.StatusOK) |
| + for i := 0; i < 3; i++ { |
| + fmt.Fprintf(w, "data: %d\n\n", i) |
| + if f, ok := w.(http.Flusher); ok { |
| + f.Flush() |
| + } |
| + } |
| + })) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/events")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Fatalf("Content-Encoding = %q on an event stream, want empty", got) |
| + } |
| + if !strings.Contains(rec.Body.String(), "data: 0") { |
| + t.Fatalf("event stream body = %q, want the plain events", rec.Body.String()) |
| + } |
| +} |
| + |
| +// TestRangeAndConditionalRequestsSurvive: a 206 must keep its byte offsets |
| +// meaningful, and a conditional request must still 304 against the weakened |
| +// validator the client was given. |
| +func TestRangeAndConditionalRequestsSurvive(t *testing.T) { |
| + body := strings.Repeat("a{color:red}\n", 400) |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + http.ServeContent(w, r, "tokens.css", time.Unix(1, 0), strings.NewReader(body)) |
| + })) |
| + |
| + rng := gzipReq("GET", "http://app.test/tokens.css") |
| + rng.Header.Set("Range", "bytes=0-9") |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, rng) |
| + if rec.Code != http.StatusPartialContent { |
| + t.Fatalf("range status = %d, want 206", rec.Code) |
| + } |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("206 carries Content-Encoding %q — compressing a byte range makes the offsets it names meaningless", got) |
| + } |
| + if rec.Body.Len() != 10 { |
| + t.Errorf("range body = %d bytes, want 10", rec.Body.Len()) |
| + } |
| + |
| + full := httptest.NewRecorder() |
| + e.ServeHTTP(full, gzipReq("GET", "http://app.test/tokens.css")) |
| + etag := full.Header().Get("ETag") |
| + if etag == "" { |
| + t.Skip("ServeContent emitted no ETag; nothing to condition on") |
| + } |
| + if !strings.HasPrefix(etag, `W/`) { |
| + t.Errorf("ETag = %q on a compressed body, want the weakened form", etag) |
| + } |
| + cond := gzipReq("GET", "http://app.test/tokens.css") |
| + cond.Header.Set("If-None-Match", etag) |
| + nm := httptest.NewRecorder() |
| + e.ServeHTTP(nm, cond) |
| + if nm.Code != http.StatusNotModified { |
| + t.Fatalf("conditional request = %d, want 304 — weakening the validator broke revalidation", nm.Code) |
| + } |
| + if got := nm.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("304 carries Content-Encoding %q; a 304 has no body to encode", got) |
| + } |
| +} |
| + |
| +// TestStatusHostCompresses: the status class reaches the wrap site through a |
| +// different door (its own handler, exempt from the shared cache and the usage |
| +// meter) and must compress like everything else. |
| +func TestStatusHostCompresses(t *testing.T) { |
| + s, h := testEdgeWithStore(t, registry.Route{Host: "status.test", Kind: "status", Status: "ready"}) |
| + s.Put(context.Background(), "status/fleet.json", []byte(`{"boxes":[`+strings.Repeat(`{"id":"box"},`, 300)+`{}]}`), "") |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, gzipReq("GET", "http://status.test/fleet.json")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("Content-Encoding = %q on the status host, want gzip", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil || !strings.Contains(string(got), `"boxes"`) { |
| + t.Fatalf("decompressed status body = %.40q (err %v)", got, err) |
| + } |
| +} |
| + |
| +// TestStaticSiteCompresses: the static class is the payoff case — a shipped |
| +// site tree whose CSS is served straight off disk by http.ServeFile. It |
| +// reaches the wrap site through Edge.ServeHTTP like every other class, so |
| +// this pins that the class nobody proxies is covered too. |
| +func TestStaticSiteCompresses(t *testing.T) { |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + priv, pub := testKeys(t) |
| + css := strings.Repeat("a{color:red}\n", 400) // 5200 bytes, well over the threshold |
| + shipSite(t, store, "site", "v1", map[string]string{ |
| + "index.html": "home", |
| + "tokens.css": css, |
| + }) |
| + if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "site.test", Kind: "static", App: "site", Channel: "canary/x", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://site.test/tokens.css")) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("status = %d, want 200", rec.Code) |
| + } |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("Content-Encoding = %q on a static asset, want gzip", got) |
| + } |
| + if got := rec.Header().Get("X-Carlos-Version"); got != "v1" { |
| + t.Errorf("X-Carlos-Version = %q, want v1 — compression must not disturb the static class's per-request version stamp", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read gzip body: %v", err) |
| + } |
| + if string(got) != css { |
| + t.Fatalf("decompressed static asset differs from the shipped file") |
| + } |
| + if rec.Body.Len() >= len(css) { |
| + t.Fatalf("compressed %d bytes >= identity %d", rec.Body.Len(), len(css)) |
| + } |
| +} |
| + |
| +// TestHeadKeepsItsContentLength: a HEAD has no body to compress, and its |
| +// truthful Content-Length is the whole value of the response. The backend |
| +// sets the length explicitly — Go only infers one when the whole body fits |
| +// its 2048-byte buffer, so a 4096-byte handler would otherwise chunk and this |
| +// test would be asserting the stdlib's buffering, not the edge's behaviour. |
| +func TestHeadKeepsItsContentLength(t *testing.T) { |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + w.Header().Set("Content-Length", "4096") |
| + w.WriteHeader(http.StatusOK) |
| + if r.Method != http.MethodHead { |
| + io.WriteString(w, strings.Repeat("x", 4096)) |
| + } |
| + })) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("HEAD", "http://app.test/tokens.css")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("HEAD Content-Encoding = %q, want empty", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "4096" { |
| + t.Errorf("HEAD Content-Length = %q, want 4096", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails (or reveals a real gap)** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/edge/ -run 'TestUpgradeIsUntouched|TestAlreadyEncoded|TestEventStream|TestRangeAndConditional|TestStatusHost|TestStaticSiteCompresses|TestHeadKeeps' -count=1 -v` |
| +Expected: these are regression tests over Task 4's implementation, so most should PASS on first run. **Any failure here is a real defect, not a missing feature** — fix `compress.go`, never the assertion. The likely one is the conditional-request case: if `http.ServeContent` compares `If-None-Match` strongly against its own tag, the weakening in `weakenETag` needs the 304 path to strip `W/` on the way back in. Diagnose before changing anything. |
| + |
| +- [ ] **Step 3: Fix any defect the battery found** |
| + |
| +If everything passed, this step is a no-op — record that in the commit message rather than inventing work. |
| + |
| +- [ ] **Step 4: Run the full gate** |
| + |
| +```bash |
| +export GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +GOOS=darwin go build ./... |
| +gofmt -l internal/edge/compress.go internal/edge/compress_test.go internal/edge/edge.go |
| +``` |
| +Expected: every package passes, the darwin cross-compile succeeds, `gofmt -l` prints nothing. |
| + |
| +- [ ] **Step 5: Record the rule and open the PR** |
| + |
| +Add to `CLAUDE.md` under "Rules this repo has paid for": |
| + |
| +```markdown |
| +- **A ResponseWriter wrapper that edits headers needs its own header map.** |
| + Wrappers here embed the writer below, so `Header()` is ONE shared map from |
| + handler to connection — and `maybeStore` caches `rec.Header().Clone()`. The |
| + gzip wrapper (`internal/edge/compress.go`) keeps a private map and writes |
| + `Content-Encoding` only to the real writer's; on the shared map it would |
| + have stored an identity body labelled gzip and served it to every later |
| + visitor. Ordering is load-bearing too: recorder ABOVE the compressor (cache |
| + stores identity), usage counter BELOW it (tenants are billed for wire |
| + bytes), and the wrap site AFTER the upgrade branch. |
| +``` |
| + |
| +```bash |
| +git add CLAUDE.md internal/edge/compress_test.go internal/edge/compress.go |
| +git commit -m "edge: gzip regression battery + record the wrapper rule |
| + |
| +Upgrades still 101 with Accept-Encoding set, an already-encoded |
| +upstream is not double-compressed, SSE and 206 stay identity, the |
| +status host compresses, and a weakened ETag still revalidates." |
| +git push -u origin gzip-assets |
| +gh pr create --title "Edge response compression (gzip)" --body "$(cat <<'BODY' |
| +Compresses eligible responses at the edge with stdlib gzip, at one |
| +client-write boundary covering cache hits, proxied misses, static trees |
| +and the status host. Apps stay identity-only; transport is the |
| +platform's problem. |
| + |
| +Writer stack: `real conn -> countingWriter -> gzipWriter -> recordingResponseWriter -> handler`. |
| +The recorder above keeps the response cache storing identity bodies |
| +(one variant, compressed after retrieval); the counter below means |
| +tenants are metered for what actually crossed the wire. |
| + |
| +The wrapper owns a private header map — wrappers here embed the writer |
| +below, so `Header()` is one shared map and `maybeStore` caches |
| +`rec.Header().Clone()`. Setting `Content-Encoding` there would have |
| +cached an identity body labelled gzip. |
| + |
| +Eligibility is header-only, decided once at WriteHeader: compressible |
| +Content-Type, no existing encoding, no `no-transform`, not 204/205/206/304, |
| +`Content-Length` absent or >= 1024. A missing Content-Type is never |
| +sniffed. Upgrades return before the wrap site. |
| + |
| +Rollback: `CARLOS_EDGE_GZIP=off`, no binary roll. |
| + |
| +Spec: `docs/superpowers/specs/2026-08-18-edge-response-compression-design.md` |
| +Plan: `docs/superpowers/plans/2026-08-18-edge-response-compression.md` |
| + |
| +🤖 Generated with [Claude Code](https://claude.com/claude-code) |
| + |
| +https://claude.ai/code/session_01NoqzkUVahW5ykubsBQitxc |
| +BODY |
| +)" |
| +``` |
| + |
| +--- |
| + |
| +## Deployment note (not a task) |
| + |
| +This ships in the platform binary and takes effect at the edge process restart |
| +that a platform roll performs. Verify on a real host with the header, against |
| +the box, not a cached answer: |
| + |
| +```bash |
| +curl -sI -H 'Accept-Encoding: gzip' --resolve <host>:443:<box-ip> https://<host>/<asset.css> \ |
| + | grep -iE 'content-encoding|vary|x-carlos-version' |
| +``` |
| + |
| +Expect `content-encoding: gzip`, a `vary` naming `Accept-Encoding`, and the |
| +`X-Carlos-Version` of the build you just rolled. A request without the |
| +`Accept-Encoding` header must come back identity from the same URL. |
diff --git a/docs/superpowers/specs/2026-08-18-edge-response-compression-design.md b/docs/superpowers/specs/2026-08-18-edge-response-compression-design.md| new file mode 100644 |
| index 000000000..c8b58224e |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-18-edge-response-compression-design.md |
| @@ -0,0 +1,184 @@ |
| +# Edge response compression (gzip) — design |
| + |
| +Status: approved 2026-08-18. Implements the transport half of the promise |
| +rastrillo's assets spec makes to apps ("ship identity bytes; the platform edge |
| +handles transport concerns"). |
| + |
| +## Problem |
| + |
| +Nothing the fleet serves is compressed on the wire. The only gzip in |
| +`internal/edge` today is `static.go`'s artifact *storage* format (`site.tar.gz`, |
| +extracted to disk before serving) and `cache.go`'s `Vary: Accept-Encoding` |
| +machinery, which anticipates encoding variants that no code path emits. |
| + |
| +Apps are identity-only by design. rastrillo's `tokens.css` is 21.5 KB on the |
| +wire where 5.6 KB would do. Compressed bytes are also what the usage meter |
| +should count — a tenant billed for `BytesOut` is billed for bytes the platform |
| +chose not to compress. |
| + |
| +## Shape |
| + |
| +One wrapper, applied once at the client-write boundary in `Edge.ServeHTTP`, so |
| +every serving class passes through a single site: cache hits (`writeCached`), |
| +proxied misses, static trees, and the status host. |
| + |
| +Writer stack, real connection outward to handler: |
| + |
| +``` |
| +real conn → countingWriter → gzipWriter → recordingResponseWriter → proxy/static/status |
| +``` |
| + |
| +- The recorder sits **above** the gzip writer, so it captures identity bytes: |
| + the response cache keeps storing identity bodies and one variant, and the |
| + compression happens after retrieval, on every delivery. |
| +- The counter sits **below**, so the usage meter counts what actually crossed |
| + the wire. |
| +- `defer gw.finish()` is registered **after** the meter's defer, so LIFO closes |
| + the gzip stream (writing its trailer through the counter) before `m.Record` |
| + reads the byte count. |
| + |
| +### Placement |
| + |
| +The wrap goes **after** the `isUpgradeRequest` branch and **before** the cache |
| +check in `Edge.ServeHTTP`. |
| + |
| +After the upgrade branch: an upgrade keeps today's exact writer stack, byte for |
| +byte. `purge_header.go`'s contract records that this package has already paid |
| +for wrapping the raw ResponseWriter on the upgrade path, and this design does |
| +not re-open that class. |
| + |
| +Before the cache check: a cache hit is a delivery like any other and must |
| +compress on replay. |
| + |
| +The tunnel hub and the fleet store door return ahead of this point (they are |
| +not registry routes, and the hub ends its handshake in a hijack). They stay |
| +uncompressed. Out of scope. |
| + |
| +## The private header map |
| + |
| +`http.ResponseWriter` wrappers in this package embed the writer below, so |
| +`Header()` returns one shared map all the way down. `maybeStore` caches |
| +`rec.Header().Clone()` (edge.go). If the gzip wrapper set `Content-Encoding: |
| +gzip` on that shared map, the cache would store an **identity body labelled |
| +gzip** and serve it to every later visitor as a broken response. |
| + |
| +So `gzipWriter` owns a private `http.Header`: |
| + |
| +- `Header()` returns the private map. Everything above — the recorder, the |
| + proxy, `http.ServeFile` — reads and writes only that. |
| +- At final `WriteHeader`, the wrapper clears the real writer's map, copies the |
| + private map into it, then applies its edits **to the real map only**: |
| + set `Content-Encoding: gzip`, delete `Content-Length`, add |
| + `Vary: Accept-Encoding`, weaken a strong `ETag`. |
| + |
| +Consequences, each pinned by a test: |
| + |
| +- **1xx**: a `103 Early Hints` block copies-and-forwards without latching a |
| + status, mirroring `recordingResponseWriter.WriteHeader`. Without the copy the |
| + hints would be lost, since they live in the private map. |
| +- **Silent handler**: if the handler returns having never called `WriteHeader` |
| + or `Write`, `finish()` syncs the private map down. Otherwise the stdlib emits |
| + a 200 with no headers at all. |
| +- **Vary placement**: `Vary: Accept-Encoding` is added below the recorder, so it |
| + reaches clients and downstream CDNs but never enters the cache key. That is |
| + correct: the stored variant is identity, keyed by whatever the app itself |
| + varied on. `cache.go`'s existing Vary machinery is unaffected. |
| + |
| +## Eligibility |
| + |
| +Decided once, at final `WriteHeader`, from headers alone. No buffering, no |
| +sniffing, no interference with streaming. |
| + |
| +Compress when **all** hold: |
| + |
| +1. The request accepts gzip. `Accept-Encoding` is parsed as a q-valued token |
| + list: `gzip;q=0` and `*;q=0` refuse; a bare `*` with non-zero q accepts. |
| +2. Method is not `HEAD`. |
| +3. Status is not 204, 205, 206 or 304, and no `Content-Range` is present. |
| +4. No `Content-Encoding` already on the response. |
| +5. No `Cache-Control: no-transform` (RFC 9110 §5.5 — the origin forbidding |
| + exactly this). |
| +6. `Content-Type` is present and compressible: `text/*` except |
| + `text/event-stream`; plus `application/json`, `application/javascript`, |
| + `application/x-javascript`, `application/xml`, `application/xhtml+xml`, |
| + `application/manifest+json`, `application/rss+xml`, `application/atom+xml`, |
| + `image/svg+xml`, `application/wasm`; plus any type with a `+json` or `+xml` |
| + suffix. |
| +7. `Content-Length` is absent, or present and ≥ 1024 bytes. |
| + |
| +**A missing `Content-Type` means identity, always.** Content sniffing belongs to |
| +the stdlib, and it runs on the bytes we would have already compressed — it would |
| +sniff gzip magic and label the response `application/x-gzip`. `http.ServeFile` |
| +and real apps set the type; the ones that don't, don't compress. |
| + |
| +`HEAD` is skipped rather than compressed-with-no-body: a HEAD's truthful |
| +`Content-Length` is the entire value of the response. The accepted cost is that |
| +a HEAD advertises the identity length while a GET returns gzip with no length. |
| + |
| +## Behaviour at the edges |
| + |
| +- **ETag**: a strong `ETag` is weakened to `W/"…"` on the real header only, so |
| + the cached entry keeps the strong tag for its identity body. `ServeFile`'s |
| + `If-None-Match` check compares weakly, so a conditional request still 304s. |
| +- **Flush**: flush the gzip stream, then the writer below. SSE never compresses |
| + (content type), so its flushes pass straight through. |
| +- **Hijack**: forwarded to the writer below, matching `countingWriter` and |
| + `recordingResponseWriter`. Not expected on this path — upgrades return |
| + earlier. |
| +- **Level**: `gzip.DefaultCompression`, writers from a `sync.Pool`. We compress |
| + on every delivery including cache hits, so allocation matters more than the |
| + last few percent of ratio. |
| +- **Kill switch**: `CARLOS_EDGE_GZIP=off` (also `false`/`0`), read once in |
| + `New`, matching the `CARLOS_*` idiom in `certcache.go` and `dns.go`. When off, |
| + the wrapper is never constructed and behaviour is byte-identical to today. |
| + This is the rollback that needs no redeploy. |
| + |
| +## Accepted cost: BREACH |
| + |
| +Compressing HTML that carries a secret (a CSRF token) alongside |
| +attacker-influenced reflected text is the BREACH precondition. Every CDN and |
| +reverse proxy in production makes this trade; the mitigation is token masking in |
| +the application, not identity transport. Named here so it is a decision on the |
| +record rather than a discovery later. |
| + |
| +## Rejected alternatives |
| + |
| +- **Precompress in `PackDir` at ship time.** Changes an artifact shape (the |
| + wire-shape rule: everything written before the change must still parse) and |
| + helps only static routes — proxied apps, the status host, and cache hits stay |
| + uncompressed. |
| +- **Compress above the recorder, cache compressed bodies.** Compresses once per |
| + cached response instead of once per delivery, but stores two variants per URL |
| + against the shared LRU's budget and makes every cache-correctness question a |
| + question about encodings too. The CPU saved is not worth widening that |
| + surface. |
| +- **brotli / zstd.** New dependency for a marginal ratio gain. Stdlib gzip only. |
| + |
| +## Testing |
| + |
| +In-package tests, TDD. Load-bearing: |
| + |
| +- Cache stores identity: after a compressible cacheable response, the stored |
| + entry's headers carry no `Content-Encoding` and its body is raw; both the miss |
| + and the hit deliver valid gzip; a client with no `Accept-Encoding` gets |
| + identity from that same entry. |
| +- Usage meter records compressed byte counts (strictly less than identity). |
| +- A WebSocket handshake with `Accept-Encoding: gzip` set still returns 101. |
| +- The existing 103 Early Hints pass-through still holds. |
| +- A handler that writes nothing still delivers its headers. |
| +- An already-gzipped upstream response is passed through, not double-compressed. |
| +- 206, HEAD, `text/event-stream`, missing `Content-Type`, and sub-1024 |
| + `Content-Length` all stay identity. |
| +- Strong ETag weakened; conditional request still 304s. |
| +- `CARLOS_EDGE_GZIP=off` reproduces today's bytes exactly. |
| + |
| +Existing tests asserting exact `BytesOut` or `Content-Length` on compressible |
| +bodies ≥ 1024 bytes will need updating; the threshold spares the rest. |
| + |
| +## Files |
| + |
| +- `internal/edge/compress.go` — new. The wrapper, the eligibility predicate, the |
| + `Accept-Encoding` parser, the pool. |
| +- `internal/edge/compress_test.go` — new. |
| +- `internal/edge/edge.go` — the wrap site in `ServeHTTP`, the `Edge` field, the |
| + env read in `New`. |
diff --git a/internal/edge/compress.go b/internal/edge/compress.go| new file mode 100644 |
| index 000000000..e460c0401 |
| --- /dev/null |
| +++ b/internal/edge/compress.go |
| @@ -0,0 +1,351 @@ |
| +// Edge response compression: one gzip ResponseWriter wrapper applied at the |
| +// client-write boundary in Edge.ServeHTTP, so every serving class — cache |
| +// hits, proxied misses, static trees, the status host — compresses through a |
| +// single site. Apps ship identity bytes; transport is the platform's problem. |
| +// |
| +// The wrapper sits BELOW recordingResponseWriter and ABOVE countingWriter: |
| +// the cache keeps storing identity bodies (one variant, compressed after |
| +// retrieval on every delivery) and the usage meter counts what actually |
| +// crossed the wire. |
| +package edge |
| + |
| +import ( |
| + "bufio" |
| + "compress/gzip" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/textproto" |
| + "os" |
| + "strconv" |
| + "strings" |
| + "sync" |
| +) |
| + |
| +// acceptsGzip reports whether r's Accept-Encoding admits gzip. Parsed as the |
| +// q-valued token list RFC 9110 §12.5.3 defines rather than a substring search: |
| +// "gzip;q=0" is a client REFUSING gzip, and answering it with a compressed |
| +// body is a broken response, not a missed optimization. |
| +// |
| +// A specific token outranks the wildcard, so the first gzip token decides — |
| +// "gzip;q=0, *" is a refusal. The wildcard only speaks when gzip is unnamed. |
| +// |
| +// Read through joinHeader, not Header.Get: a field may legally arrive on |
| +// several lines, and Get returns only the first (the same class of bug |
| +// joinHeader's doc comment describes for Vary and Cache-Control). |
| +func acceptsGzip(h http.Header) bool { |
| + var wildcard, wildcardOK bool |
| + for _, part := range strings.Split(joinHeader(h, "Accept-Encoding"), ",") { |
| + token, params, _ := strings.Cut(strings.TrimSpace(part), ";") |
| + switch strings.ToLower(strings.TrimSpace(token)) { |
| + case "gzip", "x-gzip": |
| + return qValue(params) > 0 |
| + case "*": |
| + wildcard, wildcardOK = true, qValue(params) > 0 |
| + } |
| + } |
| + if wildcard { |
| + return wildcardOK |
| + } |
| + return false |
| +} |
| + |
| +// qValue reads the q parameter out of one Accept-Encoding element's parameter |
| +// string ("q=0.5", "q=0"). Absent or malformed means q=1: a client whose |
| +// header we cannot fully parse is treated as accepting, which is the same |
| +// default the stdlib's own negotiation takes. |
| +func qValue(params string) float64 { |
| + for _, p := range strings.Split(params, ";") { |
| + name, val, ok := strings.Cut(strings.TrimSpace(p), "=") |
| + if !ok || !strings.EqualFold(strings.TrimSpace(name), "q") { |
| + continue |
| + } |
| + f, err := strconv.ParseFloat(strings.TrimSpace(val), 64) |
| + if err != nil { |
| + return 1 |
| + } |
| + return f |
| + } |
| + return 1 |
| +} |
| + |
| +// minCompressSize is the Content-Length below which compressing is not worth |
| +// the CPU: gzip's own header and trailer are 18 bytes, and a sub-kilobyte |
| +// response often lands in one packet either way. A response whose length is |
| +// UNKNOWN (streamed, chunked) is compressed regardless — refusing those would |
| +// exempt most proxied HTML, which is the bulk of what there is to save. |
| +const minCompressSize = 1024 |
| + |
| +// compressible reports whether a response with these headers and this status |
| +// should be gzipped. The request-side questions — does the client accept |
| +// gzip, is this a HEAD — are settled once at wrapper construction |
| +// (newGzipWriter), so this reads the response alone. |
| +// |
| +// Every false below is a response that would be WRONG or pointless to |
| +// compress, not merely unprofitable: |
| +// |
| +// - 204/205 have no body; 304 has none and must keep the validators of the |
| +// representation it refers to; 206 is a byte range of a larger body, and |
| +// compressing a range makes the offsets it names meaningless. |
| +// - An existing Content-Encoding means the origin already encoded it. |
| +// Compressing again produces a body no client can decode without knowing |
| +// we did it. |
| +// - Cache-Control: no-transform is the origin forbidding exactly this |
| +// (RFC 9110 §5.5). |
| +// |
| +// A MISSING Content-Type is refused rather than sniffed. Sniffing is the |
| +// stdlib's job and it runs on the bytes we would have already compressed — |
| +// it would read gzip's magic number and label the response application/x-gzip. |
| +func compressible(h http.Header, status int) bool { |
| + switch status { |
| + case http.StatusNoContent, http.StatusResetContent, |
| + http.StatusPartialContent, http.StatusNotModified: |
| + return false |
| + } |
| + if h.Get("Content-Range") != "" { |
| + return false |
| + } |
| + if h.Get("Content-Encoding") != "" { |
| + return false |
| + } |
| + if hasCCDirective(joinHeader(h, "Cache-Control"), "no-transform") { |
| + return false |
| + } |
| + if n, err := strconv.Atoi(h.Get("Content-Length")); err == nil && n < minCompressSize { |
| + return false |
| + } |
| + return compressibleType(h.Get("Content-Type")) |
| +} |
| + |
| +// compressibleType reports whether a media type is text underneath. text/* is |
| +// the bulk of it; the structured suffixes (+json, +xml) catch types nobody has |
| +// enumerated yet, which is why they are here rather than a longer switch. |
| +// |
| +// text/event-stream is the one text type excluded: it is an open stream a |
| +// client consumes event by event, and buffering it inside a compressor is a |
| +// hang, not a saving. |
| +func compressibleType(ct string) bool { |
| + mt, _, _ := strings.Cut(ct, ";") |
| + mt = strings.ToLower(strings.TrimSpace(mt)) |
| + if mt == "" || mt == "text/event-stream" { |
| + return false |
| + } |
| + if strings.HasPrefix(mt, "text/") || |
| + strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "+xml") { |
| + return true |
| + } |
| + switch mt { |
| + case "application/json", "application/javascript", "application/x-javascript", |
| + "application/xml", "application/wasm": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// weakenETag downgrades a strong validator to a weak one, in place. A gzipped |
| +// body is a different representation of the same resource, so an ETag that |
| +// claims byte-for-byte equality with the identity body is a lie — and the one |
| +// thing that lie breaks is a Range request served against the wrong bytes. |
| +// Conditional requests keep working: If-None-Match compares weakly for GET, |
| +// so a client returning W/"abc" still matches the origin's "abc" and gets its |
| +// 304. |
| +func weakenETag(h http.Header) { |
| + if et := h.Get("ETag"); strings.HasPrefix(et, `"`) { |
| + h.Set("ETag", `W/`+et) |
| + } |
| +} |
| + |
| +// gzipPool recycles compressors. We compress on every delivery, cache hits |
| +// included, so a fresh gzip.Writer per response would be a steady stream of |
| +// garbage on a busy edge for no reason. |
| +var gzipPool = sync.Pool{New: func() any { |
| + w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression) |
| + return w |
| +}} |
| + |
| +// gzipWriter compresses an eligible response on its way to the client. |
| +// |
| +// It owns a PRIVATE header map. Every other ResponseWriter wrapper in this |
| +// package embeds the writer below, so Header() returns one shared map from the |
| +// handler all the way down to the connection — and maybeStore caches |
| +// rec.Header().Clone(). Were the compression headers set on that shared map, |
| +// the cache would store an identity body labelled Content-Encoding: gzip and |
| +// hand it to every later visitor. So callers above read and write hdr, and the |
| +// edits that describe the COMPRESSED representation go on the real writer's |
| +// map alone. |
| +// |
| +// Deliberately does NOT implement io.ReaderFrom: io.Copy (and so |
| +// http.ServeFile) falls back to Write, which is what compresses. |
| +type gzipWriter struct { |
| + http.ResponseWriter |
| + hdr http.Header |
| + gz *gzip.Writer // nil unless this response is being compressed |
| + wroteHdr bool |
| +} |
| + |
| +// newGzipWriter returns a wrapper for r, or nil when this request can never |
| +// produce a compressed response — no wrapper at all, so the writer stack is |
| +// exactly what it is today. The request-side questions are settled here, once: |
| +// everything after depends only on the response. |
| +// |
| +// HEAD is excluded rather than compressed-with-no-body: there is nothing to |
| +// compress, and a HEAD's truthful Content-Length is the entire value of the |
| +// response. The accepted cost is that a HEAD advertises the identity length |
| +// while a GET of the same URL returns gzip with no length at all. |
| +func newGzipWriter(w http.ResponseWriter, r *http.Request) *gzipWriter { |
| + if r.Method == http.MethodHead || !acceptsGzip(r.Header) { |
| + return nil |
| + } |
| + return &gzipWriter{ResponseWriter: w, hdr: http.Header{}} |
| +} |
| + |
| +// Header returns the PRIVATE map. See the type's doc comment: this is what |
| +// keeps the response cache storing identity bodies. |
| +func (g *gzipWriter) Header() http.Header { return g.hdr } |
| + |
| +// syncHeader replaces the real writer's header block with the private one. |
| +// Wholesale, not a merge: a handler that DELETED a header between blocks (the |
| +// shape the purge-on-103 test exercises) must see that deletion reach the |
| +// client, which a merge would silently undo. |
| +func (g *gzipWriter) syncHeader() { |
| + down := g.ResponseWriter.Header() |
| + for k := range down { |
| + delete(down, k) |
| + } |
| + for k, v := range g.hdr { |
| + down[k] = v |
| + } |
| +} |
| + |
| +func (g *gzipWriter) WriteHeader(status int) { |
| + // 1xx blocks (103 Early Hints, forwarded by httputil.ReverseProxy) precede |
| + // the final status. Sync and forward without latching — the same shape |
| + // recordingResponseWriter.WriteHeader uses, for the same reason: latching |
| + // one would decide compression on a status that has no body. |
| + if status < 200 { |
| + g.syncHeader() |
| + g.ResponseWriter.WriteHeader(status) |
| + return |
| + } |
| + if g.wroteHdr { |
| + g.ResponseWriter.WriteHeader(status) // superfluous; let the stdlib log it |
| + return |
| + } |
| + g.wroteHdr = true |
| + g.syncHeader() |
| + if compressible(g.hdr, status) { |
| + h := g.ResponseWriter.Header() |
| + h.Set("Content-Encoding", "gzip") |
| + h.Del("Content-Length") // it described the identity body |
| + h.Add("Vary", "Accept-Encoding") |
| + weakenETag(h) |
| + g.gz = gzipPool.Get().(*gzip.Writer) |
| + g.gz.Reset(g.ResponseWriter) |
| + } |
| + g.ResponseWriter.WriteHeader(status) |
| +} |
| + |
| +func (g *gzipWriter) Write(p []byte) (int, error) { |
| + if !g.wroteHdr { |
| + g.WriteHeader(http.StatusOK) |
| + } |
| + if g.gz != nil { |
| + return g.gz.Write(p) |
| + } |
| + return g.ResponseWriter.Write(p) |
| +} |
| + |
| +// finish closes the compressor and returns it to the pool. ServeHTTP defers |
| +// it, AFTER the usage meter's defer, so LIFO runs this one first and the |
| +// metered byte count includes gzip's trailer. |
| +// |
| +// The no-header case is not a no-op: a handler that returned without ever |
| +// writing set its headers on the private map, and nothing has copied them |
| +// down. Without this sync the stdlib emits a bare 200 with none of them. |
| +func (g *gzipWriter) finish() { |
| + if !g.wroteHdr { |
| + g.syncHeader() |
| + return |
| + } |
| + g.syncTrailers() |
| + if g.gz == nil { |
| + return |
| + } |
| + g.gz.Close() |
| + g.gz.Reset(io.Discard) // don't pin this request's writer inside the pool |
| + gzipPool.Put(g.gz) |
| + g.gz = nil |
| +} |
| + |
| +// syncTrailers copies LATE additions to the private map down to the real |
| +// writer. net/http moves a header set after WriteHeader into the trailer block |
| +// when it was announced in Trailer (or carries http.TrailerPrefix) — and every |
| +// such Set landed on the private map, where syncHeader has already been and |
| +// gone. Without this the wrapper silently eats trailers, which |
| +// purge_header.go's contract names as a channel apps may legitimately use. |
| +// |
| +// The fields the compression edits own are skipped while compressing: the |
| +// private map holds the IDENTITY representation's versions of them, and |
| +// copying those down would undo the Content-Encoding, restore a Content-Length |
| +// that describes the wrong bytes, drop our Vary, and re-strengthen the ETag. |
| +func (g *gzipWriter) syncTrailers() { |
| + down := g.ResponseWriter.Header() |
| + for k, v := range g.hdr { |
| + if g.gz != nil && compressionOwned(k) { |
| + continue |
| + } |
| + down[k] = v |
| + } |
| +} |
| + |
| +// compressionOwned reports whether a header name is one the compression edits |
| +// control on the real writer's map. It is called with RAW map keys, which are |
| +// canonical — and canonical for ETag is "Etag". Spelling it "ETag" here (the |
| +// form the RFC and Header.Get use, since Get canonicalizes its argument) |
| +// matched nothing, and the identity ETag was copied back over the weakened |
| +// one. TestGzipWriterForwardsTrailers is what holds this. |
| +func compressionOwned(name string) bool { |
| + switch textproto.CanonicalMIMEHeaderKey(name) { |
| + case "Content-Encoding", "Content-Length", "Vary", "Etag": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// Flush pushes the compressor's buffered bytes out and then flushes the writer |
| +// below — needed so a streamed response reaches the client incrementally |
| +// instead of buffering until the handler returns. |
| +func (g *gzipWriter) Flush() { |
| + if g.gz != nil { |
| + g.gz.Flush() |
| + } |
| + if f, ok := g.ResponseWriter.(http.Flusher); ok { |
| + f.Flush() |
| + } |
| +} |
| + |
| +// Hijack forwards to the writer below, when it supports it — the same |
| +// optional-interface pattern countingWriter and recordingResponseWriter use. |
| +// ServeHTTP returns on the upgrade path before this wrapper is constructed, so |
| +// this is not expected on the hot path; it exists so the wrapper is never |
| +// itself the reason a hijack fails. |
| +func (g *gzipWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { |
| + hj, ok := g.ResponseWriter.(http.Hijacker) |
| + if !ok { |
| + return nil, nil, fmt.Errorf("edge: underlying ResponseWriter does not support http.Hijacker") |
| + } |
| + return hj.Hijack() |
| +} |
| + |
| +// gzipEnabled reads the CARLOS_EDGE_GZIP kill switch. Compression is ON unless |
| +// the variable names a false value — the rollback path is an env change and a |
| +// restart, not a platform binary roll. Matches the CARLOS_* env idiom in |
| +// certcache.go and dns.go. |
| +func gzipEnabled() bool { |
| + switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_EDGE_GZIP"))) { |
| + case "off", "false", "0", "no": |
| + return false |
| + } |
| + return true |
| +} |
diff --git a/internal/edge/compress_test.go b/internal/edge/compress_test.go| new file mode 100644 |
| index 000000000..24db2cfe8 |
| --- /dev/null |
| +++ b/internal/edge/compress_test.go |
| @@ -0,0 +1,807 @@ |
| +package edge |
| + |
| +import ( |
| + "bufio" |
| + "compress/gzip" |
| + "context" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestAcceptsGzip pins the q-value rules. The two that matter in production |
| +// are `gzip;q=0` (a client explicitly refusing gzip — some corporate proxies |
| +// and old curl invocations send it) and a bare `*`, which admits gzip only |
| +// when its own q is non-zero. A specific token always outranks the wildcard, |
| +// so `gzip;q=0, *` is a refusal however generous the wildcard is. |
| +func TestAcceptsGzip(t *testing.T) { |
| + for _, tc := range []struct { |
| + ae string |
| + want bool |
| + }{ |
| + {"", false}, |
| + {"gzip", true}, |
| + {"GZIP", true}, |
| + {"x-gzip", true}, |
| + {"gzip, deflate, br", true}, |
| + {" gzip ; q=0.5 ", true}, |
| + {"gzip;q=0", false}, |
| + {"gzip;q=0.0", false}, |
| + {"gzip;q=0, *", false}, |
| + {"identity", false}, |
| + {"deflate, br", false}, |
| + {"*", true}, |
| + {"*;q=0", false}, |
| + {"deflate, *;q=0.1", true}, |
| + } { |
| + h := http.Header{} |
| + if tc.ae != "" { |
| + h.Set("Accept-Encoding", tc.ae) |
| + } |
| + if got := acceptsGzip(h); got != tc.want { |
| + t.Errorf("acceptsGzip(%q) = %v, want %v", tc.ae, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestAcceptsGzipReadsEveryLine: Accept-Encoding may arrive on several lines |
| +// and that is identical in meaning to one comma-joined line (RFC 9110 §5.3). |
| +// Reading it with Header.Get would see only the first, which is the exact bug |
| +// joinHeader exists to prevent for Vary and Cache-Control. |
| +func TestAcceptsGzipReadsEveryLine(t *testing.T) { |
| + h := http.Header{} |
| + h.Add("Accept-Encoding", "deflate") |
| + h.Add("Accept-Encoding", "gzip") |
| + if !acceptsGzip(h) { |
| + t.Fatal("gzip on a second Accept-Encoding line was invisible — read every line via joinHeader, not Get") |
| + } |
| +} |
| + |
| +func TestQValue(t *testing.T) { |
| + for _, tc := range []struct { |
| + params string |
| + want float64 |
| + }{ |
| + {"", 1}, |
| + {"q=1", 1}, |
| + {"q=0", 0}, |
| + {"q=0.5", 0.5}, |
| + {" Q = 0.25 ", 0.25}, |
| + {"q=bogus", 1}, // malformed q is ignored, not treated as a refusal |
| + {"charset=utf-8", 1}, |
| + } { |
| + if got := qValue(tc.params); got != tc.want { |
| + t.Errorf("qValue(%q) = %v, want %v", tc.params, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestCompressibleEligibility walks the response-side gate. Each false case |
| +// is a response that would be WRONG or pointless to compress, not merely |
| +// unprofitable — a 206 range body, an already-encoded body, an origin that |
| +// said no-transform. |
| +func TestCompressibleEligibility(t *testing.T) { |
| + base := func() http.Header { |
| + h := http.Header{} |
| + h.Set("Content-Type", "text/css") |
| + return h |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + mutate func(http.Header) |
| + status int |
| + want bool |
| + }{ |
| + {"plain css", func(http.Header) {}, 200, true}, |
| + {"html error page", func(h http.Header) { h.Set("Content-Type", "text/html; charset=utf-8") }, 404, true}, |
| + {"json api", func(h http.Header) { h.Set("Content-Type", "application/json") }, 200, true}, |
| + {"svg", func(h http.Header) { h.Set("Content-Type", "image/svg+xml") }, 200, true}, |
| + {"long body", func(h http.Header) { h.Set("Content-Length", "4096") }, 200, true}, |
| + {"unknown length", func(h http.Header) { h.Del("Content-Length") }, 200, true}, |
| + |
| + {"no content type", func(h http.Header) { h.Del("Content-Type") }, 200, false}, |
| + {"png", func(h http.Header) { h.Set("Content-Type", "image/png") }, 200, false}, |
| + {"event stream", func(h http.Header) { h.Set("Content-Type", "text/event-stream") }, 200, false}, |
| + {"already encoded", func(h http.Header) { h.Set("Content-Encoding", "gzip") }, 200, false}, |
| + {"no-transform", func(h http.Header) { h.Set("Cache-Control", "max-age=60, no-transform") }, 200, false}, |
| + {"short body", func(h http.Header) { h.Set("Content-Length", "512") }, 200, false}, |
| + {"exactly at threshold", func(h http.Header) { h.Set("Content-Length", "1024") }, 200, true}, |
| + {"partial content", func(h http.Header) { h.Set("Content-Range", "bytes 0-99/4096") }, 206, false}, |
| + {"no content", func(http.Header) {}, 204, false}, |
| + {"reset content", func(http.Header) {}, 205, false}, |
| + {"not modified", func(http.Header) {}, 304, false}, |
| + } { |
| + h := base() |
| + tc.mutate(h) |
| + if got := compressible(h, tc.status); got != tc.want { |
| + t.Errorf("%s: compressible(%v, %d) = %v, want %v", tc.name, h, tc.status, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestCompressibleTypeSuffixes: a media type we have never heard of still |
| +// compresses when its structured suffix says it is text underneath. |
| +func TestCompressibleTypeSuffixes(t *testing.T) { |
| + for _, ct := range []string{ |
| + "application/manifest+json", "application/atom+xml", "application/ld+json", |
| + "application/vnd.api+json", "text/plain", "application/javascript", |
| + "application/wasm", "application/xml", "text/css;charset=UTF-8", |
| + } { |
| + if !compressibleType(ct) { |
| + t.Errorf("compressibleType(%q) = false, want true", ct) |
| + } |
| + } |
| + for _, ct := range []string{ |
| + "", "image/png", "video/mp4", "application/octet-stream", |
| + "application/zip", "font/woff2", "text/event-stream", |
| + } { |
| + if compressibleType(ct) { |
| + t.Errorf("compressibleType(%q) = true, want false", ct) |
| + } |
| + } |
| +} |
| + |
| +// TestWeakenETag: a gzipped body is a different representation, so its |
| +// validator must not claim byte-equality with the identity one. Weakening |
| +// keeps conditional requests working — the stdlib's If-None-Match check |
| +// compares weakly for GET — while dropping the strong-equality claim a |
| +// Range request would rely on. |
| +func TestWeakenETag(t *testing.T) { |
| + for _, tc := range []struct{ in, want string }{ |
| + {`"abc123"`, `W/"abc123"`}, |
| + {`W/"abc123"`, `W/"abc123"`}, // already weak: untouched |
| + {"", ""}, // absent: nothing to weaken |
| + } { |
| + h := http.Header{} |
| + if tc.in != "" { |
| + h.Set("ETag", tc.in) |
| + } |
| + weakenETag(h) |
| + if got := h.Get("ETag"); got != tc.want { |
| + t.Errorf("weakenETag(%q) -> %q, want %q", tc.in, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// hijackTrackingRecorder is edge_test.go's hijackableRecorder plus a record of |
| +// whether the call actually arrived — this test needs to prove the wrapper |
| +// FORWARDED the hijack, not merely that it did not error. |
| +type hijackTrackingRecorder struct { |
| + *httptest.ResponseRecorder |
| + hijacked bool |
| +} |
| + |
| +func (h *hijackTrackingRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { |
| + h.hijacked = true |
| + return nil, nil, nil |
| +} |
| + |
| +// gzipReq builds a request that accepts gzip. |
| +func gzipReq(method, url string) *http.Request { |
| + r := httptest.NewRequest(method, url, nil) |
| + r.Header.Set("Accept-Encoding", "gzip") |
| + return r |
| +} |
| + |
| +// TestGzipWriterKeepsCompressionHeadersOffTheCallerMap is THE test this design |
| +// exists for. Every writer wrapper in this package embeds the writer below, so |
| +// Header() returns one shared map all the way down — and maybeStore caches |
| +// rec.Header().Clone(). If the compression headers landed on that shared map, |
| +// the cache would store an IDENTITY body labelled Content-Encoding: gzip and |
| +// serve it to every later visitor as an undecodable response. |
| +func TestGzipWriterKeepsCompressionHeadersOffTheCallerMap(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + if g == nil { |
| + t.Fatal("newGzipWriter returned nil for a gzip-accepting GET") |
| + } |
| + g.Header().Set("Content-Type", "text/css") |
| + g.Header().Set("ETag", `"abc123"`) |
| + g.Header().Set("Content-Length", "4000") |
| + g.WriteHeader(http.StatusOK) |
| + body := strings.Repeat("a{color:red}\n", 400) |
| + io.WriteString(g, body) |
| + g.finish() |
| + |
| + if got := g.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("caller-visible Content-Encoding = %q, want empty — the recorder above would cache an identity body labelled gzip", got) |
| + } |
| + if got := g.Header().Get("ETag"); got != `"abc123"` { |
| + t.Errorf("caller-visible ETag = %q, want the strong tag untouched (the cached body IS the identity one)", got) |
| + } |
| + if got := g.Header().Get("Content-Length"); got != "4000" { |
| + t.Errorf("caller-visible Content-Length = %q, want it untouched", got) |
| + } |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Errorf("client Content-Encoding = %q, want gzip", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "" { |
| + t.Errorf("client Content-Length = %q, want it dropped — it described the identity body", got) |
| + } |
| + if got := rec.Header().Get("Vary"); got != "Accept-Encoding" { |
| + t.Errorf("client Vary = %q, want Accept-Encoding", got) |
| + } |
| + if got := rec.Header().Get("ETag"); got != `W/"abc123"` { |
| + t.Errorf("client ETag = %q, want the weakened form", got) |
| + } |
| + |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("client body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read gzip body: %v", err) |
| + } |
| + if string(got) != body { |
| + t.Fatalf("decompressed body differs from what the handler wrote") |
| + } |
| + if rec.Body.Len() >= len(body) { |
| + t.Fatalf("compressed size %d >= identity size %d", rec.Body.Len(), len(body)) |
| + } |
| +} |
| + |
| +// TestGzipWriterNilForRequestsThatCanNeverCompress: construction is where the |
| +// request-side questions are settled, once. A nil wrapper means ServeHTTP |
| +// leaves the writer stack exactly as it is today. |
| +func TestGzipWriterNilForRequestsThatCanNeverCompress(t *testing.T) { |
| + if g := newGzipWriter(httptest.NewRecorder(), httptest.NewRequest("GET", "http://app.test/", nil)); g != nil { |
| + t.Error("no Accept-Encoding must yield a nil wrapper") |
| + } |
| + if g := newGzipWriter(httptest.NewRecorder(), gzipReq("HEAD", "http://app.test/big.css")); g != nil { |
| + t.Error("HEAD must yield a nil wrapper — there is no body to compress, and its truthful Content-Length is the whole value of the response") |
| + } |
| +} |
| + |
| +// TestGzipWriterPassesThroughIneligibleResponses: an ineligible response must |
| +// be byte-identical to the unwrapped one, headers included. |
| +func TestGzipWriterPassesThroughIneligibleResponses(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/logo.png")) |
| + g.Header().Set("Content-Type", "image/png") |
| + g.Header().Set("Content-Length", "9") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, "PNG-BYTES") |
| + g.finish() |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("Content-Encoding = %q on an image, want empty", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "9" { |
| + t.Errorf("Content-Length = %q, want it preserved on a pass-through", got) |
| + } |
| + if got := rec.Body.String(); got != "PNG-BYTES" { |
| + t.Errorf("body = %q, want the identity bytes", got) |
| + } |
| +} |
| + |
| +// TestGzipWriterSilentHandlerStillDeliversHeaders: the handler set headers and |
| +// returned without ever writing. Those headers live in the PRIVATE map, so |
| +// without a sync in finish() the stdlib would emit a bare 200 with none of |
| +// them — X-Carlos-Version included. |
| +func TestGzipWriterSilentHandlerStillDeliversHeaders(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + g.Header().Set("X-Carlos-Version", "abc123") |
| + g.finish() |
| + |
| + if got := rec.Header().Get("X-Carlos-Version"); got != "abc123" { |
| + t.Fatalf("X-Carlos-Version = %q, want abc123 — a handler that wrote nothing still lost every header it set", got) |
| + } |
| +} |
| + |
| +// TestGzipWriter1xxDoesNotLatch mirrors recordingResponseWriter.WriteHeader: a |
| +// 103 Early Hints block precedes the final status. Latching it would freeze |
| +// the compression decision on a status that has no body, and the real response |
| +// would then be delivered uncompressed — or worse, with the hints lost, since |
| +// they live in the private map. |
| +func TestGzipWriter1xxDoesNotLatch(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + g.Header().Set("Link", "</tokens.css>; rel=preload") |
| + g.WriteHeader(http.StatusEarlyHints) |
| + |
| + if got := rec.Header().Get("Link"); got != "</tokens.css>; rel=preload" { |
| + t.Errorf("early-hints Link = %q, want it forwarded — it was set on the private map", got) |
| + } |
| + |
| + g.Header().Del("Link") |
| + g.Header().Set("Content-Type", "text/html") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, strings.Repeat("<p>hi</p>", 200)) |
| + g.finish() |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Errorf("Content-Encoding = %q after a 103, want gzip — the 1xx latched the decision", got) |
| + } |
| + if got := rec.Header().Get("Link"); got != "" { |
| + t.Errorf("Link = %q, want empty — the final header block replaces the informational one", got) |
| + } |
| +} |
| + |
| +// TestGzipWriterFlushReachesTheClient: a flushed response must actually leave |
| +// the compressor, not sit in its buffer until the handler returns. SSE never |
| +// compresses (content type), but a streamed HTML page does. |
| +func TestGzipWriterFlushReachesTheClient(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/stream")) |
| + g.Header().Set("Content-Type", "text/html") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, strings.Repeat("<p>chunk</p>", 100)) |
| + g.Flush() |
| + |
| + if rec.Body.Len() == 0 { |
| + t.Fatal("Flush left the bytes inside the compressor — a streaming response would hang until the handler returned") |
| + } |
| + if !rec.Flushed { |
| + t.Fatal("Flush did not reach the writer below") |
| + } |
| + g.finish() |
| +} |
| + |
| +// TestGzipWriterForwardsHijack: the upgrade path returns before this wrapper |
| +// is ever built, so this is not a hot path — it exists so the wrapper is never |
| +// itself the reason a hijack fails, matching the convention countingWriter and |
| +// recordingResponseWriter already follow. |
| +func TestGzipWriterForwardsHijack(t *testing.T) { |
| + rec := &hijackTrackingRecorder{ResponseRecorder: httptest.NewRecorder()} |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + if _, _, err := g.Hijack(); err != nil { |
| + t.Fatalf("Hijack: %v", err) |
| + } |
| + if !rec.hijacked { |
| + t.Fatal("Hijack did not reach the writer below") |
| + } |
| +} |
| + |
| +// edgeWithBackend builds an Edge with one proxied route, "app.test", pointed |
| +// at h. Driven with httptest.NewRecorder + httptest.NewRequest rather than a |
| +// real client: http.Transport adds its own Accept-Encoding and transparently |
| +// decompresses what comes back, which would hide exactly what these tests are |
| +// checking. |
| +func edgeWithBackend(t *testing.T, h http.Handler) *Edge { |
| + t.Helper() |
| + backend := httptest.NewServer(h) |
| + t.Cleanup(backend.Close) |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.test", Kind: "service", Status: "ready", |
| + Addr: backend.Listener.Addr().String(), |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + return e |
| +} |
| + |
| +// cssBackend answers every request with a cacheable stylesheet of n bytes. |
| +func cssBackend(n int) http.Handler { |
| + body := strings.Repeat("a{color:red}\n", n/13+1)[:n] |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + w.Header().Set("Cache-Control", "max-age=600") |
| + io.WriteString(w, body) |
| + }) |
| +} |
| + |
| +// TestEdgeCompressesProxiedAssets is the payoff: an app ships identity bytes |
| +// and the edge puts them on the wire compressed. |
| +func TestEdgeCompressesProxiedAssets(t *testing.T) { |
| + e := edgeWithBackend(t, cssBackend(4096)) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("Content-Encoding = %q, want gzip", got) |
| + } |
| + if got := rec.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") { |
| + t.Errorf("Vary = %q, want it to name Accept-Encoding", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read gzip body: %v", err) |
| + } |
| + if len(got) != 4096 { |
| + t.Fatalf("decompressed %d bytes, want 4096", len(got)) |
| + } |
| +} |
| + |
| +// TestEdgeCacheStoresIdentityBodies is the invariant the whole writer ordering |
| +// exists to hold. The recorder sits ABOVE the compressor, so what lands in the |
| +// cache is the identity body with the app's own headers — one variant, not one |
| +// per encoding — and a client that cannot decode gzip is still served correctly |
| +// from that same entry. |
| +func TestEdgeCacheStoresIdentityBodies(t *testing.T) { |
| + e := edgeWithBackend(t, cssBackend(4096)) |
| + e.EnableCache(NewCache(10, 1<<20)) |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) // miss |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("miss: Content-Encoding = %q, want gzip", got) |
| + } |
| + |
| + stored, ok := e.cache.get(CacheKey{Host: "app.test", Method: "GET", Path: "/tokens.css"}) |
| + if !ok { |
| + t.Fatal("the response was not cached — this test cannot say anything about what was stored") |
| + } |
| + if ce := stored.Header.Get("Content-Encoding"); ce != "" { |
| + t.Errorf("cached entry carries Content-Encoding %q: an identity body labelled gzip, served to every later visitor as an undecodable response", ce) |
| + } |
| + if v := stored.Header.Get("Vary"); strings.Contains(v, "Accept-Encoding") { |
| + t.Errorf("cached entry carries Vary %q: the edge's own Vary belongs below the recorder, not in the cache key", v) |
| + } |
| + if len(stored.Body) != 4096 { |
| + t.Errorf("cached body is %d bytes, want the 4096-byte identity body", len(stored.Body)) |
| + } |
| + |
| + hit := httptest.NewRecorder() |
| + e.ServeHTTP(hit, gzipReq("GET", "http://app.test/tokens.css")) |
| + if got := hit.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("cache hit: Content-Encoding = %q, want gzip — a hit is a delivery like any other", got) |
| + } |
| + zr, err := gzip.NewReader(hit.Body) |
| + if err != nil { |
| + t.Fatalf("cache hit body is not gzip: %v", err) |
| + } |
| + if b, err := io.ReadAll(zr); err != nil || len(b) != 4096 { |
| + t.Fatalf("cache hit decompressed to %d bytes (err %v), want 4096", len(b), err) |
| + } |
| + |
| + plain := httptest.NewRecorder() |
| + e.ServeHTTP(plain, httptest.NewRequest("GET", "http://app.test/tokens.css", nil)) |
| + if got := plain.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("identity client got Content-Encoding %q from the shared entry", got) |
| + } |
| + if plain.Body.Len() != 4096 { |
| + t.Errorf("identity client got %d bytes, want the 4096-byte identity body", plain.Body.Len()) |
| + } |
| +} |
| + |
| +// TestEdgeMetersCompressedBytes: a tenant is billed for what crossed the wire. |
| +// The counter sits BELOW the compressor and finish() runs before Record — the |
| +// equality below is what pins that defer order, since gzip buffers until Close |
| +// and a reversed order would meter a fraction of the bytes. |
| +func TestEdgeMetersCompressedBytes(t *testing.T) { |
| + e := edgeWithBackend(t, cssBackend(8192)) |
| + m := NewUsageMeter() |
| + e.EnableUsage(m) |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + |
| + row, ok := m.Drain()["app.test"] |
| + if !ok { |
| + t.Fatal("no usage row for app.test") |
| + } |
| + if row.BytesOut != int64(rec.Body.Len()) { |
| + t.Fatalf("metered %d bytes, client received %d — finish() must run before Record (defer order) or the trailer is missed", row.BytesOut, rec.Body.Len()) |
| + } |
| + if row.BytesOut >= 8192 { |
| + t.Fatalf("metered %d bytes for an 8192-byte identity body — the meter is counting bytes the edge did not send", row.BytesOut) |
| + } |
| +} |
| + |
| +// TestEdgeGzipKillSwitch: CARLOS_EDGE_GZIP=off is the rollback that needs no |
| +// redeploy, so it must produce today's bytes exactly. |
| +func TestEdgeGzipKillSwitch(t *testing.T) { |
| + t.Setenv("CARLOS_EDGE_GZIP", "off") |
| + e := edgeWithBackend(t, cssBackend(4096)) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("Content-Encoding = %q with the kill switch off, want empty", got) |
| + } |
| + if got := rec.Header().Get("Vary"); strings.Contains(got, "Accept-Encoding") { |
| + t.Errorf("Vary = %q with the kill switch off — nothing may hint at a variant that is never produced", got) |
| + } |
| + if rec.Body.Len() != 4096 { |
| + t.Errorf("body = %d bytes, want the 4096-byte identity body", rec.Body.Len()) |
| + } |
| +} |
| + |
| +// TestGzipWriterForwardsTrailers: net/http moves a header set AFTER |
| +// WriteHeader into the trailer block when it was announced in Trailer. Every |
| +// such Set lands on the private map, where syncHeader has already been and |
| +// gone — without a late sync the wrapper silently eats trailers, and |
| +// purge_header.go's contract names them as a channel apps may use. |
| +func TestGzipWriterForwardsTrailers(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + g.Header().Set("Trailer", "Carlos-Purge") |
| + g.Header().Set("Content-Type", "text/html") |
| + g.Header().Set("ETag", `"strong"`) |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, strings.Repeat("<p>hi</p>", 200)) |
| + g.Header().Set("Carlos-Purge", "/trailed") // after the body: a trailer |
| + g.finish() |
| + |
| + if got := rec.Header().Get("Carlos-Purge"); got != "/trailed" { |
| + t.Fatalf("trailer Carlos-Purge = %q, want /trailed — the private map swallowed it", got) |
| + } |
| + // The late sync must not undo the compression edits by copying the |
| + // identity representation's fields back down. |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Errorf("Content-Encoding = %q after the trailer sync, want gzip", got) |
| + } |
| + if got := rec.Header().Get("ETag"); got != `W/"strong"` { |
| + t.Errorf("ETag = %q after the trailer sync, want the weakened form", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "" { |
| + t.Errorf("Content-Length = %q after the trailer sync, want it still dropped", got) |
| + } |
| + if got := rec.Header().Get("Vary"); got != "Accept-Encoding" { |
| + t.Errorf("Vary = %q after the trailer sync, want Accept-Encoding", got) |
| + } |
| +} |
| + |
| +// TestUpgradeIsUntouchedByCompression: the wrap site is after the upgrade |
| +// branch precisely so a handshake keeps the writer stack it has always had. |
| +// A gzip-accepting client (every browser) must still get its 101, and bytes |
| +// must still flow both ways after the switch. |
| +func TestUpgradeIsUntouchedByCompression(t *testing.T) { |
| + addr := upgradeEchoBackend(t, "ws") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ws.test", Kind: "service", Status: "ready", Addr: addr, |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + c, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://")) |
| + if err != nil { |
| + t.Fatalf("dial edge: %v", err) |
| + } |
| + defer c.Close() |
| + if err := c.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { |
| + t.Fatalf("set deadline: %v", err) |
| + } |
| + if _, err := fmt.Fprint(c, "GET /socket HTTP/1.1\r\nHost: ws.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nAccept-Encoding: gzip\r\n\r\n"); err != nil { |
| + t.Fatalf("write handshake: %v", err) |
| + } |
| + br := bufio.NewReader(c) |
| + resp, err := http.ReadResponse(br, nil) |
| + if err != nil { |
| + t.Fatalf("read handshake response: %v", err) |
| + } |
| + if resp.StatusCode != http.StatusSwitchingProtocols { |
| + t.Fatalf("handshake status = %d, want 101 — an Accept-Encoding header must not divert an upgrade through the compressor", resp.StatusCode) |
| + } |
| + if _, err := fmt.Fprint(c, "ping\n"); err != nil { |
| + t.Fatalf("write post-upgrade frame: %v", err) |
| + } |
| + line, err := br.ReadString('\n') |
| + if err != nil { |
| + t.Fatalf("read post-upgrade frame: %v", err) |
| + } |
| + if line != "ws:ping\n" { |
| + t.Fatalf("post-upgrade echo = %q, want %q — bytes must flow both ways after the switch", line, "ws:ping\n") |
| + } |
| +} |
| + |
| +// TestAlreadyEncodedUpstreamPassesThrough: an app that compresses for itself |
| +// must not be compressed again into a body nobody can decode. |
| +func TestAlreadyEncodedUpstreamPassesThrough(t *testing.T) { |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + var buf strings.Builder |
| + zw := gzip.NewWriter(&buf) |
| + io.WriteString(zw, strings.Repeat("already compressed\n", 200)) |
| + zw.Close() |
| + w.Header().Set("Content-Type", "text/plain") |
| + w.Header().Set("Content-Encoding", "gzip") |
| + io.WriteString(w, buf.String()) |
| + })) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/pre.txt")) |
| + |
| + if got := rec.Header().Values("Content-Encoding"); len(got) != 1 || got[0] != "gzip" { |
| + t.Fatalf("Content-Encoding = %v, want exactly one gzip", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body did not survive as single-encoded gzip: %v", err) |
| + } |
| + body, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read body: %v", err) |
| + } |
| + if !strings.HasPrefix(string(body), "already compressed") { |
| + t.Fatalf("body = %.40q, want the upstream's own gzip decoded in ONE pass", body) |
| + } |
| +} |
| + |
| +// TestEventStreamIsNotCompressed: SSE is an open stream consumed event by |
| +// event. Buffering it inside a compressor is a hang, not a saving. |
| +func TestEventStreamIsNotCompressed(t *testing.T) { |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/event-stream") |
| + w.WriteHeader(http.StatusOK) |
| + for i := 0; i < 3; i++ { |
| + fmt.Fprintf(w, "data: %d\n\n", i) |
| + if f, ok := w.(http.Flusher); ok { |
| + f.Flush() |
| + } |
| + } |
| + })) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://app.test/events")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Fatalf("Content-Encoding = %q on an event stream, want empty", got) |
| + } |
| + if !strings.Contains(rec.Body.String(), "data: 0") { |
| + t.Fatalf("event stream body = %q, want the plain events", rec.Body.String()) |
| + } |
| +} |
| + |
| +// TestRangeAndConditionalRequestsSurvive: a 206 must keep its byte offsets |
| +// meaningful, and a conditional request must still 304 against the weakened |
| +// validator the client was given. |
| +func TestRangeAndConditionalRequestsSurvive(t *testing.T) { |
| + body := strings.Repeat("a{color:red}\n", 400) |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + w.Header().Set("ETag", `"v1"`) |
| + http.ServeContent(w, r, "tokens.css", time.Unix(1, 0), strings.NewReader(body)) |
| + })) |
| + |
| + rng := gzipReq("GET", "http://app.test/tokens.css") |
| + rng.Header.Set("Range", "bytes=0-9") |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, rng) |
| + if rec.Code != http.StatusPartialContent { |
| + t.Fatalf("range status = %d, want 206", rec.Code) |
| + } |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("206 carries Content-Encoding %q — compressing a byte range makes the offsets it names meaningless", got) |
| + } |
| + if rec.Body.Len() != 10 { |
| + t.Errorf("range body = %d bytes, want 10", rec.Body.Len()) |
| + } |
| + |
| + full := httptest.NewRecorder() |
| + e.ServeHTTP(full, gzipReq("GET", "http://app.test/tokens.css")) |
| + etag := full.Header().Get("ETag") |
| + if !strings.HasPrefix(etag, `W/`) { |
| + t.Fatalf("ETag = %q on a compressed body, want the weakened form", etag) |
| + } |
| + cond := gzipReq("GET", "http://app.test/tokens.css") |
| + cond.Header.Set("If-None-Match", etag) |
| + nm := httptest.NewRecorder() |
| + e.ServeHTTP(nm, cond) |
| + if nm.Code != http.StatusNotModified { |
| + t.Fatalf("conditional request = %d, want 304 — weakening the validator broke revalidation", nm.Code) |
| + } |
| + if got := nm.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("304 carries Content-Encoding %q; a 304 has no body to encode", got) |
| + } |
| +} |
| + |
| +// TestStatusHostCompresses: the status class reaches the wrap site through a |
| +// different door (its own handler, exempt from the shared cache and the usage |
| +// meter) and must compress like everything else. |
| +func TestStatusHostCompresses(t *testing.T) { |
| + s, h := testEdgeWithStore(t, registry.Route{Host: "status.test", Kind: "status", Status: "ready"}) |
| + s.Put(context.Background(), "status/fleet.json", []byte(`{"boxes":[`+strings.Repeat(`{"id":"box"},`, 300)+`{}]}`), "") |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, gzipReq("GET", "http://status.test/fleet.json")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("Content-Encoding = %q on the status host, want gzip", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil || !strings.Contains(string(got), `"boxes"`) { |
| + t.Fatalf("decompressed status body = %.40q (err %v)", got, err) |
| + } |
| +} |
| + |
| +// TestStaticSiteCompresses: the static class is the payoff case — a shipped |
| +// site tree whose CSS is served straight off disk by http.ServeFile. It |
| +// reaches the wrap site through Edge.ServeHTTP like every other class, so |
| +// this pins that the class nobody proxies is covered too. |
| +func TestStaticSiteCompresses(t *testing.T) { |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + priv, pub := testKeys(t) |
| + css := strings.Repeat("a{color:red}\n", 400) // 5200 bytes, well over the threshold |
| + shipSite(t, store, "site", "v1", map[string]string{ |
| + "index.html": "home", |
| + "tokens.css": css, |
| + }) |
| + if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "site.test", Kind: "static", App: "site", Channel: "canary/x", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("GET", "http://site.test/tokens.css")) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("status = %d, want 200", rec.Code) |
| + } |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Fatalf("Content-Encoding = %q on a static asset, want gzip", got) |
| + } |
| + if got := rec.Header().Get("X-Carlos-Version"); got != "v1" { |
| + t.Errorf("X-Carlos-Version = %q, want v1 — compression must not disturb the static class's per-request version stamp", got) |
| + } |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read gzip body: %v", err) |
| + } |
| + if string(got) != css { |
| + t.Fatalf("decompressed static asset differs from the shipped file") |
| + } |
| + if rec.Body.Len() >= len(css) { |
| + t.Fatalf("compressed %d bytes >= identity %d", rec.Body.Len(), len(css)) |
| + } |
| +} |
| + |
| +// TestHeadKeepsItsContentLength: a HEAD has no body to compress, and its |
| +// truthful Content-Length is the whole value of the response. The backend |
| +// sets the length explicitly — Go only infers one when the whole body fits |
| +// its 2048-byte buffer, so a 4096-byte handler would otherwise chunk and this |
| +// test would be asserting the stdlib's buffering, not the edge's behaviour. |
| +func TestHeadKeepsItsContentLength(t *testing.T) { |
| + e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + w.Header().Set("Content-Length", "4096") |
| + w.WriteHeader(http.StatusOK) |
| + if r.Method != http.MethodHead { |
| + io.WriteString(w, strings.Repeat("x", 4096)) |
| + } |
| + })) |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, gzipReq("HEAD", "http://app.test/tokens.css")) |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("HEAD Content-Encoding = %q, want empty", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "4096" { |
| + t.Errorf("HEAD Content-Length = %q, want 4096", got) |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 151be4f35..12c148917 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -157,6 +157,10 @@ type Edge struct { |
| // quietly 503s. Never stopped in production (the edge lives as long as the |
| // process); tests Stop it so probe goroutines don't outlive them. |
| probers *proberSet |
| + // gzipOn is the CARLOS_EDGE_GZIP kill switch, read once in New. When |
| + // false, ServeHTTP never constructs a gzipWriter and the writer stack is |
| + // byte-for-byte what it was before compression existed (compress.go). |
| + gzipOn bool |
| } |
| // upstream is one served host: the handler for its backend, plus — when the |
| @@ -179,7 +183,7 @@ type upstream struct { |
| // New builds an Edge over reg and performs the startup reload. act may be nil |
| // (no hibernation). |
| func New(reg *registry.Registry, act Activator) (*Edge, error) { |
| - e := &Edge{reg: reg, act: act, probers: newProberSet()} |
| + e := &Edge{reg: reg, act: act, probers: newProberSet(), gzipOn: gzipEnabled()} |
| if err := e.Reload(); err != nil { |
| return nil, err |
| } |
| @@ -801,6 +805,24 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + // Response compression (compress.go). Placed AFTER the upgrade branch, so |
| + // an upgrading request keeps the exact writer stack it has always had — |
| + // purge_header.go's contract records what wrapping the raw ResponseWriter |
| + // on that path has already cost this package — and BEFORE the cache check, |
| + // so a cache hit compresses on replay like any other delivery. |
| + // |
| + // The recorders constructed below wrap THIS writer, so they record |
| + // identity bytes and the cache keeps storing identity bodies; the usage |
| + // meter's countingWriter is below it, so the metered count is the |
| + // compressed one. This defer is registered after the meter's, so LIFO |
| + // closes the gzip stream — trailer included — before Record reads it. |
| + if e.gzipOn { |
| + if gw := newGzipWriter(w, r); gw != nil { |
| + w = gw |
| + defer gw.finish() |
| + } |
| + } |
| + |
| // Cache check happens before the hibernation branch below — a hit must |
| // never reach the activator, since avoiding pointless wakes for |
| // bot/crawler/repeat traffic is the entire point of this cache. |