@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.
- package/adapters/files.d.ts +13 -0
- package/adapters/files.js +58 -6
- package/adapters/node.d.ts +22 -5
- package/adapters/node.js +113 -67
- package/adapters/static.js +1 -1
- package/adapters/target-build.d.ts +5 -2
- package/adapters/vercel.d.ts +4 -2
- package/adapters/vercel.js +1 -1
- package/build/codegen.d.ts +13 -0
- package/build/codegen.js +152 -35
- package/build/dev-errors.d.ts +6 -2
- package/build/dev-errors.js +15 -4
- package/build/lazy-rows.d.ts +19 -0
- package/build/lazy-rows.js +59 -0
- package/build/migrate.d.ts +20 -4
- package/build/migrate.js +300 -61
- package/build/reach.d.ts +60 -0
- package/build/reach.js +302 -0
- package/build/routes.d.ts +9 -4
- package/build/routes.js +30 -13
- package/build/vite.d.ts +13 -0
- package/build/vite.js +165 -7
- package/cli/rivet.js +3 -2
- package/client/dev.js +17 -0
- package/client/index.d.ts +4 -0
- package/client/index.js +34 -0
- package/client/route-context.d.ts +1 -1
- package/index.d.ts +22 -25
- package/index.js +2 -1
- package/package.json +4 -4
- package/server/app.d.ts +7 -1
- package/server/app.js +38 -5
- package/server/deferred.d.ts +12 -0
- package/server/deferred.js +60 -0
- package/server/handler.d.ts +56 -12
- package/server/handler.js +46 -8
- package/server/index.d.ts +1 -1
- package/server/module.d.ts +7 -7
- package/server/module.js +52 -34
- package/server/render.d.ts +2 -0
- package/server/render.js +3 -0
- package/server/route-cache.d.ts +74 -4
- package/server/route-cache.js +77 -6
- package/server/shell-build.d.ts +3 -0
- package/server/shell-build.js +2 -0
- package/server/shell-cache.d.ts +8 -0
- package/server/shell-cache.js +836 -0
- package/shared/server-exports.d.ts +78 -0
- package/shared/server-exports.js +77 -0
package/adapters/files.d.ts
CHANGED
|
@@ -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
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
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
|
|
150
|
+
return { path, headers };
|
|
99
151
|
}
|
|
100
152
|
}
|
|
101
153
|
return { path: file.path, headers };
|
package/adapters/node.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
export
|
|
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
|
-
/**
|
|
116
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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, {
|
|
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,
|
|
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
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
if (
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
|
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) => {
|
package/adapters/static.js
CHANGED
|
@@ -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 (
|
|
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
|
-
/**
|
|
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
|
package/adapters/vercel.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
}
|
package/adapters/vercel.js
CHANGED
|
@@ -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
|
|
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 } : {}),
|
package/build/codegen.d.ts
CHANGED
|
@@ -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. */
|