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

scout/forge:scout/2961-the-app-proxy-compresses-a-page-after-th → main 7 commits fork 5 threads · 5 unresolved

  • Merged. scout/2961-the-app-proxy-compresses-a-page-after-th was merged into main as bd675dc.

Review

Sign in to review this change.
4 files changed +104 −7
src/apps/proxy.ts +18 −4
@@ -14,6 +14,7 @@
1414 * directory — so guessing a hostname yields the forge's own "not found".
1515 */
1616
17 import { compressBytes, MIN_BYTES, negotiate } from "../http/compress.ts";
1718 import type { Database } from "bun:sqlite";
1819 import { getIssue, type Issue } from "../core/issues.ts";
1920 import { mayView } from "../core/sealed.ts";
@@ -649,7 +650,8 @@ export async function proxyApp(
649650 headers.set(name, value);
650651
651652 // A page the pill may be written into is asked for plain: the rewrite
652 // cannot read a compressed body, and the app compresses what it is allowed.
653 // cannot read a compressed body, and the proxy compresses what it wrote.
outdated

This regex accepts gzip;q=0 and ignores br. Use negotiate() from src/http/compress.ts.

654 const encoding = negotiate(request.headers.get("accept-encoding"));
653655 if (
654656 request.method === "GET" &&
655657 !target.nested &&
@@ -718,7 +720,7 @@ export async function proxyApp(
718720 response.body !== null &&
719721 rewritable(out, response.status)
720722 ) {
721 let page: Uint8Array | undefined;
723 let page: Uint8Array<ArrayBuffer> | undefined;
722724 try {
723725 page = withToolbar(
724726 new Uint8Array(await response.arrayBuffer()),
@@ -728,10 +730,22 @@ export async function proxyApp(
728730 } catch {
729731 return unavailable(runtime, target, status, "the app stopped answering");
730732 }
731 out.set("content-length", String(page.length));
733 let sent = page;
734 // Whether it is gzipped depends on the client, so every copy says so.
735 out.append("vary", "accept-encoding");

Nit: append adds a second accept-encoding to Vary when the app already varies on it. That's harmless, but compress() checks for it first. The same regex here would keep the header clean.

736 if (
737 encoding !== null &&
738 page.length >= MIN_BYTES &&
739 !out.has("content-encoding") &&

Small: rewritable() accepts content-encoding: identity. An app that sends that header explicitly passes the rewrite, but out.has("content-encoding") then skips compression and forwards the identity header as it came. Checking for a real coding instead ((out.get("content-encoding") ?? "identity").toLowerCase() === "identity", then deleting the header before setting the new one) would compress those pages too.

740 !/\bno-transform\b/i.test(out.get("cache-control") ?? "")
741 ) {
742 sent = compressBytes(page, encoding) as Uint8Array<ArrayBuffer>;
743 out.set("content-encoding", encoding);
744 }
745 out.set("content-length", String(sent.length));
732746 out.delete("etag");
733747 out.delete("content-md5");
734 return new Response(page, {
748 return new Response(sent, {
735749 status: response.status,
736750 statusText: response.statusText,
737751 headers: out,
src/apps/toolbar.ts +1 −1
@@ -227,7 +227,7 @@ export function withToolbar(
227227 bytes: Uint8Array,
228228 forgeUrl: string,
229229 where?: PillWhere,
230 ): Uint8Array {
230 ): Uint8Array<ArrayBuffer> {
231231 const tag = Buffer.from(toolbarTag(forgeUrl, where));
232232 const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
233233 // latin1 is one character per byte, so an index in it is a byte offset.
src/http/compress.ts +3
@@ -58,6 +58,9 @@ function encode(bytes: Uint8Array<ArrayBuffer>, encoding: Encoding, best: boolea
5858 });
5959 }
6060
61 /** Speed-first encoding of a body built per response. */
62 export const compressBytes = (bytes: Uint8Array<ArrayBuffer>, encoding: Encoding) => encode(bytes, encoding, false);
63
6164 const kept = new Map<string, Uint8Array>();
6265 const KEPT_MAX = 64;
6366
test/apps-toolbar.test.ts +82 −2
@@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
99 import { mkdtemp, rm } from "node:fs/promises";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
12 import { gzipSync } from "node:zlib";
12 import { brotliDecompressSync, gzipSync } from "node:zlib";
1313 import { createApp, type App } from "../src/app.ts";
1414 import { newApp } from "../src/actions/apps.ts";
1515 import { commitToBranch } from "../src/actions/repos.ts";
@@ -27,6 +27,7 @@ quietLogs();
2727 const DOMAIN = "apps.test";
2828 const PNG = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 1, 2, 3]);
2929 const HTML = "<html><body><h1>hi</h1></body></html>";
30 const BIG = `<html><body>${"<p>hello hello hello</p>".repeat(200)}</body></html>`;
3031
3132 describe("the forge pill", () => {
3233 let dataDir: string;
@@ -34,6 +35,7 @@ describe("the forge pill", () => {
3435 let servers: Servers;
3536 let runtime: AppsRuntime;
3637 let backend: ReturnType<typeof Bun.serve>;
38 let seen: string | null = null;
3739 let toolbar: boolean | undefined = undefined;
3840 let status: ReturnType<typeof spyOn>;
3941
@@ -56,10 +58,12 @@ describe("the forge pill", () => {
5658 if (path === "/json") return Response.json({ a: 1 });
5759 if (path === "/img") return new Response(PNG, { headers: { "content-type": "image/png" } });
5860 if (path === "/bare") return new Response("<p>no body tag</p>", { headers: html });
59 if (path === "/gz")
61 if (path === "/gz") {
62 seen = request.headers.get("accept-encoding");
6063 return new Response(gzipSync(HTML), {
6164 headers: { ...html, "content-encoding": "gzip" },
6265 });
66 }
6367 if (path === "/stream")
6468 return new Response(
6569 new ReadableStream({
@@ -83,6 +87,10 @@ describe("the forge pill", () => {
8387 return new Response(HTML, {
8488 headers: { "content-type": "text/html; charset=shift_jis" },
8589 });
90 if (path === "/long") {
91 seen = request.headers.get("accept-encoding");
92 return new Response(BIG, { headers: html });
93 }
8694 return new Response(HTML, { headers: html });
8795 },
8896 });
@@ -183,6 +191,78 @@ describe("the forge pill", () => {
183191 expect(await (await at("/sjis")).text()).toBe(HTML);
184192 });
185193
194 test("a rewritten page is gzipped by the proxy for a client that accepts gzip", async () => {
195 const res = await fetch(`${servers.url}/long`, {
196 headers: { host: `notes.${DOMAIN}`, accept: "text/html", "accept-encoding": "gzip" },
197 redirect: "manual",
198 decompress: false,
199 } as RequestInit);
200 expect(seen).toBe("identity");
201 expect(res.headers.get("content-encoding")).toBe("gzip");
202 expect(res.headers.get("vary")).toContain("accept-encoding");
203 const raw = new Uint8Array(await res.arrayBuffer());
204 expect(res.headers.get("content-length")).toBe(String(raw.length));
205 expect(new TextDecoder().decode(Bun.gunzipSync(raw))).toBe(
206 BIG.replace("</body>", `${tag()}</body>`),
207 );
208 const plain = await fetch(`${servers.url}/long`, {
209 headers: { host: `notes.${DOMAIN}`, accept: "text/html", "accept-encoding": "identity" },
210 redirect: "manual",
211 });
212 expect(plain.headers.get("content-encoding")).toBeNull();
213 expect(plain.headers.get("vary")).toContain("accept-encoding");
214 expect(await plain.text()).toContain(TOOLBAR_PATH);
215 });
216
217 test("a client that refuses gzip with q=0 gets the page plain", async () => {
218 const res = await fetch(`${servers.url}/long`, {
219 headers: { host: `notes.${DOMAIN}`, accept: "text/html", "accept-encoding": "gzip;q=0" },
220 redirect: "manual",
221 decompress: false,
222 } as RequestInit);
223 expect(res.headers.get("content-encoding")).toBeNull();
224 expect(res.headers.get("vary")).toContain("accept-encoding");
225 expect(await res.text()).toContain(TOOLBAR_PATH);
226 });
227
228 test("a client that accepts br is sent brotli", async () => {
229 const res = await fetch(`${servers.url}/long`, {
230 headers: { host: `notes.${DOMAIN}`, accept: "text/html", "accept-encoding": "gzip, br" },
231 redirect: "manual",
232 decompress: false,
233 } as RequestInit);
234 expect(res.headers.get("content-encoding")).toBe("br");
235 const raw = new Uint8Array(await res.arrayBuffer());
236 expect(res.headers.get("content-length")).toBe(String(raw.length));
237 expect(new TextDecoder().decode(brotliDecompressSync(raw))).toBe(
238 BIG.replace("</body>", `${tag()}</body>`),
239 );
240 });
241
242 test("a page under the threshold stays plain for a gzip client, and still varies", async () => {
243 const res = await fetch(`${servers.url}/`, {
244 headers: { host: `notes.${DOMAIN}`, accept: "text/html", "accept-encoding": "gzip" },
245 redirect: "manual",
246 decompress: false,
247 } as RequestInit);
248 expect(res.headers.get("content-encoding")).toBeNull();
249 expect(res.headers.get("vary")).toContain("accept-encoding");
250 expect(await res.text()).toContain(TOOLBAR_PATH);
251 });
252
253 test("an app with the pill off is sent the client's encoding and its gzip passes through", async () => {
254 toolbar = false;
255 const res = await fetch(`${servers.url}/gz`, {
256 headers: { host: `notes.${DOMAIN}`, accept: "text/html", "accept-encoding": "gzip" },
257 redirect: "manual",
258 decompress: false,
259 } as RequestInit);
260 toolbar = undefined;
261 expect(seen).toBe("gzip");
262 expect(res.headers.get("content-encoding")).toBe("gzip");
263 expect(new TextDecoder().decode(Bun.gunzipSync(new Uint8Array(await res.arrayBuffer())))).toBe(HTML);
264 });
265
186266 test("toolbar: false turns it off", async () => {
187267 toolbar = false;
188268 expect(await (await at("/")).text()).toBe(HTML);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs