@rsc-kit/core 0.30.19 → 0.30.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/compress.js CHANGED
@@ -17,6 +17,18 @@ import { deflate } from "./compressRuntime.js";
17
17
  const COMPRESSIBLE = /^(?:text\/|application\/(?:json|javascript|xml|manifest\+json|rss\+xml|atom\+xml)|image\/svg\+xml)/i;
18
18
  /** Answers too small to be worth a compressor's frame. */
19
19
  const MIN_BYTES = 1024;
20
+ /**
21
+ * How long a body that has begun is given to end, or to reach MIN_BYTES,
22
+ * before it is taken to be streaming.
23
+ *
24
+ * A complete answer built from a string ends in the same turn it starts, so
25
+ * this is only ever spent on one that is still being written. Waiting for it
26
+ * to finish was the bug: a client navigation that skips every layout sends a
27
+ * shell under a kilobyte and then the page's slow data, and the shell sat in
28
+ * the peek until that data was in. The loading state it carried reached
29
+ * nobody, and the old page stayed on screen the whole time.
30
+ */
31
+ const PEEK_MS = 5;
20
32
  /** Stored answers, compressed once: the same bytes go to every visitor who accepts gzip. */
21
33
  const stored = new Map();
22
34
  const STORED_CAP = 1000;
@@ -87,18 +99,31 @@ export async function compressed(request, response, storedKey) {
87
99
  // first kilobyte decides. An answer that ends inside it - /api/health, a
88
100
  // 15-byte "ok" - goes out as it was; anything longer is compressed from
89
101
  // its first byte, the peeked part included, and keeps streaming.
102
+ //
103
+ // The first chunk is waited for however long it takes, since it is the
104
+ // first byte. After it, only PEEK_MS: a body still being written after
105
+ // that is a stream, and a stream is compressed from its first chunk - its
106
+ // shell must not wait for the rest of it to decide a question of size.
90
107
  const reader = response.body.getReader();
91
108
  const peeked = [];
92
109
  let size = 0;
93
110
  let ended = false;
111
+ // A read that lost the race is still a read: the next chunk is its, and
112
+ // asking again would skip one.
113
+ let pending = null;
94
114
  while (size < MIN_BYTES) {
95
- const { done, value } = await reader.read();
96
- if (done) {
115
+ const read = pending ?? reader.read();
116
+ pending = read;
117
+ const result = size === 0 ? await read : await within(read, PEEK_MS);
118
+ if (result === null)
119
+ break;
120
+ pending = null;
121
+ if (result.done) {
97
122
  ended = true;
98
123
  break;
99
124
  }
100
- peeked.push(value);
101
- size += value.byteLength;
125
+ peeked.push(result.value);
126
+ size += result.value.byteLength;
102
127
  }
103
128
  if (ended) {
104
129
  return new Response(size === 0 ? null : concat(peeked, size), {
@@ -113,7 +138,9 @@ export async function compressed(request, response, storedKey) {
113
138
  controller.enqueue(chunk);
114
139
  },
115
140
  async pull(controller) {
116
- const { done, value } = await reader.read();
141
+ const read = pending ?? reader.read();
142
+ pending = null;
143
+ const { done, value } = await read;
117
144
  if (done)
118
145
  controller.close();
119
146
  else
@@ -125,6 +152,21 @@ export async function compressed(request, response, storedKey) {
125
152
  });
126
153
  return withEncoding(response, gzip.stream(rest));
127
154
  }
155
+ /** What the promise settles with, or null if it has not within `ms`. */
156
+ async function within(promise, ms) {
157
+ let timer;
158
+ try {
159
+ return await Promise.race([
160
+ promise,
161
+ new Promise((resolve) => {
162
+ timer = setTimeout(() => resolve(null), ms);
163
+ }),
164
+ ]);
165
+ }
166
+ finally {
167
+ clearTimeout(timer);
168
+ }
169
+ }
128
170
  function concat(chunks, size) {
129
171
  const out = new Uint8Array(size);
130
172
  let at = 0;
@@ -1 +1 @@
1
- {"version":3,"file":"compress.js","sourceRoot":"","sources":["../src/compress.ts"],"names":[],"mappings":"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","sourcesContent":["// Compression for what the host answers, where nothing in front of it will.\n//\n// Behind Cloudflare, Vercel or nginx the platform compresses and this never\n// runs. A bun or node server answering the internet by itself sent every\n// byte raw - a 90 kB document and 145 kB of stylesheet at their full size -\n// and that alone was the mobile Lighthouse regression a port measured:\n// first paint at 3.8 s where 1.2 s was the baseline. Public assets are\n// precompressed by the build and served by Nitro with their encoding; the\n// pages, payloads, stored answers and api routes come through here.\n//\n// gzip, flushed on every chunk. A streamed page is a shell followed by the\n// holes as they fill, and a compressor left to its own buffer would hold\n// the shell back until enough had accumulated - so each write is flushed\n// out, which costs a little ratio and keeps the stream a stream.\n\nimport { deflate } from \"./compressRuntime.js\";\n\n/** What is worth compressing: text of every kind, and the formats that are text. */\nconst COMPRESSIBLE = /^(?:text\\/|application\\/(?:json|javascript|xml|manifest\\+json|rss\\+xml|atom\\+xml)|image\\/svg\\+xml)/i;\n\n/** Answers too small to be worth a compressor's frame. */\nconst MIN_BYTES = 1024;\n\n/** Stored answers, compressed once: the same bytes go to every visitor who accepts gzip. */\nconst stored = new Map<string, Uint8Array>();\n\nconst STORED_CAP = 1000;\n\nfunction acceptsGzip(request: Request): boolean {\n const accept = request.headers.get(\"accept-encoding\") ?? \"\";\n\n return /(?:^|,)\\s*(?:gzip|\\*)\\s*(?:;\\s*q=(?!0(?:\\.0*)?\\s*(?:,|$)))?/i.test(accept);\n}\n\n/** Whether this answer is one to compress at all. */\nexport function shouldCompress(request: Request, response: Response): boolean {\n if (response.body === null || request.method === \"HEAD\") return false;\n if (response.status === 204 || response.status === 304 || response.status < 200) return false;\n if (response.headers.has(\"content-encoding\")) return false;\n if (/\\bno-transform\\b/i.test(response.headers.get(\"cache-control\") ?? \"\")) return false;\n const type = response.headers.get(\"content-type\") ?? \"\";\n\n if (!COMPRESSIBLE.test(type)) return false;\n // A stream of events is text, and must not be compressed: each message is\n // a few bytes that has to reach the browser the moment it is written, and\n // a compressor between them delivered nothing at all - not even headers -\n // to a browser that sent Accept-Encoding: gzip, which is every browser.\n if (/^text\\/event-stream\\b/i.test(type)) return false;\n\n const length = Number(response.headers.get(\"content-length\"));\n\n if (Number.isFinite(length) && length > 0 && length < MIN_BYTES) return false;\n\n return acceptsGzip(request);\n}\n\nfunction withEncoding(response: Response, body: BodyInit): Response {\n const headers = new Headers(response.headers);\n\n headers.set(\"Content-Encoding\", \"gzip\");\n headers.delete(\"Content-Length\");\n\n const vary = headers.get(\"Vary\");\n\n if (!vary) headers.set(\"Vary\", \"Accept-Encoding\");\n else if (!/\\baccept-encoding\\b/i.test(vary) && vary.trim() !== \"*\") headers.set(\"Vary\", vary + \", Accept-Encoding\");\n\n return new Response(body, { status: response.status, statusText: response.statusText, headers });\n}\n\n/**\n * The response, gzipped if the request accepts it and the answer is worth it.\n *\n * `storedKey` names an answer the build wrote - the same bytes for every\n * visitor - so it is compressed once and kept; everything else is streamed\n * through the compressor as it is produced.\n */\nexport async function compressed(\n request: Request,\n response: Response,\n storedKey?: string,\n): Promise<Response> {\n if (!shouldCompress(request, response)) return response;\n\n const gzip = await deflate();\n\n if (!gzip) return response;\n\n if (storedKey !== undefined) {\n let bytes = stored.get(storedKey);\n\n if (!bytes) {\n bytes = await gzip.whole(new Uint8Array(await response.arrayBuffer()));\n\n if (stored.size >= STORED_CAP) stored.clear();\n\n stored.set(storedKey, bytes);\n }\n\n return withEncoding(response, bytes as unknown as BodyInit);\n }\n\n // A Response made from a string or JSON carries no Content-Length for the\n // check above to read, so the body is peeked: what arrives before the\n // first kilobyte decides. An answer that ends inside it - /api/health, a\n // 15-byte \"ok\" - goes out as it was; anything longer is compressed from\n // its first byte, the peeked part included, and keeps streaming.\n const reader = response.body!.getReader();\n const peeked: Uint8Array[] = [];\n let size = 0;\n let ended = false;\n\n while (size < MIN_BYTES) {\n const { done, value } = await reader.read();\n\n if (done) {\n ended = true;\n break;\n }\n\n peeked.push(value);\n size += value.byteLength;\n }\n\n if (ended) {\n return new Response(size === 0 ? null : (concat(peeked, size) as unknown as BodyInit), {\n status: response.status,\n statusText: response.statusText,\n headers: response.headers,\n });\n }\n\n const rest = new ReadableStream<Uint8Array>({\n start(controller) {\n for (const chunk of peeked) controller.enqueue(chunk);\n },\n async pull(controller) {\n const { done, value } = await reader.read();\n\n if (done) controller.close();\n else controller.enqueue(value);\n },\n cancel(reason) {\n return reader.cancel(reason);\n },\n });\n\n return withEncoding(response, gzip.stream(rest));\n}\n\nfunction concat(chunks: Uint8Array[], size: number): Uint8Array {\n const out = new Uint8Array(size);\n let at = 0;\n\n for (const chunk of chunks) {\n out.set(chunk, at);\n at += chunk.byteLength;\n }\n\n return out;\n}\n\n/** For a test, or a redeploy that reuses the process: forget the stored bytes. */\nexport function forgetCompressed(): void {\n stored.clear();\n}\n"]}
1
+ {"version":3,"file":"compress.js","sourceRoot":"","sources":["../src/compress.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,4EAA4E;AAC5E,yEAAyE;AACzE,4EAA4E;AAC5E,uEAAuE;AACvE,uEAAuE;AACvE,0EAA0E;AAC1E,oEAAoE;AACpE,EAAE;AACF,2EAA2E;AAC3E,yEAAyE;AACzE,yEAAyE;AACzE,iEAAiE;AAEjE,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAE/C,oFAAoF;AACpF,MAAM,YAAY,GAAG,qGAAqG,CAAC;AAE3H,0DAA0D;AAC1D,MAAM,SAAS,GAAG,IAAI,CAAC;AAEvB;;;;;;;;;;GAUG;AACH,MAAM,OAAO,GAAG,CAAC,CAAC;AAElB,4FAA4F;AAC5F,MAAM,MAAM,GAAG,IAAI,GAAG,EAAsB,CAAC;AAE7C,MAAM,UAAU,GAAG,IAAI,CAAC;AAExB,SAAS,WAAW,CAAC,OAAgB;IACnC,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,IAAI,EAAE,CAAC;IAE5D,OAAO,8DAA8D,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AACrF,CAAC;AAED,qDAAqD;AACrD,MAAM,UAAU,cAAc,CAAC,OAAgB,EAAE,QAAkB;IACjE,IAAI,QAAQ,CAAC,IAAI,KAAK,IAAI,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,KAAK,CAAC;IACtE,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,IAAI,QAAQ,CAAC,MAAM,GAAG,GAAG;QAAE,OAAO,KAAK,CAAC;IAC9F,IAAI,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC;QAAE,OAAO,KAAK,CAAC;IAC3D,IAAI,mBAAmB,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC;QAAE,OAAO,KAAK,CAAC;IACxF,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC;IAExD,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IAC3C,0EAA0E;IAC1E,0EAA0E;IAC1E,0EAA0E;IAC1E,wEAAwE;IACxE,IAAI,wBAAwB,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IAEtD,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC;IAE9D,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,CAAC,IAAI,MAAM,GAAG,SAAS;QAAE,OAAO,KAAK,CAAC;IAE9E,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;AAC9B,CAAC;AAED,SAAS,YAAY,CAAC,QAAkB,EAAE,IAAc;IACtD,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IAE9C,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;IACxC,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;IAEjC,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAEjC,IAAI,CAAC,IAAI;QAAE,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;SAC7C,IAAI,CAAC,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,EAAE,KAAK,GAAG;QAAE,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,GAAG,mBAAmB,CAAC,CAAC;IAEpH,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,QAAQ,CAAC,UAAU,EAAE,OAAO,EAAE,CAAC,CAAC;AACnG,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,OAAgB,EAChB,QAAkB,EAClB,SAAkB;IAElB,IAAI,CAAC,cAAc,CAAC,OAAO,EAAE,QAAQ,CAAC;QAAE,OAAO,QAAQ,CAAC;IAExD,MAAM,IAAI,GAAG,MAAM,OAAO,EAAE,CAAC;IAE7B,IAAI,CAAC,IAAI;QAAE,OAAO,QAAQ,CAAC;IAE3B,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC5B,IAAI,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAElC,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,UAAU,CAAC,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;YAEvE,IAAI,MAAM,CAAC,IAAI,IAAI,UAAU;gBAAE,MAAM,CAAC,KAAK,EAAE,CAAC;YAE9C,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QAC/B,CAAC;QAED,OAAO,YAAY,CAAC,QAAQ,EAAE,KAA4B,CAAC,CAAC;IAC9D,CAAC;IAED,0EAA0E;IAC1E,sEAAsE;IACtE,yEAAyE;IACzE,wEAAwE;IACxE,iEAAiE;IACjE,EAAE;IACF,uEAAuE;IACvE,uEAAuE;IACvE,0EAA0E;IAC1E,uEAAuE;IACvE,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAK,CAAC,SAAS,EAAE,CAAC;IAC1C,MAAM,MAAM,GAAiB,EAAE,CAAC;IAChC,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,IAAI,KAAK,GAAG,KAAK,CAAC;IAClB,wEAAwE;IACxE,+BAA+B;IAC/B,IAAI,OAAO,GAA0C,IAAI,CAAC;IAE1D,OAAO,IAAI,GAAG,SAAS,EAAE,CAAC;QACxB,MAAM,IAAI,GAAG,OAAO,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;QAEtC,OAAO,GAAG,IAAI,CAAC;QAEf,MAAM,MAAM,GAAG,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,MAAM,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAErE,IAAI,MAAM,KAAK,IAAI;YAAE,MAAM;QAE3B,OAAO,GAAG,IAAI,CAAC;QAEf,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;YAChB,KAAK,GAAG,IAAI,CAAC;YACb,MAAM;QACR,CAAC;QAED,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC1B,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC;IAClC,CAAC;IAED,IAAI,KAAK,EAAE,CAAC;QACV,OAAO,IAAI,QAAQ,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAyB,EAAE;YACrF,MAAM,EAAE,QAAQ,CAAC,MAAM;YACvB,UAAU,EAAE,QAAQ,CAAC,UAAU;YAC/B,OAAO,EAAE,QAAQ,CAAC,OAAO;SAC1B,CAAC,CAAC;IACL,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,cAAc,CAAa;QAC1C,KAAK,CAAC,UAAU;YACd,KAAK,MAAM,KAAK,IAAI,MAAM;gBAAE,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QACxD,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,UAAU;YACnB,MAAM,IAAI,GAAG,OAAO,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;YAEtC,OAAO,GAAG,IAAI,CAAC;YAEf,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC;YAEnC,IAAI,IAAI;gBAAE,UAAU,CAAC,KAAK,EAAE,CAAC;;gBACxB,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QACjC,CAAC;QACD,MAAM,CAAC,MAAM;YACX,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAC/B,CAAC;KACF,CAAC,CAAC;IAEH,OAAO,YAAY,CAAC,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;AACnD,CAAC;AAED,wEAAwE;AACxE,KAAK,UAAU,MAAM,CAAI,OAAmB,EAAE,EAAU;IACtD,IAAI,KAAgD,CAAC;IAErD,IAAI,CAAC;QACH,OAAO,MAAM,OAAO,CAAC,IAAI,CAAC;YACxB,OAAO;YACP,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;gBAC5B,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;YAC9C,CAAC,CAAC;SACH,CAAC,CAAC;IACL,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,KAAK,CAAC,CAAC;IACtB,CAAC;AACH,CAAC;AAED,SAAS,MAAM,CAAC,MAAoB,EAAE,IAAY;IAChD,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,IAAI,CAAC,CAAC;IACjC,IAAI,EAAE,GAAG,CAAC,CAAC;IAEX,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,GAAG,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QACnB,EAAE,IAAI,KAAK,CAAC,UAAU,CAAC;IACzB,CAAC;IAED,OAAO,GAAG,CAAC;AACb,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,gBAAgB;IAC9B,MAAM,CAAC,KAAK,EAAE,CAAC;AACjB,CAAC","sourcesContent":["// Compression for what the host answers, where nothing in front of it will.\n//\n// Behind Cloudflare, Vercel or nginx the platform compresses and this never\n// runs. A bun or node server answering the internet by itself sent every\n// byte raw - a 90 kB document and 145 kB of stylesheet at their full size -\n// and that alone was the mobile Lighthouse regression a port measured:\n// first paint at 3.8 s where 1.2 s was the baseline. Public assets are\n// precompressed by the build and served by Nitro with their encoding; the\n// pages, payloads, stored answers and api routes come through here.\n//\n// gzip, flushed on every chunk. A streamed page is a shell followed by the\n// holes as they fill, and a compressor left to its own buffer would hold\n// the shell back until enough had accumulated - so each write is flushed\n// out, which costs a little ratio and keeps the stream a stream.\n\nimport { deflate } from \"./compressRuntime.js\";\n\n/** What is worth compressing: text of every kind, and the formats that are text. */\nconst COMPRESSIBLE = /^(?:text\\/|application\\/(?:json|javascript|xml|manifest\\+json|rss\\+xml|atom\\+xml)|image\\/svg\\+xml)/i;\n\n/** Answers too small to be worth a compressor's frame. */\nconst MIN_BYTES = 1024;\n\n/**\n * How long a body that has begun is given to end, or to reach MIN_BYTES,\n * before it is taken to be streaming.\n *\n * A complete answer built from a string ends in the same turn it starts, so\n * this is only ever spent on one that is still being written. Waiting for it\n * to finish was the bug: a client navigation that skips every layout sends a\n * shell under a kilobyte and then the page's slow data, and the shell sat in\n * the peek until that data was in. The loading state it carried reached\n * nobody, and the old page stayed on screen the whole time.\n */\nconst PEEK_MS = 5;\n\n/** Stored answers, compressed once: the same bytes go to every visitor who accepts gzip. */\nconst stored = new Map<string, Uint8Array>();\n\nconst STORED_CAP = 1000;\n\nfunction acceptsGzip(request: Request): boolean {\n const accept = request.headers.get(\"accept-encoding\") ?? \"\";\n\n return /(?:^|,)\\s*(?:gzip|\\*)\\s*(?:;\\s*q=(?!0(?:\\.0*)?\\s*(?:,|$)))?/i.test(accept);\n}\n\n/** Whether this answer is one to compress at all. */\nexport function shouldCompress(request: Request, response: Response): boolean {\n if (response.body === null || request.method === \"HEAD\") return false;\n if (response.status === 204 || response.status === 304 || response.status < 200) return false;\n if (response.headers.has(\"content-encoding\")) return false;\n if (/\\bno-transform\\b/i.test(response.headers.get(\"cache-control\") ?? \"\")) return false;\n const type = response.headers.get(\"content-type\") ?? \"\";\n\n if (!COMPRESSIBLE.test(type)) return false;\n // A stream of events is text, and must not be compressed: each message is\n // a few bytes that has to reach the browser the moment it is written, and\n // a compressor between them delivered nothing at all - not even headers -\n // to a browser that sent Accept-Encoding: gzip, which is every browser.\n if (/^text\\/event-stream\\b/i.test(type)) return false;\n\n const length = Number(response.headers.get(\"content-length\"));\n\n if (Number.isFinite(length) && length > 0 && length < MIN_BYTES) return false;\n\n return acceptsGzip(request);\n}\n\nfunction withEncoding(response: Response, body: BodyInit): Response {\n const headers = new Headers(response.headers);\n\n headers.set(\"Content-Encoding\", \"gzip\");\n headers.delete(\"Content-Length\");\n\n const vary = headers.get(\"Vary\");\n\n if (!vary) headers.set(\"Vary\", \"Accept-Encoding\");\n else if (!/\\baccept-encoding\\b/i.test(vary) && vary.trim() !== \"*\") headers.set(\"Vary\", vary + \", Accept-Encoding\");\n\n return new Response(body, { status: response.status, statusText: response.statusText, headers });\n}\n\n/**\n * The response, gzipped if the request accepts it and the answer is worth it.\n *\n * `storedKey` names an answer the build wrote - the same bytes for every\n * visitor - so it is compressed once and kept; everything else is streamed\n * through the compressor as it is produced.\n */\nexport async function compressed(\n request: Request,\n response: Response,\n storedKey?: string,\n): Promise<Response> {\n if (!shouldCompress(request, response)) return response;\n\n const gzip = await deflate();\n\n if (!gzip) return response;\n\n if (storedKey !== undefined) {\n let bytes = stored.get(storedKey);\n\n if (!bytes) {\n bytes = await gzip.whole(new Uint8Array(await response.arrayBuffer()));\n\n if (stored.size >= STORED_CAP) stored.clear();\n\n stored.set(storedKey, bytes);\n }\n\n return withEncoding(response, bytes as unknown as BodyInit);\n }\n\n // A Response made from a string or JSON carries no Content-Length for the\n // check above to read, so the body is peeked: what arrives before the\n // first kilobyte decides. An answer that ends inside it - /api/health, a\n // 15-byte \"ok\" - goes out as it was; anything longer is compressed from\n // its first byte, the peeked part included, and keeps streaming.\n //\n // The first chunk is waited for however long it takes, since it is the\n // first byte. After it, only PEEK_MS: a body still being written after\n // that is a stream, and a stream is compressed from its first chunk - its\n // shell must not wait for the rest of it to decide a question of size.\n const reader = response.body!.getReader();\n const peeked: Uint8Array[] = [];\n let size = 0;\n let ended = false;\n // A read that lost the race is still a read: the next chunk is its, and\n // asking again would skip one.\n let pending: ReturnType<typeof reader.read> | null = null;\n\n while (size < MIN_BYTES) {\n const read = pending ?? reader.read();\n\n pending = read;\n\n const result = size === 0 ? await read : await within(read, PEEK_MS);\n\n if (result === null) break;\n\n pending = null;\n\n if (result.done) {\n ended = true;\n break;\n }\n\n peeked.push(result.value);\n size += result.value.byteLength;\n }\n\n if (ended) {\n return new Response(size === 0 ? null : (concat(peeked, size) as unknown as BodyInit), {\n status: response.status,\n statusText: response.statusText,\n headers: response.headers,\n });\n }\n\n const rest = new ReadableStream<Uint8Array>({\n start(controller) {\n for (const chunk of peeked) controller.enqueue(chunk);\n },\n async pull(controller) {\n const read = pending ?? reader.read();\n\n pending = null;\n\n const { done, value } = await read;\n\n if (done) controller.close();\n else controller.enqueue(value);\n },\n cancel(reason) {\n return reader.cancel(reason);\n },\n });\n\n return withEncoding(response, gzip.stream(rest));\n}\n\n/** What the promise settles with, or null if it has not within `ms`. */\nasync function within<T>(promise: Promise<T>, ms: number): Promise<T | null> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n\n try {\n return await Promise.race([\n promise,\n new Promise<null>((resolve) => {\n timer = setTimeout(() => resolve(null), ms);\n }),\n ]);\n } finally {\n clearTimeout(timer);\n }\n}\n\nfunction concat(chunks: Uint8Array[], size: number): Uint8Array {\n const out = new Uint8Array(size);\n let at = 0;\n\n for (const chunk of chunks) {\n out.set(chunk, at);\n at += chunk.byteLength;\n }\n\n return out;\n}\n\n/** For a test, or a redeploy that reuses the process: forget the stored bytes. */\nexport function forgetCompressed(): void {\n stored.clear();\n}\n"]}
@@ -500,7 +500,10 @@ notFound = false) {
500
500
  * hoists document metadata into <head>. Nothing needs injecting from headers.
501
501
  */
502
502
  function deserializeResponse(response) {
503
- return flightDeserializer(response.body, {
503
+ return deserializeStream(response.body);
504
+ }
505
+ function deserializeStream(stream) {
506
+ return flightDeserializer(stream, {
504
507
  callServer: callServerFn ??
505
508
  (async () => {
506
509
  throw new Error("Server actions not initialized");
@@ -713,7 +716,6 @@ export async function navigate(url, opts) {
713
716
  // open over the page behind it.
714
717
  const usable = isUsable(cached, chain);
715
718
  if (usable) {
716
- treePromise = cached.tree;
717
719
  // Read after the tree resolves, not now. A prefetch fills these in when
718
720
  // its response lands, and a click can land first — hover a modal link
719
721
  // and click it quickly and the entry still says depth 0, so the
@@ -722,6 +724,26 @@ export async function navigate(url, opts) {
722
724
  // waiting for it is what makes them true.
723
725
  reused = cached;
724
726
  cache.delete(cacheKey);
727
+ if (reused.settled) {
728
+ treePromise = reused.tree;
729
+ }
730
+ else {
731
+ // Still arriving, so read it as it comes - see follow(). The request
732
+ // is this navigation's from here: leaving the link, or an action
733
+ // dropping the other prefetches, must not cut short a page the
734
+ // visitor is waiting on, and being overtaken by another navigation
735
+ // must stop it as it would a request of its own.
736
+ const arriving = reused;
737
+ prefetchControllers.delete(cacheKey);
738
+ controller.signal.addEventListener("abort", () => arriving.cancel(), { once: true });
739
+ // A request that dies is asked again, as a prefetch that died before
740
+ // the click is; the entry says which it was once it has settled.
741
+ treePromise = arriving.follow().catch((error) => arriving.body.then(() => {
742
+ if (arriving.failed)
743
+ return null;
744
+ throw error;
745
+ }));
746
+ }
725
747
  }
726
748
  else {
727
749
  cache.delete(cacheKey);
@@ -1172,6 +1194,11 @@ function warm(entry, cacheKey, followed = new Set()) {
1172
1194
  return;
1173
1195
  // The page about to show: its pictures ahead of every other page's.
1174
1196
  preloadImages(text, "high");
1197
+ // Taken by a click before it had all arrived, which read it as it came:
1198
+ // the page is on its way to the screen, and decoding it again here would
1199
+ // be the same chunks asked for twice.
1200
+ if (cache.get(cacheKey) !== entry)
1201
+ return;
1175
1202
  return entry.tree.then((tree) => {
1176
1203
  if (entry.slot !== null ||
1177
1204
  entry.segmentDepth === 0 ||
@@ -1200,11 +1227,24 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
1200
1227
  const controller = new AbortController();
1201
1228
  prefetchControllers.set(cacheKey, controller);
1202
1229
  let decoded = null;
1230
+ // Settled once the response has said whether there is a page to follow: its
1231
+ // bytes as they arrive, or nothing for a redirect or a failure.
1232
+ let arriving;
1233
+ let followed = false;
1234
+ const followable = new Promise((resolve) => {
1235
+ arriving = resolve;
1236
+ });
1203
1237
  const entry = {
1204
1238
  body: Promise.resolve(null),
1205
1239
  get tree() {
1206
1240
  return (decoded ??= this.body.then((text) => (text === null ? null : deserializeResponse(new Response(text)))));
1207
1241
  },
1242
+ settled: false,
1243
+ follow() {
1244
+ followed = true;
1245
+ return followable.then((stream) => (stream ? deserializeStream(stream) : null));
1246
+ },
1247
+ cancel: () => controller.abort(),
1208
1248
  expiresAt: Date.now() + ttl,
1209
1249
  segmentDepth: 0,
1210
1250
  layouts: null,
@@ -1237,6 +1277,7 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
1237
1277
  entry.redirectTo = redirectTo;
1238
1278
  if (!isExternalUrl(redirectTo) && isSafeRedirect(redirectTo))
1239
1279
  prefetch(redirectTo, ttl);
1280
+ arriving(null);
1240
1281
  return null;
1241
1282
  }
1242
1283
  entry.slot = response.headers.get("X-RSC-Revalidate");
@@ -1261,7 +1302,16 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
1261
1302
  // - unless the visitor is already on the way, see prefetch(). The
1262
1303
  // pictures the page shows are asked for now, though: on a phone they
1263
1304
  // take longer than the touch-to-click a hidden render has.
1305
+ //
1306
+ // Read twice: whole, for the cache, and from the first byte by a click
1307
+ // that lands before the last of them - see follow(). The second read is
1308
+ // let go as soon as the first is complete and nobody took it.
1309
+ const copy = response.clone().body;
1310
+ arriving(copy);
1264
1311
  return response.text().then((text) => {
1312
+ entry.settled = true;
1313
+ if (!followed)
1314
+ copy?.cancel();
1265
1315
  preloadImages(text);
1266
1316
  // And the code for its client components, so the first visit to a
1267
1317
  // page does not wait on a chunk after it has arrived.
@@ -1272,6 +1322,7 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
1272
1322
  .catch(() => {
1273
1323
  entry.failed = true;
1274
1324
  cache.delete(cacheKey);
1325
+ arriving(null);
1275
1326
  return null;
1276
1327
  })
1277
1328
  .finally(() => {