@rivetjs/router 0.0.0-stage → 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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -2
  3. package/adapters/assemble.d.ts +32 -0
  4. package/adapters/assemble.js +32 -0
  5. package/adapters/build.d.ts +24 -0
  6. package/adapters/build.js +43 -0
  7. package/adapters/bun.d.ts +18 -0
  8. package/adapters/bun.js +44 -0
  9. package/adapters/cloudflare.d.ts +5 -0
  10. package/adapters/cloudflare.js +8 -0
  11. package/adapters/cluster.d.ts +7 -0
  12. package/adapters/cluster.js +130 -0
  13. package/adapters/csr.d.ts +12 -0
  14. package/adapters/csr.js +11 -0
  15. package/adapters/deno.d.ts +13 -0
  16. package/adapters/deno.js +35 -0
  17. package/adapters/fetch.d.ts +55 -0
  18. package/adapters/fetch.js +72 -0
  19. package/adapters/files.d.ts +20 -0
  20. package/adapters/files.js +155 -0
  21. package/adapters/idle-memory.d.ts +23 -0
  22. package/adapters/idle-memory.js +91 -0
  23. package/adapters/netlify.d.ts +13 -0
  24. package/adapters/netlify.js +15 -0
  25. package/adapters/node.d.ts +180 -0
  26. package/adapters/node.js +986 -0
  27. package/adapters/pack.d.ts +39 -0
  28. package/adapters/pack.js +360 -0
  29. package/adapters/server-target.d.ts +17 -0
  30. package/adapters/server-target.js +8 -0
  31. package/adapters/static.d.ts +84 -0
  32. package/adapters/static.js +231 -0
  33. package/adapters/target-build.d.ts +95 -0
  34. package/adapters/target-build.js +118 -0
  35. package/adapters/vercel-edge.d.ts +7 -0
  36. package/adapters/vercel-edge.js +9 -0
  37. package/adapters/vercel.d.ts +35 -0
  38. package/adapters/vercel.js +45 -0
  39. package/build/codegen.d.ts +97 -0
  40. package/build/codegen.js +1037 -0
  41. package/build/dev-errors.d.ts +23 -0
  42. package/build/dev-errors.js +247 -0
  43. package/build/lazy-rows.d.ts +19 -0
  44. package/build/lazy-rows.js +59 -0
  45. package/build/migrate.d.ts +31 -0
  46. package/build/migrate.js +385 -0
  47. package/build/reach.d.ts +60 -0
  48. package/build/reach.js +302 -0
  49. package/build/route-css.d.ts +54 -0
  50. package/build/route-css.js +216 -0
  51. package/build/routes.d.ts +55 -0
  52. package/build/routes.js +219 -0
  53. package/build/sync.d.ts +20 -0
  54. package/build/sync.js +16 -0
  55. package/build/vite.d.ts +208 -0
  56. package/build/vite.js +861 -0
  57. package/cli/rivet.d.ts +2 -0
  58. package/cli/rivet.js +101 -0
  59. package/client/active.d.ts +6 -0
  60. package/client/active.js +22 -0
  61. package/client/announce.d.ts +2 -0
  62. package/client/announce.js +21 -0
  63. package/client/dev.d.ts +41 -0
  64. package/client/dev.js +91 -0
  65. package/client/index.d.ts +156 -0
  66. package/client/index.js +714 -0
  67. package/client/layouts.d.ts +110 -0
  68. package/client/layouts.js +310 -0
  69. package/client/leave.d.ts +40 -0
  70. package/client/leave.js +75 -0
  71. package/client/link.d.ts +48 -0
  72. package/client/link.jsx +65 -0
  73. package/client/live.d.ts +57 -0
  74. package/client/live.js +170 -0
  75. package/client/navigate.d.ts +71 -0
  76. package/client/navigate.js +120 -0
  77. package/client/navigation.d.ts +28 -0
  78. package/client/navigation.js +14 -0
  79. package/client/params.d.ts +16 -0
  80. package/client/params.js +28 -0
  81. package/client/route-context.d.ts +32 -0
  82. package/client/route-context.js +21 -0
  83. package/client/scroll.d.ts +16 -0
  84. package/client/scroll.js +80 -0
  85. package/client/search.d.ts +8 -0
  86. package/client/search.js +17 -0
  87. package/client/tailwind.css +8 -0
  88. package/client/transition-helpers.d.ts +28 -0
  89. package/client/transition-helpers.js +63 -0
  90. package/client/transitions.css +90 -0
  91. package/client/transitions.d.ts +21 -0
  92. package/client/transitions.js +156 -0
  93. package/index.d.ts +135 -0
  94. package/index.js +8 -0
  95. package/package.json +156 -4
  96. package/server/app.d.ts +175 -0
  97. package/server/app.js +335 -0
  98. package/server/body.d.ts +20 -0
  99. package/server/body.js +8 -0
  100. package/server/deferred.d.ts +12 -0
  101. package/server/deferred.js +60 -0
  102. package/server/early-hints.d.ts +34 -0
  103. package/server/early-hints.js +63 -0
  104. package/server/handler.d.ts +240 -0
  105. package/server/handler.js +661 -0
  106. package/server/index.d.ts +9 -0
  107. package/server/index.js +6 -0
  108. package/server/inline-styles.d.ts +21 -0
  109. package/server/inline-styles.js +28 -0
  110. package/server/module.d.ts +14 -0
  111. package/server/module.js +96 -0
  112. package/server/render.d.ts +96 -0
  113. package/server/render.js +492 -0
  114. package/server/route-cache.d.ts +245 -0
  115. package/server/route-cache.js +813 -0
  116. package/server/security.d.ts +18 -0
  117. package/server/security.js +47 -0
  118. package/server/shell-build.d.ts +3 -0
  119. package/server/shell-build.js +2 -0
  120. package/server/shell-cache.d.ts +8 -0
  121. package/server/shell-cache.js +836 -0
  122. package/server/strict-csp.d.ts +7 -0
  123. package/server/strict-csp.js +18 -0
  124. package/server-only.d.ts +1 -0
  125. package/server-only.js +3 -0
  126. package/shared/actions.d.ts +10 -0
  127. package/shared/actions.js +18 -0
  128. package/shared/base.d.ts +8 -0
  129. package/shared/base.js +20 -0
  130. package/shared/cookie.d.ts +7 -0
  131. package/shared/cookie.js +32 -0
  132. package/shared/csp-directives.d.ts +9 -0
  133. package/shared/csp-directives.js +85 -0
  134. package/shared/csp-hash.d.ts +39 -0
  135. package/shared/csp-hash.js +121 -0
  136. package/shared/csp.d.ts +26 -0
  137. package/shared/csp.js +13 -0
  138. package/shared/data.d.ts +175 -0
  139. package/shared/data.js +22 -0
  140. package/shared/errors.d.ts +31 -0
  141. package/shared/errors.js +51 -0
  142. package/shared/head.d.ts +57 -0
  143. package/shared/head.js +242 -0
  144. package/shared/nonce.d.ts +2 -0
  145. package/shared/nonce.js +23 -0
  146. package/shared/prefetch-fallback.min.d.ts +1 -0
  147. package/shared/prefetch-fallback.min.js +2 -0
  148. package/shared/prefetch.d.ts +54 -0
  149. package/shared/prefetch.js +74 -0
  150. package/shared/redirect.d.ts +6 -0
  151. package/shared/redirect.js +25 -0
  152. package/shared/server-exports.d.ts +78 -0
  153. package/shared/server-exports.js +77 -0
  154. package/shared/slash.d.ts +8 -0
  155. package/shared/slash.js +24 -0
@@ -0,0 +1,986 @@
1
+ // Node HTTP adapter: serves built client assets and streams a web-standard handler's Responses.
2
+ import { createServer } from "node:http";
3
+ import { createReadStream, readdirSync, realpathSync, statSync } from "node:fs";
4
+ import { readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, extname, join, resolve, sep } from "node:path";
6
+ import { Readable, Transform } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { brotliCompress, brotliCompressSync, constants as zlib, createBrotliCompress, createGzip, createZstdCompress, gzip, gzipSync, zstdCompress, zstdCompressSync, } from "node:zlib";
9
+ import { promisify } from "node:util";
10
+ import { availableParallelism } from "node:os";
11
+ import { loadBuild } from "./build.js";
12
+ import { negotiate } from "./files.js";
13
+ import { normalizeBase } from "../shared/base.js";
14
+ import { nonce } from "../shared/nonce.js";
15
+ import { DIRECT_BODY } from "../server/body.js";
16
+ import { idleMemory, idleMemorySupported } from "./idle-memory.js";
17
+ // `server/route-cache.ts` `ROUTE_CACHE` (registered symbol: that module loads only in apps with route rules).
18
+ const ROUTE_CACHE = Symbol.for("rivet.routeCache");
19
+ // `server/early-hints.ts` `EARLY_HINTS`: the app calls it (`createApp({ earlyHints })`) before guards and loaders.
20
+ const EARLY_HINTS = Symbol.for("rivet.earlyHints");
21
+ const MIME = {
22
+ ".html": "text/html; charset=utf-8",
23
+ ".js": "text/javascript; charset=utf-8",
24
+ ".mjs": "text/javascript; charset=utf-8",
25
+ ".css": "text/css; charset=utf-8",
26
+ ".json": "application/json; charset=utf-8",
27
+ ".map": "application/json; charset=utf-8",
28
+ ".svg": "image/svg+xml",
29
+ ".txt": "text/plain; charset=utf-8",
30
+ ".xml": "application/xml; charset=utf-8",
31
+ ".webmanifest": "application/manifest+json",
32
+ ".wasm": "application/wasm",
33
+ ".png": "image/png",
34
+ ".jpg": "image/jpeg",
35
+ ".jpeg": "image/jpeg",
36
+ ".gif": "image/gif",
37
+ ".webp": "image/webp",
38
+ ".avif": "image/avif",
39
+ ".ico": "image/x-icon",
40
+ ".woff2": "font/woff2",
41
+ ".woff": "font/woff",
42
+ };
43
+ const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|manifest\+json|wasm)|image\/svg\+xml)/;
44
+ const MIN_COMPRESS = 1024;
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";
48
+ /** `If-None-Match` (a list, `*`, weak or strong tags) matches `etag` (weak comparison, as for GET). */
49
+ export function etagMatches(header, etag) {
50
+ if (!header)
51
+ return false;
52
+ if (header.trim() === "*")
53
+ return true;
54
+ const opaque = (tag) => tag.trim().replace(/^W\//, "");
55
+ const own = opaque(etag);
56
+ return header.split(",").some((tag) => opaque(tag) === own);
57
+ }
58
+ /**
59
+ * A single `Range: bytes=…` of a `size`-byte file: `[start, end]` (inclusive), `"unsatisfiable"`
60
+ * (416), or null to send the whole file (no header, several ranges, another unit, malformed).
61
+ */
62
+ export function byteRange(header, size) {
63
+ if (!header)
64
+ return null;
65
+ const match = /^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$/i.exec(header);
66
+ if (!match || (!match[1] && !match[2]))
67
+ return null;
68
+ if (!match[1]) {
69
+ // Suffix: the last n bytes.
70
+ const length = Number(match[2]);
71
+ if (!length)
72
+ return "unsatisfiable";
73
+ return [Math.max(0, size - length), size - 1];
74
+ }
75
+ const start = Number(match[1]);
76
+ const end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
77
+ if (match[2] && Number(match[2]) < start)
78
+ return null;
79
+ if (start >= size)
80
+ return "unsatisfiable";
81
+ return [start, end];
82
+ }
83
+ // Bodies up to this size that finish before the event loop turns are compressed in one synchronous
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).
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;
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 });
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
+ }
100
+ const params = { ...HTML_BROTLI, [zlib.BROTLI_PARAM_SIZE_HINT]: body.length };
101
+ if (body.length > ONE_SHOT_SYNC)
102
+ return encoding === "br" ? brotli(body, { params }) : gz(body, { level: 6 });
103
+ return encoding === "br" ? brotliCompressSync(body, { params }) : gzipSync(body, { level: 6 });
104
+ }
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) });
112
+ return encoding === "br"
113
+ ? createBrotliCompress({
114
+ flush: zlib.BROTLI_OPERATION_FLUSH,
115
+ params: HTML_BROTLI,
116
+ })
117
+ : createGzip({ flush: zlib.Z_SYNC_FLUSH, level: 6 });
118
+ }
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
+ }
146
+ /** Bytes a streamed direct body may queue in the response before the renderer waits for `drain`. */
147
+ const MAX_BUFFERED = 256 * 1024;
148
+ /**
149
+ * A rendered page's body taken straight from the renderer (`server/body.ts`): no web stream, read
150
+ * promises or per-chunk races; slab views are released once written (the renderer pools its slabs).
151
+ * - Complete when attached (the renderer hands a Response out only once it waits or ended, so status and
152
+ * headers are final either way): one write with a Content-Length (its chunks corked, or compressed in
153
+ * one call).
154
+ * - Still rendering when attached (a large synchronous page past the renderer's 64 KiB queue, or pending
155
+ * content), uncompressed: headers + the rendered head go to the socket at once (early bytes: first byte
156
+ * = document head, no event-loop turn), later chunks stream (chunked; Node corks a tick's writes) with
157
+ * backpressure (`MAX_BUFFERED`).
158
+ * - Compressed: chunks collect until the event loop turns (the one macrotask hop: a body complete by then
159
+ * = one compression call with a Content-Length). Still pending: headers go out, the collected chunks
160
+ * follow through the compressor and later ones stream with backpressure.
161
+ * null: the body stream was already read, send it the usual way.
162
+ */
163
+ function sendDirect(attach, res, response, request, headers, encoding, vary) {
164
+ let collected = [];
165
+ let releases = [];
166
+ let size = 0;
167
+ let target = null;
168
+ let closed = false;
169
+ let settle, failed;
170
+ const result = new Promise((done, fail) => ((settle = done), (failed = fail)));
171
+ const release = () => {
172
+ const pending = releases;
173
+ releases = [];
174
+ for (const done of pending)
175
+ done();
176
+ };
177
+ // Aborted (client gone) or failed: later writes throw, which ends the render.
178
+ const close = () => {
179
+ closed = true;
180
+ collected = null;
181
+ release();
182
+ request.signal.removeEventListener("abort", close);
183
+ settle();
184
+ };
185
+ const head = () => res.writeHead(response.status, response.statusText || undefined, headers);
186
+ /**
187
+ * The collected chunks as one writev (`res.write` callbacks release their slab views). The socket is corked,
188
+ * not the response: `res.uncork()` uncorks the socket before it writes a chunked body's buffered frames (one
189
+ * syscall per frame), and `res.end()` sends the chunked terminator before them. `res.end()` / `early` uncork.
190
+ */
191
+ const out = (chunks) => {
192
+ const done = releases;
193
+ releases = [];
194
+ res.socket?.cork();
195
+ for (let i = 0; i < chunks.length; i++)
196
+ res.write(chunks[i], done[i]);
197
+ };
198
+ /** Early bytes: headers + what is rendered so far leave now (Node corks a tick's writes otherwise); later chunks stream. */
199
+ const early = () => {
200
+ const chunks = collected;
201
+ collected = null;
202
+ target = res;
203
+ head();
204
+ out(chunks);
205
+ // `out`'s cork and the one `res.write` holds until the next tick (not `res.uncork`: its count would go below zero).
206
+ const socket = res.socket;
207
+ while (socket?.writableCorked)
208
+ socket.uncork();
209
+ };
210
+ const whole = () => {
211
+ const chunks = collected;
212
+ collected = null;
213
+ request.signal.removeEventListener("abort", close);
214
+ if (encoding)
215
+ vary();
216
+ if (encoding && size >= MIN_COMPRESS) {
217
+ const one = chunks.length === 1;
218
+ const packed = compressOnce(encoding, one ? chunks[0] : Buffer.concat(chunks, size));
219
+ // Concatenated: copied already. One chunk: its slab is read until compression ends.
220
+ if (!one)
221
+ release();
222
+ const send = (bytes) => {
223
+ release();
224
+ headers["content-encoding"] = encoding;
225
+ headers["content-length"] = String(bytes.length);
226
+ head();
227
+ res.end(bytes);
228
+ };
229
+ if (packed instanceof Promise)
230
+ packed.then(send, (error) => (release(), failed(error)));
231
+ else
232
+ send(packed);
233
+ return;
234
+ }
235
+ // Uncompressed: the chunks go out as they are (one corked writev), no concatenated copy.
236
+ headers["content-length"] = String(size);
237
+ head();
238
+ out(chunks);
239
+ res.end();
240
+ };
241
+ const stream = () => {
242
+ if (!collected || closed)
243
+ return;
244
+ if (encoding) {
245
+ delete headers["content-length"];
246
+ headers["content-encoding"] = encoding;
247
+ vary();
248
+ }
249
+ head();
250
+ const packer = encoding ? compressor(encoding, size) : null;
251
+ if (packer)
252
+ pipeline(packer, res).catch(close);
253
+ const to = (target = packer ?? res);
254
+ const chunks = collected, done = releases;
255
+ collected = null;
256
+ releases = [];
257
+ for (let i = 0; i < chunks.length; i++)
258
+ to.write(chunks[i], done[i]);
259
+ };
260
+ const sink = {
261
+ write(chunk, done) {
262
+ if (closed)
263
+ throw new Error("Response stream closed");
264
+ if (collected) {
265
+ collected.push(chunk);
266
+ releases.push(done);
267
+ size += chunk.length;
268
+ return;
269
+ }
270
+ const out = target;
271
+ if (out.destroyed || out.writableEnded)
272
+ throw new Error("Response stream closed");
273
+ if (out.write(chunk, done) || out.writableLength < MAX_BUFFERED)
274
+ return;
275
+ return new Promise((resolve, reject) => {
276
+ const finish = (error) => {
277
+ out.off("drain", drained);
278
+ out.off("error", finish);
279
+ out.off("close", gone);
280
+ error ? reject(error) : resolve();
281
+ };
282
+ const drained = () => finish();
283
+ const gone = () => finish(new Error("Response stream closed"));
284
+ out.once("drain", drained);
285
+ out.once("error", finish);
286
+ out.once("close", gone);
287
+ });
288
+ },
289
+ end() {
290
+ if (closed)
291
+ return;
292
+ if (collected)
293
+ whole();
294
+ else
295
+ target.end();
296
+ },
297
+ error(error) {
298
+ if (closed)
299
+ return;
300
+ closed = true;
301
+ collected = null;
302
+ release();
303
+ request.signal.removeEventListener("abort", close);
304
+ failed(error);
305
+ },
306
+ };
307
+ request.signal.addEventListener("abort", close, { once: true });
308
+ res.once("finish", settle);
309
+ if (!attach(sink)) {
310
+ request.signal.removeEventListener("abort", close);
311
+ res.off("finish", settle);
312
+ return null;
313
+ }
314
+ if (collected) {
315
+ // Still rendering (status and headers are final: the Response exists): the head goes out now.
316
+ if (encoding)
317
+ setImmediate(stream);
318
+ else if (!closed)
319
+ early();
320
+ }
321
+ return result;
322
+ }
323
+ /** The one dot-named directory served (RFC 8615: `security.txt`, `assetlinks.json`, ACME challenges), at the root only. */
324
+ const WELL_KNOWN = ".well-known";
325
+ /** Dot-named path segments are refused, except a leading `.well-known`. */
326
+ const hidden = (decoded) => {
327
+ const parts = decoded.split("/");
328
+ for (let i = 0; i < parts.length; i++)
329
+ if (parts[i].startsWith(".") && !(parts[i] === WELL_KNOWN && parts.slice(0, i).every((p) => !p)))
330
+ return true;
331
+ return false;
332
+ };
333
+ /**
334
+ * Index the client directory once: URL path (`assets/x.js`) → file. Same rules as the per-request
335
+ * lookup: dot-named entries skipped, symlinks resolved and kept only inside the directory.
336
+ */
337
+ function indexFiles(client) {
338
+ const out = new Map();
339
+ let root;
340
+ try {
341
+ root = realpathSync(client);
342
+ }
343
+ catch {
344
+ return out;
345
+ }
346
+ const inside = (real) => real === root || real.startsWith(root + sep);
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) => {
349
+ try {
350
+ const info = statSync(path);
351
+ return info.isFile() && info.mtimeMs >= sourceMtime ? info.size : -1;
352
+ }
353
+ catch {
354
+ return -1;
355
+ }
356
+ };
357
+ // `open`: real directories on the current path (symlink cycles end there).
358
+ const walk = (dir, key, open) => {
359
+ let entries;
360
+ try {
361
+ entries = readdirSync(dir, { withFileTypes: true });
362
+ }
363
+ catch {
364
+ return;
365
+ }
366
+ for (const entry of entries) {
367
+ if (entry.name.startsWith(".") && !(key === "" && entry.name === WELL_KNOWN))
368
+ continue;
369
+ const path = join(dir, entry.name);
370
+ const name = key ? key + "/" + entry.name : entry.name;
371
+ let real, info;
372
+ try {
373
+ real = entry.isSymbolicLink() ? realpathSync(path) : path;
374
+ info = statSync(real);
375
+ }
376
+ catch {
377
+ continue;
378
+ }
379
+ if (entry.isSymbolicLink() && !inside(real))
380
+ continue;
381
+ if (info.isDirectory()) {
382
+ if (open.has(real))
383
+ continue;
384
+ open.add(real);
385
+ walk(real, name, open);
386
+ open.delete(real);
387
+ }
388
+ else if (info.isFile())
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
+ });
397
+ }
398
+ };
399
+ walk(root, "", new Set([root]));
400
+ return out;
401
+ }
402
+ /** Host (`Host` / `X-Forwarded-Host`): a name or IP literal with an optional port (no userinfo, path or spaces). */
403
+ const HOST = /^(?:[a-z\d](?:[a-z\d_-]*[a-z\d])?(?:\.[a-z\d](?:[a-z\d_-]*[a-z\d])?)*\.?|\[[\da-f:.]+\])(?::\d{1,5})?$/i;
404
+ /**
405
+ * The request target as an origin-relative path: origin-form (`/a?b`, also `//a`: appended to the
406
+ * origin it stays a path) as it is; absolute-form (`http://host/a`) only for `origin` itself. Null for
407
+ * anything else (another origin, `*`, authority-form): the URL never takes its origin from the target.
408
+ */
409
+ function pathOf(target, origin) {
410
+ if (target.charCodeAt(0) === 47 /* / */)
411
+ return target;
412
+ if (!/^https?:\/\//i.test(target))
413
+ return null;
414
+ let url;
415
+ try {
416
+ url = new URL(target);
417
+ }
418
+ catch {
419
+ return null;
420
+ }
421
+ return url.origin === origin && !url.username && !url.password ? url.pathname + url.search : null;
422
+ }
423
+ /** Convert a Node request into a web Request whose signal aborts when the client goes away. */
424
+ function toRequest(req, res, url) {
425
+ const controller = new AbortController();
426
+ const abort = () => {
427
+ if (!res.writableFinished)
428
+ controller.abort(new Error("Client disconnected"));
429
+ };
430
+ const headers = new Headers();
431
+ for (const [name, value] of Object.entries(req.headers)) {
432
+ if (value === undefined)
433
+ continue;
434
+ if (Array.isArray(value))
435
+ for (const v of value)
436
+ headers.append(name, v);
437
+ else
438
+ headers.set(name, value);
439
+ }
440
+ const method = req.method ?? "GET";
441
+ const body = method === "GET" || method === "HEAD" ? null : Readable.toWeb(req);
442
+ const init = { method, headers, signal: controller.signal };
443
+ if (body) {
444
+ init.body = body;
445
+ init.duplex = "half";
446
+ }
447
+ return { request: new Request(url, init), abort };
448
+ }
449
+ /**
450
+ * Discard the rest of a request body nobody reads (`Readable.toWeb` keeps it paused once its queue is
451
+ * full, and Node's own dump skips requests a reader consumed): keep-alive and pipelined requests go on.
452
+ */
453
+ function drain(req) {
454
+ if (req.complete || req.destroyed)
455
+ return;
456
+ req.removeAllListeners("data");
457
+ req.resume();
458
+ }
459
+ /** Create the Node request listener (exposed for custom servers and tests). */
460
+ export function createListener(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;
466
+ const prefix = normalizeBase(options.base);
467
+ const immutable = options.immutable ?? ((pathname) => pathname.startsWith(prefix + "/assets/"));
468
+ // Header names compare case-insensitively: `res.writeHead` sends differently cased keys as separate
469
+ // headers, so an option (or a response header) of the same name replaces the default, never doubles it.
470
+ const base = { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin" };
471
+ for (const [name, value] of Object.entries(options.headers ?? {})) {
472
+ for (const key of Object.keys(base))
473
+ if (key.toLowerCase() === name.toLowerCase())
474
+ delete base[key];
475
+ base[name] = value;
476
+ }
477
+ const client = options.client ? resolve(options.client) : null;
478
+ const fixedOrigin = options.origin ? new URL(options.origin).origin : null;
479
+ const trust = options.trustProxy;
480
+ const hops = trust === true ? 1 : typeof trust === "number" && trust > 0 ? Math.floor(trust) : 0;
481
+ const trusting = hops > 0 || typeof trust === "function";
482
+ /** Comma-separated header values, all occurrences, in order. */
483
+ const list = (value) => value === undefined
484
+ ? []
485
+ : (Array.isArray(value) ? value.join(",") : value)
486
+ .split(",")
487
+ .map((part) => part.trim())
488
+ .filter(Boolean);
489
+ /** Trusted hops and the client address: `X-Forwarded-For` entries then the peer, read from the right. */
490
+ const forwarded = (req) => {
491
+ const peer = req.socket.remoteAddress;
492
+ if (!trusting)
493
+ return { trusted: 0, address: peer };
494
+ const chain = [...list(req.headers["x-forwarded-for"]), ...(peer ? [peer] : [])];
495
+ if (typeof trust !== "function")
496
+ return { trusted: hops, address: chain[Math.max(0, chain.length - 1 - hops)] };
497
+ let hop = 0;
498
+ while (hop < chain.length && trust(chain[chain.length - 1 - hop], hop))
499
+ hop++;
500
+ // Every hop trusted: the leftmost entry is as close to the client as it gets.
501
+ return { trusted: hop, address: chain[Math.max(0, chain.length - 1 - hop)] };
502
+ };
503
+ /** A forwarded header's value at the outermost trusted hop (its leftmost when it has fewer entries). */
504
+ const hopValue = (value, trusted) => {
505
+ const values = list(value);
506
+ return values[Math.max(0, values.length - trusted)];
507
+ };
508
+ /** Public origin of a request: the fixed `origin`, else (trusted) forwarded headers, else the socket's; null: malformed host (400). */
509
+ const originOf = (req, trusted) => {
510
+ if (fixedOrigin)
511
+ return fixedOrigin;
512
+ const tls = req.socket.encrypted === true;
513
+ let scheme = tls ? "https" : "http", host = req.headers.host;
514
+ if (trusted > 0) {
515
+ const proto = hopValue(req.headers["x-forwarded-proto"], trusted)?.toLowerCase();
516
+ if (proto === "https" || proto === "http")
517
+ scheme = proto;
518
+ host = hopValue(req.headers["x-forwarded-host"], trusted) ?? host;
519
+ }
520
+ if (host === undefined)
521
+ return scheme + "://localhost";
522
+ return HOST.test(host) ? scheme + "://" + host : null;
523
+ };
524
+ let clientReal = null;
525
+ let draining = false, inflight = 0, wake = null;
526
+ const memory = options.idleMemory !== false && idleMemorySupported() ? idleMemory(() => inflight) : null;
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
529
+ const index = client && options.static !== "fs" ? indexFiles(client) : null;
530
+ const metas = new WeakMap();
531
+ /** Resolve a URL path to a file inside the client directory; null when absent or escaping. */
532
+ async function file(pathname) {
533
+ if (!client)
534
+ return null;
535
+ let decoded;
536
+ try {
537
+ decoded = decodeURIComponent(pathname);
538
+ }
539
+ catch {
540
+ return null;
541
+ }
542
+ if (decoded.includes("\0") || hidden(decoded))
543
+ return null;
544
+ if (index)
545
+ return index.get(decoded.split("/").filter(Boolean).join("/")) ?? null;
546
+ clientReal ??= await realpath(client).catch(() => null);
547
+ if (!clientReal)
548
+ return null;
549
+ const candidate = resolve(clientReal, "." + decoded);
550
+ try {
551
+ const real = await realpath(candidate);
552
+ if (real !== clientReal && !real.startsWith(clientReal + sep))
553
+ return null;
554
+ const info = await stat(real);
555
+ return info.isFile() ? { path: real, size: info.size, mtime: info.mtimeMs } : null;
556
+ }
557
+ catch {
558
+ return null;
559
+ }
560
+ }
561
+ /** MIME type + validators of a file; derived once per indexed entry (fixed size / mtime), per request without an index. */
562
+ function metaOf(found) {
563
+ let meta = metas.get(found);
564
+ if (meta === undefined) {
565
+ meta = {
566
+ type: MIME[extname(found.path).toLowerCase()] ?? "application/octet-stream",
567
+ etag: `W/"${found.size.toString(16)}-${Math.floor(found.mtime).toString(16)}"`,
568
+ modified: new Date(found.mtime).toUTCString(),
569
+ };
570
+ if (index)
571
+ metas.set(found, meta);
572
+ }
573
+ return meta;
574
+ }
575
+ async function serveFile(req, res, pathname) {
576
+ if (prefix && !pathname.startsWith(prefix + "/"))
577
+ return false;
578
+ const found = await file(pathname.slice(prefix.length));
579
+ if (!found)
580
+ return false;
581
+ const { type, etag, modified } = metaOf(found);
582
+ const headers = {
583
+ ...base,
584
+ "Content-Type": type,
585
+ "Cache-Control": immutable(pathname) ? "public, max-age=31536000, immutable" : "no-cache",
586
+ ETag: etag,
587
+ "Last-Modified": modified,
588
+ Vary: "Accept-Encoding",
589
+ };
590
+ // `If-None-Match` wins over `If-Modified-Since` (RFC 9110 13.2.2); dates compare at second precision.
591
+ const match = req.headers["if-none-match"];
592
+ const since = match === undefined ? req.headers["if-modified-since"] : undefined;
593
+ if (match !== undefined ? etagMatches(match, etag) : since !== undefined && Math.floor(found.mtime / 1000) * 1000 <= Date.parse(since)) {
594
+ res.writeHead(304, headers).end();
595
+ return true;
596
+ }
597
+ // One byte range (video/audio seeking, resumed downloads): identity bytes, 206. An `If-Range` other
598
+ // than the current `Last-Modified` sends the whole file (the ETag is weak: never a strong match).
599
+ const ifRange = req.headers["if-range"];
600
+ const range = req.headers.range !== undefined && (ifRange === undefined || ifRange === modified) ? byteRange(req.headers.range, found.size) : null;
601
+ if (range === "unsatisfiable") {
602
+ res.writeHead(416, { ...headers, "Content-Range": `bytes */${found.size}`, "Accept-Ranges": "bytes" }).end();
603
+ return true;
604
+ }
605
+ if (range) {
606
+ const [from, to] = range;
607
+ res.writeHead(206, {
608
+ ...headers,
609
+ "Accept-Ranges": "bytes",
610
+ "Content-Range": `bytes ${from}-${to}/${found.size}`,
611
+ "Content-Length": String(to - from + 1),
612
+ });
613
+ if (req.method === "HEAD")
614
+ res.end();
615
+ else
616
+ await pipeline(createReadStream(found.path, { start: from, end: to }), res);
617
+ return true;
618
+ }
619
+ const encoding = compress && COMPRESSIBLE.test(type) && found.size >= MIN_COMPRESS ? negotiate(req.headers["accept-encoding"], withZstd) : null;
620
+ if (encoding) {
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;
653
+ }
654
+ }
655
+ res.writeHead(200, { ...headers, "Content-Encoding": encoding, "Content-Length": String(body.length) });
656
+ res.end(req.method === "HEAD" ? undefined : body);
657
+ return true;
658
+ }
659
+ res.writeHead(200, { ...headers, "Accept-Ranges": "bytes", "Content-Length": String(found.size) });
660
+ if (req.method === "HEAD")
661
+ res.end();
662
+ else if (!res.destroyed) {
663
+ // The client may go away mid-file (page left, preload cancelled): a closed response is not a server error.
664
+ try {
665
+ await pipeline(createReadStream(found.path), res);
666
+ }
667
+ catch (error) {
668
+ if (!res.destroyed && !res.writableEnded)
669
+ throw error;
670
+ }
671
+ }
672
+ return true;
673
+ }
674
+ const pages = new WeakMap();
675
+ const one = (value) => (Array.isArray(value) ? value.join(", ") : value);
676
+ function pageOut(page) {
677
+ let out = pages.get(page);
678
+ if (!out) {
679
+ const plain = {};
680
+ const unchanged = {};
681
+ for (const name in base)
682
+ if (!page.headers.some(([own]) => own === name.toLowerCase()))
683
+ plain[name] = base[name];
684
+ for (const [name, value] of page.headers) {
685
+ plain[name] = value;
686
+ if (name === "cache-control" || name === "etag" || name === "last-modified" || name === "vary")
687
+ unchanged[name] = value;
688
+ }
689
+ // A br / gzip copy exists for this page: identity answers and 304s vary by Accept-Encoding too.
690
+ if (compress && page.body.byteLength >= MIN_COMPRESS && COMPRESSIBLE.test(plain["content-type"] ?? ""))
691
+ plain.vary = unchanged.vary = plain.vary ? plain.vary + ", Accept-Encoding" : "Accept-Encoding";
692
+ pages.set(page, (out = { plain, unchanged }));
693
+ }
694
+ return out;
695
+ }
696
+ async function serveCached(req, res, page) {
697
+ const out = pageOut(page);
698
+ const age = String(Math.max(0, Math.floor((Date.now() - page.created) / 1000)));
699
+ const match = one(req.headers["if-none-match"]);
700
+ const since = match === undefined ? one(req.headers["if-modified-since"]) : undefined;
701
+ if (match !== undefined ? etagMatches(match, page.etag) : since !== undefined && page.modifiedAt <= Date.parse(since)) {
702
+ res.writeHead(304, { ...out.unchanged, age, "x-rivet-cache": "HIT" }).end();
703
+ return;
704
+ }
705
+ const encoding = compress && req.method !== "HEAD" && page.body.byteLength >= MIN_COMPRESS && COMPRESSIBLE.test(out.plain["content-type"] ?? "")
706
+ ? negotiate(req.headers["accept-encoding"], withZstd)
707
+ : null;
708
+ if (!encoding) {
709
+ res.writeHead(200, { ...out.plain, age, "x-rivet-cache": "HIT", "content-length": String(page.body.byteLength) });
710
+ res.end(req.method === "HEAD" ? undefined : page.body);
711
+ return;
712
+ }
713
+ const body = await (out[encoding] ??= compressBest(encoding, page.body).then((packed) => (page.charge(packed.length), packed)));
714
+ res.writeHead(200, { ...out.plain, age, "x-rivet-cache": "HIT", "content-encoding": encoding, "content-length": String(body.length) });
715
+ res.end(body);
716
+ }
717
+ async function serveResponse(req, res, response, request) {
718
+ const headers = {};
719
+ for (const name in base)
720
+ if (!response.headers.has(name))
721
+ headers[name] = base[name];
722
+ response.headers.forEach((value, name) => {
723
+ if (name === "set-cookie")
724
+ return;
725
+ headers[name] = value;
726
+ });
727
+ const cookies = response.headers.getSetCookie();
728
+ if (cookies.length)
729
+ headers["set-cookie"] = cookies;
730
+ const type = response.headers.get("content-type") ?? "";
731
+ const body = response.body;
732
+ const encoding = compress && body && !response.headers.has("content-encoding") && COMPRESSIBLE.test(type) ? negotiate(req.headers["accept-encoding"], withZstd) : null;
733
+ const vary = () => (headers.vary = response.headers.has("vary") ? response.headers.get("vary") + ", Accept-Encoding" : "Accept-Encoding");
734
+ if (!body || req.method === "HEAD") {
735
+ if (encoding)
736
+ vary();
737
+ res.writeHead(response.status, response.statusText || undefined, headers);
738
+ await body?.cancel().catch(() => { });
739
+ res.end();
740
+ return;
741
+ }
742
+ const direct = response[DIRECT_BODY];
743
+ const sent = direct && !body.locked ? sendDirect(direct, res, response, request, headers, encoding, vary) : null;
744
+ if (sent)
745
+ return sent;
746
+ // Most pages render synchronously: when the whole body is in before the event loop turns, it
747
+ // goes out in one write with a Content-Length (compressed in one call). A body still pending
748
+ // then (streamed async boundaries) streams from here, its first chunks flushed right away.
749
+ const reader = body.getReader();
750
+ const cancel = () => void reader.cancel(request.signal.reason).catch(() => { });
751
+ request.signal.addEventListener("abort", cancel, { once: true });
752
+ const chunks = [];
753
+ let turned = false;
754
+ const turn = new Promise((done) => setImmediate(() => ((turned = true), done(null))));
755
+ let pending = reader.read();
756
+ for (;;) {
757
+ const next = turned ? null : await Promise.race([pending, turn]);
758
+ if (next === null)
759
+ break;
760
+ if (next.done) {
761
+ pending = null;
762
+ break;
763
+ }
764
+ chunks.push(next.value);
765
+ pending = reader.read();
766
+ }
767
+ if (!pending) {
768
+ request.signal.removeEventListener("abort", cancel);
769
+ let size = 0;
770
+ for (const chunk of chunks)
771
+ size += chunk.byteLength;
772
+ if (encoding)
773
+ vary();
774
+ if (encoding && size >= MIN_COMPRESS) {
775
+ const packed = await compressOnce(encoding, chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, size));
776
+ headers["content-encoding"] = encoding;
777
+ headers["content-length"] = String(packed.length);
778
+ res.writeHead(response.status, response.statusText || undefined, headers);
779
+ res.end(packed);
780
+ return;
781
+ }
782
+ // Uncompressed: the body's chunks go out as they are (one corked writev), no concatenated copy.
783
+ headers["content-length"] = String(size);
784
+ res.writeHead(response.status, response.statusText || undefined, headers);
785
+ if (chunks.length > 1) {
786
+ res.cork();
787
+ for (let i = 0; i < chunks.length - 1; i++)
788
+ res.write(chunks[i]);
789
+ }
790
+ res.end(chunks[chunks.length - 1]);
791
+ return;
792
+ }
793
+ let collected = 0;
794
+ if (encoding) {
795
+ delete headers["content-length"];
796
+ headers["content-encoding"] = encoding;
797
+ vary();
798
+ for (const chunk of chunks)
799
+ collected += chunk.byteLength;
800
+ }
801
+ res.writeHead(response.status, response.statusText || undefined, headers);
802
+ const rest = pending;
803
+ const source = Readable.from((async function* () {
804
+ try {
805
+ yield* chunks;
806
+ for (let next = await rest; !next.done; next = await reader.read())
807
+ yield next.value;
808
+ }
809
+ finally {
810
+ request.signal.removeEventListener("abort", cancel);
811
+ }
812
+ })(), { objectMode: false });
813
+ await (encoding ? pipeline(source, compressor(encoding, collected), res) : pipeline(source, res));
814
+ }
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);
816
+ const listener = (req, res) => {
817
+ const via = forwarded(req);
818
+ const origin = originOf(req, via.trusted);
819
+ if (origin === null)
820
+ return reject(res, 400, "Bad Request: malformed Host");
821
+ const target = pathOf(req.url ?? "/", origin);
822
+ if (target === null)
823
+ return reject(res, 400, "Bad Request: request target");
824
+ const pathname = target.split("?", 1)[0];
825
+ if (pathname === "/healthz") {
826
+ res.writeHead(200, { ...base, "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("ok");
827
+ return;
828
+ }
829
+ if (pathname === "/readyz") {
830
+ res.writeHead(draining ? 503 : 200, { ...base, "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
831
+ res.end(draining ? "draining" : "ready");
832
+ return;
833
+ }
834
+ inflight++;
835
+ memory?.request();
836
+ if (draining)
837
+ res.setHeader("Connection", "close");
838
+ const done = () => {
839
+ if (--inflight === 0) {
840
+ if (wake)
841
+ wake();
842
+ memory?.idle();
843
+ }
844
+ };
845
+ res.once("close", done);
846
+ let request;
847
+ void (async () => {
848
+ try {
849
+ if (req.method === "GET" || req.method === "HEAD") {
850
+ if (await serveFile(req, res, pathname))
851
+ return;
852
+ const lookup = handler[ROUTE_CACHE];
853
+ const page = lookup?.(req.method, origin, pathname, target.slice(pathname.length), (name) => one(req.headers[name]));
854
+ if (page)
855
+ return await serveCached(req, res, page);
856
+ }
857
+ const converted = toRequest(req, res, origin + target);
858
+ request = converted.request;
859
+ if (earlyHints && req.httpVersionMinor >= 1)
860
+ request[EARLY_HINTS] = (links) => {
861
+ if (!res.headersSent && !res.destroyed)
862
+ res.writeEarlyHints({ link: links });
863
+ };
864
+ res.once("close", converted.abort);
865
+ // A body the handler never read (CSRF 403, 413, guard, unknown action…): drain it once the
866
+ // response is out, as Node does for untouched requests, so the connection stays usable.
867
+ if (converted.request.body)
868
+ res.once("finish", () => drain(req));
869
+ const address = via.address;
870
+ const context = { nonce: nonce(), ...(address ? { address } : {}) };
871
+ const response = await handler(request, context);
872
+ await serveResponse(req, res, response, request);
873
+ }
874
+ catch (error) {
875
+ if (request?.signal.aborted || error?.code === "ERR_STREAM_PREMATURE_CLOSE")
876
+ return;
877
+ try {
878
+ onError(error, request);
879
+ }
880
+ catch (failure) {
881
+ // A throwing reporter must not become an unhandled rejection (it would end the process).
882
+ console.error(failure);
883
+ }
884
+ if (!res.headersSent) {
885
+ res.writeHead(500, { ...base, "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
886
+ res.end("Internal Server Error");
887
+ }
888
+ else
889
+ res.destroy(error instanceof Error ? error : undefined);
890
+ }
891
+ })();
892
+ };
893
+ return {
894
+ listener,
895
+ drain: () => {
896
+ draining = true;
897
+ memory?.stop();
898
+ },
899
+ idle: () => (inflight === 0 ? Promise.resolve() : new Promise((done) => (wake = done))),
900
+ };
901
+ }
902
+ /** Listen on `port` (0 = ephemeral). Resolves once listening. */
903
+ export async function serve(options) {
904
+ if (options.reusePort) {
905
+ // Port 0 would give every worker its own port; elsewhere the kernel does not balance (macOS) or refuses (Windows).
906
+ if (!options.port)
907
+ throw new TypeError("serve({ reusePort }) needs a fixed port (not 0)");
908
+ if (process.platform !== "linux" && process.platform !== "freebsd")
909
+ throw new TypeError(`serve({ reusePort }) balances connections only on Linux and FreeBSD (this is ${process.platform}); leave it off here`);
910
+ }
911
+ const { listener, drain, idle } = createListener(options);
912
+ const server = createServer({ requestTimeout: 30_000, headersTimeout: 15_000, keepAliveTimeout: 5_000 }, listener);
913
+ const sockets = new Set();
914
+ server.on("connection", (socket) => {
915
+ sockets.add(socket);
916
+ socket.once("close", () => sockets.delete(socket));
917
+ });
918
+ await new Promise((done, fail) => {
919
+ server.once("error", fail);
920
+ const listen = { port: options.port ?? 3000, host: options.host ?? "127.0.0.1" };
921
+ // `exclusive`: a cluster worker binds its own SO_REUSEPORT socket (else the primary would own one shared handle).
922
+ server.listen(options.reusePort ? { ...listen, reusePort: true, exclusive: true } : listen, () => {
923
+ server.off("error", fail);
924
+ done();
925
+ });
926
+ });
927
+ const address = server.address();
928
+ const host = address.family === "IPv6" ? `[${address.address}]` : address.address;
929
+ let closing = null;
930
+ return {
931
+ server,
932
+ url: `http://${host}:${address.port}`,
933
+ close: () => (closing ??= (async () => {
934
+ drain();
935
+ const stopped = new Promise((done) => server.close(() => done()));
936
+ server.closeIdleConnections();
937
+ const grace = new Promise((done) => setTimeout(done, options.grace ?? 10_000).unref());
938
+ await Promise.race([idle(), grace]);
939
+ for (const socket of sockets)
940
+ socket.destroy();
941
+ await stopped;
942
+ })()),
943
+ };
944
+ }
945
+ /** Close on SIGTERM/SIGINT once; exits the process after draining. */
946
+ export function closeOnSignals(serving, signals = ["SIGTERM", "SIGINT"]) {
947
+ const stop = () => {
948
+ for (const signal of signals)
949
+ process.off(signal, stop);
950
+ serving.close().then(() => process.exit(0), () => process.exit(1));
951
+ };
952
+ for (const signal of signals)
953
+ process.once(signal, stop);
954
+ }
955
+ export async function start(options = {}) {
956
+ const count = options.workers === "auto" ? availableParallelism() : Math.max(1, Math.floor(options.workers ?? 1));
957
+ if (count > 1) {
958
+ const { isPrimary, primary, worker } = await import("./cluster.js");
959
+ const single = { ...options, workers: 1 };
960
+ return isPrimary() ? primary(count, options.grace ?? 10_000, options.onError ?? ((error) => console.error(error))) : worker(() => start(single));
961
+ }
962
+ const { dist = "dist", stream, locales, resolveLocale, maxBody, csp, preload, routeRules, routeCache, renderCache, earlyHints, workers: _workers, ...serveOptions } = options;
963
+ const { handler, client, entry } = await loadBuild(dist, {
964
+ ...(stream ? { stream } : {}),
965
+ ...(options.onError ? { onError: options.onError } : {}),
966
+ ...(maxBody !== undefined ? { maxBody } : {}),
967
+ ...(csp !== undefined ? { csp } : {}),
968
+ ...(preload ? { preload } : {}),
969
+ ...(routeRules ? { routeRules } : {}),
970
+ // The fixed public origin is the one stored pages are for (`isr` rules need one).
971
+ ...(routeCache || serveOptions.origin ? { routeCache: { ...(serveOptions.origin ? { origin: serveOptions.origin } : {}), ...routeCache } } : {}),
972
+ ...(renderCache ? { renderCache } : {}),
973
+ ...(earlyHints ? { earlyHints } : {}),
974
+ }, locales?.length ? { locales, ...(resolveLocale ? { resolveLocale } : {}) } : {});
975
+ const prefix = normalizeBase(serveOptions.base ?? entry.base);
976
+ const immutable = serveOptions.immutable ??
977
+ (locales?.length ? (pathname) => locales.some((locale) => pathname.startsWith(prefix + "/" + locale + "/assets/")) : undefined);
978
+ return serve({
979
+ ...serveOptions,
980
+ handler,
981
+ client,
982
+ ...(prefix ? { base: prefix } : {}),
983
+ ...(immutable ? { immutable } : {}),
984
+ ...(earlyHints ? { earlyHints: true } : {}),
985
+ });
986
+ }