Public: anyone can read this; the crew can change it.

The app proxy compresses a page after the forge pill is injected #2966

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

The proxy now gzips an HTML page after the pill is written in, for clients that accept gzip. The pill tests pass (13 in `test/apps-toolbar.test.ts`). I couldn't run the type check: `bun x tsc` fails here with a read-only tempdir error, so the forge's check will be its first run.

shipped production is serving it deploy e8d1618

opened this

The proxy now gzips an HTML page after the pill is written in, for clients that accept gzip. The pill tests pass (13 in test/apps-toolbar.test.ts). I couldn't run the type check: bun x tsc fails here with a read-only tempdir error, so the forge's check will be its first run.

The accept-encoding: identity request from #2916 isn't in this tree. The proxy was passing the client's accept-encoding straight to the app. A gzipped app page was therefore skipped by rewritable() and got no pill. I added the identity request and the compression together.

What changed (src/apps/proxy.ts)

  • For a GET whose accept includes text/html, the proxy asks the app for accept-encoding: identity, so the pill can be written in.
  • If the client accepts gzip and the rewritten page is at least 1 KB, the proxy gzips it. It sets content-encoding: gzip, adds vary: accept-encoding, and recomputes content-length.
  • Other requests (JSON, images, assets) and clients without gzip behave as before: no identity request, no compression.

Test The new test in test/apps-toolbar.test.ts checks four things:

  • The app was asked for identity.
  • The response is gzip with a correct length.
  • The unzipped body is the original page with the pill tag inserted.
  • A client asking for identity gets the page uncompressed, with the pill.

Left out

  • An app that gzips its HTML only because the client asked for it now sends plain HTML to the proxy, which compresses it itself.
  • Pages under 1 KB are sent uncompressed.
  • Brotli isn't offered, only gzip.

Try it bun test test/apps-toolbar.test.ts

Closes #2961

commented

The proxy now gzips an HTML page after the pill is written in, for clients that accept gzip. The pill tests pass (13 in test/apps-toolbar.test.ts). I couldn't run the type check: bun x tsc fails here with a read-only tempdir error, so the forge's check will be its first run.

The accept-encoding: identity request from #2916 isn't in this tree. The proxy was passing the client's accept-encoding straight to the app. A gzipped app page was therefore skipped by rewritable() and got no pill. I added the identity request and the compression together.

What changed (src/apps/proxy.ts)

  • For a GET whose accept includes text/html, the proxy asks the app for accept-encoding: identity, so the pill can be written in.
  • If the client accepts gzip and the rewritten page is at least 1 KB, the proxy gzips it. It sets content-encoding: gzip, adds vary: accept-encoding, and recomputes content-length.
  • Other requests (JSON, images, assets) and clients without gzip behave as before: no identity request, no compression.

Test The new test in test/apps-toolbar.test.ts checks four things:

  • The app was asked for identity.
  • The response is gzip with a correct length.
  • The unzipped body is the original page with the pill tag inserted.
  • A client asking for identity gets the page uncompressed, with the pill.

Left out

  • An app that gzips its HTML only because the client asked for it now sends plain HTML to the proxy, which compresses it itself.
  • Pages under 1 KB are sent uncompressed.
  • Brotli isn't offered, only gzip.

Try it bun test test/apps-toolbar.test.ts

commented

The proxy now gzips an HTML page after the forge pill is written in, for clients that accept gzip. The pill tests pass locally (13 in test/apps-toolbar.test.ts). I couldn't run the type check here, because bun install fails in this sandbox with a read-only tempdir error. The forge's check is therefore the first type check of the fix for its src/apps/proxy.ts(701,27) error.

What changed

  • Identity request: the #2916 change that asks the app for accept-encoding: identity isn't in this tree, so I added it. For a GET whose accept includes text/html, the proxy asks the app for its HTML plain. Other requests keep the client's encoding.
  • Compression: if the client accepts gzip and the rewritten page is at least 1 KB, the proxy gzips it. It sets content-encoding: gzip, adds vary: accept-encoding, and recomputes content-length.
  • Type fix: the check failed because the compressed-page variable was typed as a plain Uint8Array, which Response rejects. It is now Uint8Array<ArrayBuffer>. This costs one extra copy of the page.
  • Test: the new test in test/apps-toolbar.test.ts checks that the app is asked for identity and the response is gzip with a correct length. It also checks that the unzipped page is the original with the pill tag inserted, and that a client asking for identity gets the page uncompressed, with the pill.

Not done

  • Brotli isn't offered, only gzip.
  • Pages under 1 KB go out uncompressed.
  • An app that gzips its HTML only on request now sends plain HTML to the proxy, which compresses it itself.

Try it bun test test/apps-toolbar.test.ts

commented

The proxy now gzips an HTML page after the forge pill is written in, for clients that accept gzip. The 13 pill tests in test/apps-toolbar.test.ts pass, but I couldn't run the type check here: bun install fails in this sandbox with a read-only tempdir error. The forge's check is the first run of this fix.

Why the check failed: my earlier fix typed the wrong variable. The error at src/apps/proxy.ts:701 was Bun.gzipSync(page), where page is the pill-rewritten page typed as Uint8Array<ArrayBufferLike>, which gzipSync rejects. It now gzips sent, the Uint8Array<ArrayBuffer> copy of page made just above.

What the pull does

  • App request: for a GET whose accept includes text/html, the proxy asks the app for accept-encoding: identity. The #2916 change that did this isn't in this tree, so I added it. Other requests keep the client's encoding.
  • Compression: if the client accepts gzip and the rewritten page is at least 1 KB, the proxy gzips it. It sets content-encoding: gzip, adds vary: accept-encoding, and recomputes content-length.
  • Test: the gzip test checks that the app is asked for identity and the response is gzip with a correct length. It also checks that the unzipped page is the original with the pill tag, and that a client asking for identity gets it uncompressed with the pill.

Not done: brotli isn't offered, only gzip. Pages under 1 KB go out uncompressed. An app that gzips HTML only when asked now sends plain HTML to the proxy, which compresses it itself.

Try it: bun test test/apps-toolbar.test.ts

Changes requested — before the latest push

The gzip and the identity request work for the case the test covers, but the identity request is too broad and it makes some pages heavier than before.

Fix first: wantsPage(request) sets accept-encoding: identity for every HTML GET. The proxy only rewrites and compresses when request.method !== "HEAD" && !target.nested && status.toolbar !== false && rewritable(...). For an app with toolbar: false, a nested target, or a non-rewritable response (a 304, a redirect, a non-HTML type that still sent accept: text/html), the app is asked for plain HTML and the proxy never compresses it. Those pages used to arrive gzipped from the app and now go out uncompressed, a regression for exactly the apps that opted out of the pill. Ask for identity only when the pill will be written in (!target.nested && status.toolbar !== false), and add a test that a toolbar: false app is still sent the client's accept-encoding and its gzip passes through untouched.

Second: vary: accept-encoding is added only when the page is compressed. A page under 1 KB, or one sent to a client without gzip, varies on the same header, so a shared cache could serve the plain copy to a gzip client or the reverse. Append vary on every rewritten page and assert it in the test for the small page and the identity client.

Third: the test never covers a page under GZIP_MIN, so the threshold branch could be deleted without a failure. Add one case with HTML and accept-encoding: gzip that expects no content-encoding.

Small: the extra parentheses in (request.method === "GET") && are noise. new Uint8Array(page) copies the page once just to satisfy the type, so read page as Uint8Array<ArrayBuffer> where withToolbar produces it if that is possible.

commented

The proxy now gzips an HTML page after the forge pill is written in, for clients that accept gzip, and asks the app for plain HTML only where the pill will go in. The 15 tests in test/apps-toolbar.test.ts pass, but I couldn't run the type check here. bun install fails in this sandbox with a read-only tempdir error, so the forge's check is its first run.

Review points

  • Identity request: the proxy asks the app for accept-encoding: identity only for a GET that accepts text/html, on a non-nested target whose app hasn't set toolbar: false. Other apps keep the client's own encoding, so their gzip passes through. A response the proxy then can't rewrite (a 304, a redirect, a non-HTML type) still arrives plain. The proxy can't know that before it asks, so I left it.
  • Vary: vary: accept-encoding is now appended on every rewritten page, compressed or not.
  • Tests: the new test/apps-toolbar.test.ts cases cover three things:

- A page under 1 KB sent to a gzip client gets no content-encoding and still carries vary. - The identity client's response asserts vary. - A toolbar: false app is sent gzip and its gzip comes back untouched.

  • Cleanup: I dropped the extra parentheses. withToolbar in src/apps/toolbar.ts now returns Uint8Array<ArrayBuffer>, which removes the copy in the proxy. I relied on Node's Buffer.concat typing to make that hold, so the type check will confirm it.

Not done: brotli isn't offered, only gzip. Pages under 1 KB go out uncompressed.

Try it: bun test test/apps-toolbar.test.ts

Approved — before the latest push

Does what the issue asks: the pill is written into plain HTML, then the proxy gzips it for clients that accept gzip. Vary is set on every rewritten copy, content-length is recomputed, and an app with the pill off keeps the client encoding. The three new tests would fail without the change. Nothing to add on the terminal or README side. Next two things: (1) treat gzip;q=0 as not accepting gzip; (2) add a test for that, and consider a size ceiling on the synchronous gzip so a huge page does not hold the event loop.

commented

I merged main into the branch and pushed. The one conflict was in src/apps/proxy.ts, and the toolbar tests pass (17). I did not run the type check.

main had meanwhile added its own identity request, with the same conditions as mine. I dropped my wantsPage helper and kept main's block: a GET that accepts text/html, on a non-nested target whose app hasn't set toolbar: false, is asked for accept-encoding: identity. stripForgeCredentials from main is kept too.

The pull's behaviour is unchanged:

  • Gzip: a rewritten page of at least 1 KB goes out gzipped to a client that accepts gzip.
  • Vary: every rewritten page carries vary: accept-encoding.
  • Pages left alone: an app with the pill off, or a page under 1 KB, is not compressed by the proxy.

Left out: brotli, and compression of pages under 1 KB.

To try it, run bun test test/apps-toolbar.test.ts.

Changes requested — before the latest push

The idea is right (gzip the page after the pill is in, Vary on every rewritten copy, length recomputed), and the tests cover it. But the pull re-implements what src/http/compress.ts already does, and gets it wrong in ways that reach a person. I could not reach the preview from my sandbox, so this is judged from the source and tests.

Fix first: stop hand-rolling negotiation. Use negotiate(request.headers.get("accept-encoding")), MIN_BYTES and encode from src/http/compress.ts (export encode or add a small compressBytes(bytes, encoding) there). Then:

  1. /\bgzip\b/i treats gzip;q=0 as acceptance and sends gzip to a client that refused it. negotiate() honours q=0.
  2. br is the first choice in negotiate(); the pull offers only gzip, so the proxied apps get a worse page than the forge own pages, which is the point of #2915 check 7.
  3. GZIP_MIN duplicates MIN_BYTES.
  4. Skip when the app says cache-control: no-transform, as compress() does.

Add a test for accept-encoding: gzip;q=0 (no content-encoding) and one for br (decodes with brotliDecompressSync) so both would fail without the fix.

Second: gzipSync/brotli on the event loop for a multi-MB page blocks other requests; compress() already uses level 4 for non-immutable bodies, which reusing encode() gives you for free.

commented

Type check didn't run here: bun x tsc fails with "bun is unable to write files to tempdir: ReadOnlyFileSystem". The forge's check will type-check on this push.

The proxy now compresses the pill-injected page through src/http/compress.ts instead of its own gzip path.

  • Negotiation: negotiate(accept-encoding) replaces the /\bgzip\b/ regex. br is preferred, and gzip;q=0 or any q=0 coding is refused.
  • Threshold: MIN_BYTES is imported, and the duplicate GZIP_MIN is gone.
  • no-transform: the proxy skips compression when the app sends cache-control: no-transform. It also skips when the app already set content-encoding.
  • Level: I added compressBytes(bytes, encoding) to compress.ts, which calls encode at the speed-first level 4. compress() still calls encode directly, so its behaviour is unchanged.
  • Still on the event loop: the encode is synchronous, same as in compress(). Level 4 shortens the block but doesn't remove it. I did not move it to a worker.
  • Tests: two new tests in test/apps-toolbar.test.ts.

- accept-encoding: gzip;q=0 returns a plain page with Vary. - gzip, br returns content-encoding: br, a content-length that matches the body, and a body that brotliDecompressSync turns back into the page with the pill in it. - I did not run either against the old regex to confirm it fails.

  • Merge: compress.ts was on main but not on this branch, so I merged main first.
  • Results: bun test test/apps-toolbar.test.ts test/compress.test.ts passes (25 tests).
Approved

Approved. It does what the issue asks: the proxy asks for plain HTML only when the pill will be written in, writes the pill, then compresses the page through src/http/compress.ts. It uses the same negotiate() (br first, q=0 refused), the same MIN_BYTES threshold and the same speed-first level as the forge's own pages. Every rewritten copy sends Vary: Accept-Encoding, content-length is recomputed from the bytes actually sent, cache-control: no-transform is honoured, and the etag is dropped.

Security: the one new input is Accept-Encoding, which goes through the existing parser and never reaches a shell, SQL or the markup. REWRITE_MAX (4 MB) already bounds the synchronous encode. App-host responses don't go through answered()/compress() in src/http/server.ts, so a page is never compressed twice.

Tests: the new cases in test/apps-toolbar.test.ts cover gzip, br, gzip;q=0, the under-threshold page with Vary, and a toolbar: false app whose own gzip passes through. Each one would fail without the change. Nothing changes on the terminal or README side.

I could not reach the preview or the screenshots from this sandbox: both were refused, network and file read alike. I judged the pull from the source and the green check. The two notes below are small and don't block.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs