@rivetjs/router 0.1.0-alpha.0 → 0.1.0-alpha.1

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.
Files changed (49) hide show
  1. package/adapters/files.d.ts +13 -0
  2. package/adapters/files.js +58 -6
  3. package/adapters/node.d.ts +22 -5
  4. package/adapters/node.js +113 -67
  5. package/adapters/static.js +1 -1
  6. package/adapters/target-build.d.ts +5 -2
  7. package/adapters/vercel.d.ts +4 -2
  8. package/adapters/vercel.js +1 -1
  9. package/build/codegen.d.ts +13 -0
  10. package/build/codegen.js +152 -35
  11. package/build/dev-errors.d.ts +6 -2
  12. package/build/dev-errors.js +15 -4
  13. package/build/lazy-rows.d.ts +19 -0
  14. package/build/lazy-rows.js +59 -0
  15. package/build/migrate.d.ts +20 -4
  16. package/build/migrate.js +300 -61
  17. package/build/reach.d.ts +60 -0
  18. package/build/reach.js +302 -0
  19. package/build/routes.d.ts +9 -4
  20. package/build/routes.js +30 -13
  21. package/build/vite.d.ts +13 -0
  22. package/build/vite.js +165 -7
  23. package/cli/rivet.js +3 -2
  24. package/client/dev.js +17 -0
  25. package/client/index.d.ts +4 -0
  26. package/client/index.js +34 -0
  27. package/client/route-context.d.ts +1 -1
  28. package/index.d.ts +22 -25
  29. package/index.js +2 -1
  30. package/package.json +4 -4
  31. package/server/app.d.ts +7 -1
  32. package/server/app.js +38 -5
  33. package/server/deferred.d.ts +12 -0
  34. package/server/deferred.js +60 -0
  35. package/server/handler.d.ts +56 -12
  36. package/server/handler.js +46 -8
  37. package/server/index.d.ts +1 -1
  38. package/server/module.d.ts +7 -7
  39. package/server/module.js +52 -34
  40. package/server/render.d.ts +2 -0
  41. package/server/render.js +3 -0
  42. package/server/route-cache.d.ts +74 -4
  43. package/server/route-cache.js +77 -6
  44. package/server/shell-build.d.ts +3 -0
  45. package/server/shell-build.js +2 -0
  46. package/server/shell-cache.d.ts +8 -0
  47. package/server/shell-cache.js +836 -0
  48. package/shared/server-exports.d.ts +78 -0
  49. package/shared/server-exports.js +77 -0
@@ -3,5 +3,18 @@ export interface StaticFile {
3
3
  readonly path: string;
4
4
  readonly headers: Record<string, string>;
5
5
  }
6
+ /** A response encoding the adapters produce. */
7
+ export type Encoding = "zstd" | "br" | "gzip" | null;
8
+ /**
9
+ * `q` per content coding of an `Accept-Encoding` header, parsed once per distinct string (browsers send a handful;
10
+ * bounded: client-controlled text). `weightOf` applies `*` to codings the header does not name.
11
+ */
12
+ export declare function acceptWeights(header: string): Map<string, number>;
13
+ export declare const weightOf: (accepted: Map<string, number>, name: string) => number;
14
+ /**
15
+ * Pick `zstd`, `br` or `gzip` from an Accept-Encoding header: the highest `q` (a tie: `zstd`, then `br`); q=0 excludes.
16
+ * `zstd: false` never picks zstd (`serve({ compress: { zstd: false } })`).
17
+ */
18
+ export declare function negotiate(header: string | null | undefined, zstd?: boolean): Encoding;
6
19
  /** Index `client` (served under `base`); returns the per-request lookup. */
7
20
  export declare function staticFiles(client: string, base: string, immutable: readonly string[]): (request: Request, pathname: string) => StaticFile | 304 | null;
package/adapters/files.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // runtimes implement `node:fs` / `node:path`), looked up per GET/HEAD by URL path. Same rules as the
3
3
  // node adapter: below the base only, no `\0` or dot segments (except a root `.well-known/`), hashed
4
4
  // `<base>/assets/` immutable, everything else `no-cache` + weak ETag (304 on `If-None-Match`),
5
- // precompressed `.br` / `.gz` siblings when the client accepts them. Directory pages
5
+ // precompressed `.zst` / `.br` / `.gz` siblings when the client accepts them (zstd first). Directory pages
6
6
  // (`a/index.html`, `a.html`, as the static adapter writes them) answer at `/a` and `/a/`.
7
7
  import { readdirSync, realpathSync, statSync } from "node:fs";
8
8
  import { extname, join, relative, sep } from "node:path";
@@ -28,6 +28,45 @@ const MIME = {
28
28
  ".woff2": "font/woff2",
29
29
  ".woff": "font/woff",
30
30
  };
31
+ /**
32
+ * `q` per content coding of an `Accept-Encoding` header, parsed once per distinct string (browsers send a handful;
33
+ * bounded: client-controlled text). `weightOf` applies `*` to codings the header does not name.
34
+ */
35
+ export function acceptWeights(header) {
36
+ let accepted = weights.get(header);
37
+ if (accepted === undefined) {
38
+ accepted = new Map();
39
+ for (const part of header.toLowerCase().split(",")) {
40
+ const [name, ...params] = part.trim().split(";");
41
+ const q = params.map((p) => /^\s*q=([\d.]+)\s*$/.exec(p)).find(Boolean);
42
+ accepted.set(name.trim(), q ? Number(q[1]) : 1);
43
+ }
44
+ if (weights.size < 64)
45
+ weights.set(header, accepted);
46
+ }
47
+ return accepted;
48
+ }
49
+ const weights = new Map();
50
+ export const weightOf = (accepted, name) => accepted.get(name) ?? accepted.get("*") ?? 0;
51
+ /**
52
+ * Pick `zstd`, `br` or `gzip` from an Accept-Encoding header: the highest `q` (a tie: `zstd`, then `br`); q=0 excludes.
53
+ * `zstd: false` never picks zstd (`serve({ compress: { zstd: false } })`).
54
+ */
55
+ export function negotiate(header, zstd = true) {
56
+ if (!header)
57
+ return null;
58
+ const memo = zstd ? negotiated : negotiatedNoZstd;
59
+ let hit = memo.get(header);
60
+ if (hit === undefined) {
61
+ const accepted = acceptWeights(header);
62
+ const zs = zstd ? weightOf(accepted, "zstd") : 0, br = weightOf(accepted, "br"), gzip = weightOf(accepted, "gzip");
63
+ hit = zs > 0 && zs >= br && zs >= gzip ? "zstd" : br > 0 && br >= gzip ? "br" : gzip > 0 ? "gzip" : null;
64
+ if (memo.size < 64)
65
+ memo.set(header, hit);
66
+ }
67
+ return hit;
68
+ }
69
+ const negotiated = new Map(), negotiatedNoZstd = new Map();
31
70
  /** Index `client` (served under `base`); returns the per-request lookup. */
32
71
  export function staticFiles(client, base, immutable) {
33
72
  const files = new Map();
@@ -47,6 +86,8 @@ export function staticFiles(client, base, immutable) {
47
86
  continue;
48
87
  if (/\.(br|gz)$/.test(key) && names.has(full.slice(0, -3)))
49
88
  continue;
89
+ if (key.endsWith(".zst") && names.has(full.slice(0, -4)))
90
+ continue;
50
91
  const info = statSync(full);
51
92
  const url = base + "/" + key;
52
93
  const file = {
@@ -55,6 +96,7 @@ export function staticFiles(client, base, immutable) {
55
96
  etag: `W/"${info.size.toString(16)}-${Math.floor(info.mtimeMs).toString(16)}"`,
56
97
  modified: info.mtime.toUTCString(),
57
98
  immutable: immutable.some((prefix) => url.startsWith(prefix)),
99
+ ...(names.has(full + ".zst") ? { zst: full + ".zst" } : {}),
58
100
  ...(names.has(full + ".br") ? { br: full + ".br" } : {}),
59
101
  ...(names.has(full + ".gz") ? { gz: full + ".gz" } : {}),
60
102
  };
@@ -89,13 +131,23 @@ export function staticFiles(client, base, immutable) {
89
131
  ETag: file.etag,
90
132
  "Last-Modified": file.modified,
91
133
  };
92
- if (file.br || file.gz) {
134
+ if (file.zst || file.br || file.gz) {
93
135
  headers.Vary = "Accept-Encoding";
94
- const accept = request.headers.get("accept-encoding") ?? "";
95
- const encoding = file.br && /\bbr\b/.test(accept) ? "br" : file.gz && /\bgzip\b/.test(accept) ? "gzip" : null;
96
- if (encoding) {
136
+ // The best-weighted coding among the siblings that exist (`q=0` excludes; a tie: zstd, then br).
137
+ const accepted = acceptWeights(request.headers.get("accept-encoding") ?? "");
138
+ let path, encoding = "", best = 0;
139
+ for (const [name, sibling] of [
140
+ ["zstd", file.zst],
141
+ ["br", file.br],
142
+ ["gzip", file.gz],
143
+ ]) {
144
+ const q = sibling ? weightOf(accepted, name) : 0;
145
+ if (q > best)
146
+ ((best = q), (path = sibling), (encoding = name));
147
+ }
148
+ if (path) {
97
149
  headers["Content-Encoding"] = encoding;
98
- return { path: encoding === "br" ? file.br : file.gz, headers };
150
+ return { path, headers };
99
151
  }
100
152
  }
101
153
  return { path: file.path, headers };
@@ -2,7 +2,10 @@ import { type IncomingMessage, type Server, type ServerResponse } from "node:htt
2
2
  import type { BodyLimit } from "../server/security.js";
3
3
  import type { ErrorInfo } from "../server/handler.js";
4
4
  import type { AppConfig } from "../server/app.js";
5
- /** Per-request context passed to the handler. */
5
+ /**
6
+ * Per-request context passed to the handler. It never carries `locals`: the adapter fills nothing from request
7
+ * headers, query or body. A handler that renders with its own data forwards `{ ...context, locals }` to the app.
8
+ */
6
9
  export interface RequestContext {
7
10
  /** Fresh CSP nonce (base64) for inline scripts the handler emits. */
8
11
  readonly nonce: string;
@@ -11,6 +14,13 @@ export interface RequestContext {
11
14
  }
12
15
  /** A request handler (web `Request` → `Response`) served by the Node adapter. */
13
16
  export type Handler = (request: Request, context: RequestContext) => Response | Promise<Response>;
17
+ /** `serve({ compress })` as an object: which codecs and whether immutable files' compressed copies are written to disk. */
18
+ export interface CompressOptions {
19
+ /** Offer `zstd` (default true). `false`: brotli / gzip only. */
20
+ zstd?: boolean;
21
+ /** Write immutable files' compressed copies next to them on their first request (default true). */
22
+ precompress?: boolean;
23
+ }
14
24
  /** Options of `serve()`. */
15
25
  export interface ServeOptions {
16
26
  handler: Handler;
@@ -28,8 +38,16 @@ export interface ServeOptions {
28
38
  * later are not served. `"fs"`: look each GET up on disk (a directory that changes while serving).
29
39
  */
30
40
  static?: "index" | "fs";
31
- /** Compress responses by `Accept-Encoding` (default true). */
32
- compress?: boolean;
41
+ /**
42
+ * Compress responses by `Accept-Encoding` (default true): `zstd` when the client accepts it (level 1 for bodies of
43
+ * 256 KiB and more, 3 below; streamed bodies by what is collected when streaming starts), else brotli (q4), else gzip
44
+ * (6). Immutable `client` files are precompressed once per file at the best quality (brotli q11, zstd 19, gzip 9) on
45
+ * their first request and written next to the file (`.br` / `.zst` / `.gz`, hidden temp + rename; a read-only
46
+ * directory keeps them in memory only), so the next process start serves them from disk; build-time siblings are
47
+ * used as they are. `{ zstd: false }`: brotli / gzip only (no zstd work at all); `{ precompress: false }`: nothing
48
+ * written to disk. `false`: no compression (a proxy that compresses).
49
+ */
50
+ compress?: boolean | CompressOptions;
33
51
  /**
34
52
  * Send `103 Early Hints` when the app asks (`createApp({ earlyHints })`; `start({ earlyHints })` sets both), to
35
53
  * HTTP/1.1 clients only (1xx are not allowed for 1.0). Browsers use 103s over HTTP/2+ only: behind a proxy or CDN
@@ -97,8 +115,7 @@ export interface PrimaryServing {
97
115
  /** Drain every worker (each within its `grace`), then resolve once all exited. */
98
116
  close(): Promise<void>;
99
117
  }
100
- /** Pick `br` or `gzip` from an Accept-Encoding header: the higher `q` (a tie: `br`); q=0 excludes. */
101
- export declare function negotiate(header: string | null | undefined): "br" | "gzip" | null;
118
+ export { negotiate, type Encoding } from "./files.js";
102
119
  /** `If-None-Match` (a list, `*`, weak or strong tags) matches `etag` (weak comparison, as for GET). */
103
120
  export declare function etagMatches(header: string | undefined, etag: string): boolean;
104
121
  /**
package/adapters/node.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // Node HTTP adapter: serves built client assets and streams a web-standard handler's Responses.
2
2
  import { createServer } from "node:http";
3
3
  import { createReadStream, readdirSync, realpathSync, statSync } from "node:fs";
4
- import { readFile, realpath, stat } from "node:fs/promises";
5
- import { extname, join, resolve, sep } from "node:path";
4
+ import { readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, extname, join, resolve, sep } from "node:path";
6
6
  import { Readable, Transform } from "node:stream";
7
7
  import { pipeline } from "node:stream/promises";
8
- import { brotliCompress, brotliCompressSync, constants as zlib, createBrotliCompress, createGzip, gzip, gzipSync } from "node:zlib";
8
+ import { brotliCompress, brotliCompressSync, constants as zlib, createBrotliCompress, createGzip, createZstdCompress, gzip, gzipSync, zstdCompress, zstdCompressSync, } from "node:zlib";
9
9
  import { promisify } from "node:util";
10
10
  import { availableParallelism } from "node:os";
11
11
  import { loadBuild } from "./build.js";
12
+ import { negotiate } from "./files.js";
12
13
  import { normalizeBase } from "../shared/base.js";
13
14
  import { nonce } from "../shared/nonce.js";
14
15
  import { DIRECT_BODY } from "../server/body.js";
@@ -41,32 +42,9 @@ const MIME = {
41
42
  };
42
43
  const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|manifest\+json|wasm)|image\/svg\+xml)/;
43
44
  const MIN_COMPRESS = 1024;
44
- const brotli = promisify(brotliCompress), gz = promisify(gzip);
45
- /** Pick `br` or `gzip` from an Accept-Encoding header: the higher `q` (a tie: `br`); q=0 excludes. */
46
- export function negotiate(header) {
47
- if (!header)
48
- return null;
49
- // Browsers send a handful of distinct headers: parse once per string (bounded: client-controlled text).
50
- let hit = negotiated.get(header);
51
- if (hit === undefined) {
52
- hit = parseEncoding(header);
53
- if (negotiated.size < 64)
54
- negotiated.set(header, hit);
55
- }
56
- return hit;
57
- }
58
- const negotiated = new Map();
59
- function parseEncoding(header) {
60
- const accepted = new Map();
61
- for (const part of header.toLowerCase().split(",")) {
62
- const [name, ...params] = part.trim().split(";");
63
- const q = params.map((p) => /^\s*q=([\d.]+)\s*$/.exec(p)).find(Boolean);
64
- accepted.set(name.trim(), q ? Number(q[1]) : 1);
65
- }
66
- const weight = (name) => accepted.get(name) ?? accepted.get("*") ?? 0;
67
- const br = weight("br"), gzip = weight("gzip");
68
- return br > 0 && br >= gzip ? "br" : gzip > 0 ? "gzip" : null;
69
- }
45
+ const brotli = promisify(brotliCompress), gz = promisify(gzip), zstd = promisify(zstdCompress);
46
+ // `negotiate` / `Encoding` live in `files.ts` (pure; shared with the Bun / Deno static index) and stay exported here.
47
+ export { negotiate } from "./files.js";
70
48
  /** `If-None-Match` (a list, `*`, weak or strong tags) matches `etag` (weak comparison, as for GET). */
71
49
  export function etagMatches(header, etag) {
72
50
  if (!header)
@@ -103,17 +81,34 @@ export function byteRange(header, size) {
103
81
  return [start, end];
104
82
  }
105
83
  // Bodies up to this size that finish before the event loop turns are compressed in one synchronous
106
- // call (tens of microseconds for a page; a streaming compressor costs ~0.5 ms to set up).
84
+ // call (tens of microseconds for a page; a streaming compressor costs ~0.5 ms to set up). zstd −1 runs at
85
+ // ≈ 350 MB/s here, so its one-shot call stays synchronous up to `ZSTD_SYNC` (the thread-pool round trip of the async
86
+ // convenience path cost ≈ +1 ms on a 757 KB page through the adapter: `docs/design/research/2026-10-18-floor-first.md` §9).
107
87
  const ONE_SHOT_SYNC = 256 * 1024;
88
+ const ZSTD_SYNC = 4 * 1024 * 1024;
89
+ // Per-request codec levels (floor-first X1, `docs/design/research/2026-10-18-floor-first.md` §8): zstd −1 on large
90
+ // bodies (0.18× brotli q5's CPU at +10 % bytes on a 757 KB table), −3 on content-sized ones (0.5× at +14 %); brotli
91
+ // q4 is the brotli sweet spot (0.54×) for clients without zstd; gzip last.
92
+ const LARGE_BODY = 256 * 1024;
108
93
  const HTML_BROTLI = { [zlib.BROTLI_PARAM_QUALITY]: 4, [zlib.BROTLI_PARAM_MODE]: zlib.BROTLI_MODE_TEXT };
94
+ const zstdLevel = (size) => ({ [zlib.ZSTD_c_compressionLevel]: size >= LARGE_BODY ? 1 : 3 });
109
95
  function compressOnce(encoding, body) {
96
+ if (encoding === "zstd") {
97
+ const params = zstdLevel(body.length);
98
+ return body.length > ZSTD_SYNC ? zstd(body, { params }) : zstdCompressSync(body, { params });
99
+ }
110
100
  const params = { ...HTML_BROTLI, [zlib.BROTLI_PARAM_SIZE_HINT]: body.length };
111
101
  if (body.length > ONE_SHOT_SYNC)
112
102
  return encoding === "br" ? brotli(body, { params }) : gz(body, { level: 6 });
113
103
  return encoding === "br" ? brotliCompressSync(body, { params }) : gzipSync(body, { level: 6 });
114
104
  }
115
- /** Streaming compressor that flushes every chunk, so streamed HTML reaches the browser promptly. */
116
- function compressor(encoding) {
105
+ /**
106
+ * Streaming compressor that flushes every chunk, so streamed HTML reaches the browser promptly. `collected`: bytes
107
+ * in hand when streaming starts (a body already past `LARGE_BODY` takes zstd's large-body level).
108
+ */
109
+ function compressor(encoding, collected = 0) {
110
+ if (encoding === "zstd")
111
+ return createZstdCompress({ flush: zlib.ZSTD_e_flush, params: zstdLevel(collected) });
117
112
  return encoding === "br"
118
113
  ? createBrotliCompress({
119
114
  flush: zlib.BROTLI_OPERATION_FLUSH,
@@ -121,7 +116,33 @@ function compressor(encoding) {
121
116
  })
122
117
  : createGzip({ flush: zlib.Z_SYNC_FLUSH, level: 6 });
123
118
  }
124
- /** The one dot-named directory served (RFC 8615: `security.txt`, `assetlinks.json`, ACME challenges), at the root only. */
119
+ /** Best-quality compression of a body compressed once and reused (client files, stored pages): brotli q11, zstd 19, gzip 9. */
120
+ function compressBest(encoding, body) {
121
+ if (encoding === "zstd")
122
+ return zstd(body, { params: { [zlib.ZSTD_c_compressionLevel]: 19 } });
123
+ if (encoding === "br")
124
+ return brotli(body, {
125
+ params: { [zlib.BROTLI_PARAM_QUALITY]: 11, [zlib.BROTLI_PARAM_MODE]: zlib.BROTLI_MODE_TEXT, [zlib.BROTLI_PARAM_SIZE_HINT]: body.length },
126
+ });
127
+ return gz(body, { level: 9 });
128
+ }
129
+ const SIBLING = { zstd: ".zst", br: ".br", gzip: ".gz" };
130
+ /**
131
+ * Write a compressed copy next to its file (hidden temp name, then rename: a concurrent reader or worker never sees a
132
+ * partial file; the temp is never served). False when the directory is not writable.
133
+ */
134
+ async function persist(path, bytes) {
135
+ const temp = join(dirname(path), `.${basename(path)}.${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.tmp`);
136
+ try {
137
+ await writeFile(temp, bytes);
138
+ await rename(temp, path);
139
+ return true;
140
+ }
141
+ catch {
142
+ await rm(temp, { force: true }).catch(() => { });
143
+ return false;
144
+ }
145
+ }
125
146
  /** Bytes a streamed direct body may queue in the response before the renderer waits for `drain`. */
126
147
  const MAX_BUFFERED = 256 * 1024;
127
148
  /**
@@ -226,7 +247,7 @@ function sendDirect(attach, res, response, request, headers, encoding, vary) {
226
247
  vary();
227
248
  }
228
249
  head();
229
- const packer = encoding ? compressor(encoding) : null;
250
+ const packer = encoding ? compressor(encoding, size) : null;
230
251
  if (packer)
231
252
  pipeline(packer, res).catch(close);
232
253
  const to = (target = packer ?? res);
@@ -299,6 +320,7 @@ function sendDirect(attach, res, response, request, headers, encoding, vary) {
299
320
  }
300
321
  return result;
301
322
  }
323
+ /** The one dot-named directory served (RFC 8615: `security.txt`, `assetlinks.json`, ACME challenges), at the root only. */
302
324
  const WELL_KNOWN = ".well-known";
303
325
  /** Dot-named path segments are refused, except a leading `.well-known`. */
304
326
  const hidden = (decoded) => {
@@ -322,10 +344,11 @@ function indexFiles(client) {
322
344
  return out;
323
345
  }
324
346
  const inside = (real) => real === root || real.startsWith(root + sep);
325
- const sizeOf = (path) => {
347
+ /** Size of a precompressed sibling, −1 when absent or older than its source (a copy-over deploy left a stale copy). */
348
+ const sizeOf = (path, sourceMtime) => {
326
349
  try {
327
350
  const info = statSync(path);
328
- return info.isFile() ? info.size : -1;
351
+ return info.isFile() && info.mtimeMs >= sourceMtime ? info.size : -1;
329
352
  }
330
353
  catch {
331
354
  return -1;
@@ -363,7 +386,14 @@ function indexFiles(client) {
363
386
  open.delete(real);
364
387
  }
365
388
  else if (info.isFile())
366
- out.set(name, { path: real, size: info.size, mtime: info.mtimeMs, br: sizeOf(real + ".br"), gz: sizeOf(real + ".gz") });
389
+ out.set(name, {
390
+ path: real,
391
+ size: info.size,
392
+ mtime: info.mtimeMs,
393
+ zstd: sizeOf(real + ".zst", info.mtimeMs),
394
+ br: sizeOf(real + ".br", info.mtimeMs),
395
+ gzip: sizeOf(real + ".gz", info.mtimeMs),
396
+ });
367
397
  }
368
398
  };
369
399
  walk(root, "", new Set([root]));
@@ -428,7 +458,11 @@ function drain(req) {
428
458
  }
429
459
  /** Create the Node request listener (exposed for custom servers and tests). */
430
460
  export function createListener(options) {
431
- const { handler, compress = true, earlyHints = false, onError = (error) => console.error(error) } = options;
461
+ const { handler, earlyHints = false, onError = (error) => console.error(error) } = options;
462
+ const compress = options.compress !== false;
463
+ const codecs = typeof options.compress === "object" ? options.compress : null;
464
+ const withZstd = codecs?.zstd !== false;
465
+ const precompress = compress && codecs?.precompress !== false;
432
466
  const prefix = normalizeBase(options.base);
433
467
  const immutable = options.immutable ?? ((pathname) => pathname.startsWith(prefix + "/assets/"));
434
468
  // Header names compare case-insensitively: `res.writeHead` sends differently cased keys as separate
@@ -491,6 +525,7 @@ export function createListener(options) {
491
525
  let draining = false, inflight = 0, wake = null;
492
526
  const memory = options.idleMemory !== false && idleMemorySupported() ? idleMemory(() => inflight) : null;
493
527
  const cache = new Map(); // compressed static bodies by path|etag|encoding
528
+ const pending = new Map(); // compressions in flight by the same key: N concurrent first requests compress once
494
529
  const index = client && options.static !== "fs" ? indexFiles(client) : null;
495
530
  const metas = new WeakMap();
496
531
  /** Resolve a URL path to a file inside the client directory; null when absent or escaping. */
@@ -581,29 +616,41 @@ export function createListener(options) {
581
616
  await pipeline(createReadStream(found.path, { start: from, end: to }), res);
582
617
  return true;
583
618
  }
584
- const encoding = compress && COMPRESSIBLE.test(type) && found.size >= MIN_COMPRESS ? negotiate(req.headers["accept-encoding"]) : null;
619
+ const encoding = compress && COMPRESSIBLE.test(type) && found.size >= MIN_COMPRESS ? negotiate(req.headers["accept-encoding"], withZstd) : null;
585
620
  if (encoding) {
586
- const ext = encoding === "br" ? ".br" : ".gz";
587
- // Prefer a build-time precompressed sibling; otherwise compress once and keep it in memory.
588
- const sibling = (encoding === "br" ? found.br : found.gz) ??
589
- (await stat(found.path + ext).then((s) => (s.isFile() ? s.size : -1), () => -1));
590
- let body;
591
- if (sibling >= 0)
592
- body = await readFile(found.path + ext);
593
- else {
594
- const key = found.path + "|" + etag + "|" + encoding;
595
- let cached = cache.get(key);
596
- if (!cached) {
597
- const raw = await readFile(found.path);
598
- cached =
599
- encoding === "br"
600
- ? await brotli(raw, { params: { [zlib.BROTLI_PARAM_QUALITY]: 11, [zlib.BROTLI_PARAM_SIZE_HINT]: raw.length } })
601
- : await gz(raw, { level: 9 });
602
- if (cache.size > 512)
603
- cache.clear();
604
- cache.set(key, cached);
621
+ const ext = SIBLING[encoding];
622
+ // A copy compressed in this process (bounded memory), else a precompressed sibling on disk (build-time, or
623
+ // written by an earlier process), else compress once at the best quality: kept in memory, and for an immutable
624
+ // file written next to it (hidden temp + rename) so later starts read it from disk.
625
+ const key = found.path + "|" + etag + "|" + encoding;
626
+ let body = cache.get(key);
627
+ if (!body) {
628
+ // A sibling older than its source is a stale copy (copy-over deploy): ignored and overwritten below.
629
+ const sibling = found[encoding] ??
630
+ (await stat(found.path + ext).then((s) => (s.isFile() && s.mtimeMs >= found.mtime ? s.size : -1), () => -1));
631
+ if (sibling >= 0)
632
+ body = await readFile(found.path + ext);
633
+ else {
634
+ let job = pending.get(key);
635
+ if (!job) {
636
+ job = readFile(found.path)
637
+ .then((raw) => compressBest(encoding, raw))
638
+ .then((bytes) => {
639
+ if (cache.size > 512)
640
+ cache.clear();
641
+ cache.set(key, bytes);
642
+ if (precompress && immutable(pathname))
643
+ void persist(found.path + ext, bytes).then((written) => {
644
+ if (written && index)
645
+ found[encoding] = bytes.length;
646
+ });
647
+ return bytes;
648
+ })
649
+ .finally(() => pending.delete(key));
650
+ pending.set(key, job);
651
+ }
652
+ body = await job;
605
653
  }
606
- body = cached;
607
654
  }
608
655
  res.writeHead(200, { ...headers, "Content-Encoding": encoding, "Content-Length": String(body.length) });
609
656
  res.end(req.method === "HEAD" ? undefined : body);
@@ -656,18 +703,14 @@ export function createListener(options) {
656
703
  return;
657
704
  }
658
705
  const encoding = compress && req.method !== "HEAD" && page.body.byteLength >= MIN_COMPRESS && COMPRESSIBLE.test(out.plain["content-type"] ?? "")
659
- ? negotiate(req.headers["accept-encoding"])
706
+ ? negotiate(req.headers["accept-encoding"], withZstd)
660
707
  : null;
661
708
  if (!encoding) {
662
709
  res.writeHead(200, { ...out.plain, age, "x-rivet-cache": "HIT", "content-length": String(page.body.byteLength) });
663
710
  res.end(req.method === "HEAD" ? undefined : page.body);
664
711
  return;
665
712
  }
666
- const body = await (out[encoding] ??= (encoding === "br"
667
- ? brotli(page.body, {
668
- params: { [zlib.BROTLI_PARAM_QUALITY]: 11, [zlib.BROTLI_PARAM_MODE]: zlib.BROTLI_MODE_TEXT, [zlib.BROTLI_PARAM_SIZE_HINT]: page.body.byteLength },
669
- })
670
- : gz(page.body, { level: 9 })).then((packed) => (page.charge(packed.length), packed)));
713
+ const body = await (out[encoding] ??= compressBest(encoding, page.body).then((packed) => (page.charge(packed.length), packed)));
671
714
  res.writeHead(200, { ...out.plain, age, "x-rivet-cache": "HIT", "content-encoding": encoding, "content-length": String(body.length) });
672
715
  res.end(body);
673
716
  }
@@ -686,7 +729,7 @@ export function createListener(options) {
686
729
  headers["set-cookie"] = cookies;
687
730
  const type = response.headers.get("content-type") ?? "";
688
731
  const body = response.body;
689
- const encoding = compress && body && !response.headers.has("content-encoding") && COMPRESSIBLE.test(type) ? negotiate(req.headers["accept-encoding"]) : null;
732
+ const encoding = compress && body && !response.headers.has("content-encoding") && COMPRESSIBLE.test(type) ? negotiate(req.headers["accept-encoding"], withZstd) : null;
690
733
  const vary = () => (headers.vary = response.headers.has("vary") ? response.headers.get("vary") + ", Accept-Encoding" : "Accept-Encoding");
691
734
  if (!body || req.method === "HEAD") {
692
735
  if (encoding)
@@ -747,10 +790,13 @@ export function createListener(options) {
747
790
  res.end(chunks[chunks.length - 1]);
748
791
  return;
749
792
  }
793
+ let collected = 0;
750
794
  if (encoding) {
751
795
  delete headers["content-length"];
752
796
  headers["content-encoding"] = encoding;
753
797
  vary();
798
+ for (const chunk of chunks)
799
+ collected += chunk.byteLength;
754
800
  }
755
801
  res.writeHead(response.status, response.statusText || undefined, headers);
756
802
  const rest = pending;
@@ -764,7 +810,7 @@ export function createListener(options) {
764
810
  request.signal.removeEventListener("abort", cancel);
765
811
  }
766
812
  })(), { objectMode: false });
767
- await (encoding ? pipeline(source, compressor(encoding), res) : pipeline(source, res));
813
+ await (encoding ? pipeline(source, compressor(encoding, collected), res) : pipeline(source, res));
768
814
  }
769
815
  const reject = (res, status, message) => void res.writeHead(status, { ...base, "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", Connection: "close" }).end(message);
770
816
  const listener = (req, res) => {
@@ -154,7 +154,7 @@ export async function prerender(options) {
154
154
  // Layout loaders/guards run per request too: the CSR shell would skip them.
155
155
  const layouts = await Promise.all(route.layouts.map(loadServer));
156
156
  if ([module, ...layouts].some((m) => m?.load || m?.guard))
157
- throw new Error(`Route ${route.id} needs a server (serverLoad/serverGuard on it or a layout, without prerender); use the node adapter or add serverEntries()`);
157
+ throw new Error(`Route ${route.id} needs a server (a load(…) / guard(…) export on it or a layout, without prerender); use the node adapter or add an entries(…) export`);
158
158
  if (!report.fallback.includes(route.id))
159
159
  report.fallback.push(route.id);
160
160
  continue;
@@ -38,8 +38,11 @@ export interface VercelAdapterOptions extends TargetOptions {
38
38
  readonly isr?: {
39
39
  readonly expiration: number | false;
40
40
  };
41
- /** Brotli/gzip in the Node.js function (default true; see `@rivetjs/router/vercel`). */
42
- readonly compress?: boolean;
41
+ /** zstd / brotli / gzip in the Node.js function (default true; `{ zstd: false }` for brotli / gzip only; see `@rivetjs/router/vercel`). */
42
+ readonly compress?: boolean | {
43
+ readonly zstd?: boolean;
44
+ readonly precompress?: boolean;
45
+ };
43
46
  }
44
47
  /**
45
48
  * Netlify, Frameworks API (https://docs.netlify.com/build/frameworks/frameworks-api/): a
@@ -4,6 +4,7 @@ import type { ErrorInfo } from "../server/handler.js";
4
4
  import type { BodyLimit } from "../server/security.js";
5
5
  import type { AppConfig } from "../server/app.js";
6
6
  import { type BuildManifests } from "./assemble.js";
7
+ import { type CompressOptions } from "./node.js";
7
8
  export interface VercelOptions {
8
9
  /** Fixed public origin; default: the request's `Host` + `X-Forwarded-Proto` (Vercel's proxy sets both). */
9
10
  origin?: string;
@@ -19,11 +20,12 @@ export interface VercelOptions {
19
20
  /** Preload planner (`createApp` `preload`): `background` byte budget of idle-time visible prefetch, `lean` (nothing before an interaction); per page when a function. */
20
21
  preload?: AppConfig["preload"];
21
22
  /**
22
- * Brotli/gzip in the function (default true, as the node adapter). Vercel's CDN documents its
23
+ * zstd / brotli / gzip in the function (default true, as the node adapter; `{ zstd, precompress }` as there — the
24
+ * function has no client files, so nothing is written). Vercel's CDN documents its
23
25
  * automatic compression by MIME allowlist (https://vercel.com/docs/compression), which does not list
24
26
  * `text/html`; an encoded response passes through as is.
25
27
  */
26
- compress?: boolean;
28
+ compress?: boolean | CompressOptions;
27
29
  /** Set when the build has ISR pages: their prerender functions get the page path as `__rivet_path`. */
28
30
  isr?: unknown;
29
31
  }
@@ -30,7 +30,7 @@ export function create(entry, build, options = {}) {
30
30
  trustProxy: 1,
31
31
  // A function instance has no idle phase worth shrinking the heap for.
32
32
  idleMemory: false,
33
- ...(options.compress === false ? { compress: false } : {}),
33
+ ...(options.compress !== undefined ? { compress: options.compress } : {}),
34
34
  ...(options.origin ? { origin: options.origin } : {}),
35
35
  ...(options.headers ? { headers: options.headers } : {}),
36
36
  ...(options.onError ? { onError: options.onError } : {}),
@@ -66,6 +66,19 @@ export interface CodegenOptions {
66
66
  * they do not make a page resumable: inert pages ship zero JS). Default: `false` in `static` mode.
67
67
  */
68
68
  clientRouter?: boolean;
69
+ /**
70
+ * Document routes (routes-relative page files; `build/reach.ts`): their browser graph reaches a server-only module, so
71
+ * links to them load documents — rows of the client's match table are `_endpoint` (no interception, data request or
72
+ * chunk prefetch) and document prefetch takes them. Absent / empty: output unchanged.
73
+ */
74
+ documents?: readonly string[];
75
+ /**
76
+ * `rivetRouter({ resume: { model: "derived" } })` (M2 R5, server / static modes): `app.tsx` default-exports `Root`, the
77
+ * page's independent root boundary — props = the route payload as data (`s`), from which the browser's adopt run
78
+ * rebuilds the router (browser history) and the first load of each level (`revived`, no data request); the server entry
79
+ * renders `<Root s={…} />`. Absent: output unchanged.
80
+ */
81
+ derived?: boolean;
69
82
  }
70
83
  export interface Generated {
71
84
  /** File name (relative to outDir) → contents. */