rosefc 0.0.0-stage → 0.2.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.
@@ -0,0 +1,817 @@
1
+ // Must run before any request can reach bootstrap(): see shell.ts.
2
+ (globalThis as any).__ROSEFC_SKIP_MINIFY = true;
3
+
4
+ import http from 'node:http';
5
+ import { Readable } from 'node:stream';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import zlib from 'node:zlib';
9
+ import { promisify } from 'node:util';
10
+ import { AsyncLocalStorage } from 'node:async_hooks';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { createEdgeHandler } from './edge.ts';
13
+ import { requestOrigin } from './origin.ts';
14
+ import { hotVaryValues } from './hot-vary.js';
15
+ import { ifNoneMatch, responseEtag } from './validator.js';
16
+ import { onShutdown, runShutdownHooks } from './shutdown.ts';
17
+
18
+ // Brotli quality for ON-THE-FLY compression. Baked .br siblings keep quality 11 (see the build) - those are the headline number.
19
+ const brotliFast = { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } };
20
+
21
+
22
+ const dist = fileURLToPath(new URL('./', import.meta.url));
23
+ const port = Number(process.env.PORT || 8080);
24
+ let handle: ((request: Request) => Promise<Response>) | null = null;
25
+ let mod: any = null;
26
+ let warmList: string[] = [];
27
+
28
+ // Per-request context isolation.
29
+ const requestAls = new AsyncLocalStorage<Record<string, unknown>>();
30
+
31
+ function runIsolated<T>(fn: () => T): T {
32
+ return requestAls.run({}, fn);
33
+ /* client gone: the render still finishes */
34
+ }
35
+
36
+ let extraRoots: string[] | null = null;
37
+ function resolveStaticRoots(): string[] {
38
+ if (extraRoots) return extraRoots;
39
+ const seen = new Set<string>([dist]);
40
+ const add = (p: string) => {
41
+ if (p && !seen.has(p)) seen.add(p);
42
+ };
43
+
44
+ if (process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME) {
45
+ add(path.join(dist, '..'));
46
+ add(path.join(dist, '..', '..'));
47
+ add(path.resolve(dist, '..', 'public'));
48
+ }
49
+
50
+ // The framework never names an app: a static root is the project root
51
+ // beside dist, or a public/ directory in any of the usual places.
52
+ add(path.join(dist, '..'));
53
+ add(path.join(dist, 'public'));
54
+ add(path.resolve(dist, '..', 'public'));
55
+ add(path.resolve(process.cwd(), 'public'));
56
+ extraRoots = [...seen];
57
+ return extraRoots;
58
+ }
59
+
60
+ // A request path that must never reach the dynamic render.
61
+ function isBakedAssetPath(pathname: string): boolean {
62
+ return (
63
+ pathname.startsWith('/public/') ||
64
+ pathname.startsWith('/icons/') ||
65
+ pathname.startsWith('/vendor/') ||
66
+ pathname === '/favicon.ico'
67
+ );
68
+ }
69
+
70
+ // B8: 103 Early Hints.
71
+ const earlyHintLinks: string[] = (() => {
72
+ try {
73
+ const html = fs.readFileSync(path.join(dist, 'index.html'), 'utf8');
74
+ const links: string[] = [];
75
+ for (const m of html.matchAll(/<script[^>]+src="([^"]+)"/g)) {
76
+ const v = `<${m[1]}>; rel=preload; as=script`;
77
+ if (!links.includes(v)) links.push(v);
78
+ }
79
+ for (const m of html.matchAll(/<link[^>]+rel="stylesheet"[^>]*>/g)) {
80
+ const href = /href="([^"]+)"/.exec(m[0])?.[1];
81
+ if (href) { const v = `<${href}>; rel=preload; as=style`; if (!links.includes(v)) links.push(v); }
82
+ }
83
+ for (const m of html.matchAll(/<link[^>]+href="([^"]+)"[^>]+rel="stylesheet"[^>]*>/g)) {
84
+ const v = `<${m[1]}>; rel=preload; as=style`;
85
+ if (!links.includes(v)) links.push(v);
86
+ }
87
+ return links;
88
+ } catch { return []; }
89
+ })();
90
+
91
+ const ready = createEdgeHandler(dist).then(async (h) => {
92
+ handle = h;
93
+ try {
94
+ mod = await import(new URL('./server.js', import.meta.url).href);
95
+ warmList = ['/', ...((mod.dynamicRoutes as string[]) || []).filter((r: string) => !r.includes(':'))];
96
+ mod.invalidateRenderCache?.();
97
+ } catch { /* warm list stays just the home route */ }
98
+ mod?.installContextStore?.({
99
+ getStore: () => requestAls.getStore(),
100
+ run: (store: Record<string, unknown>, fn: () => unknown) => requestAls.run(store, fn),
101
+ });
102
+ return h;
103
+ });
104
+
105
+ const shouldWarm = !process.env.VERCEL;
106
+ if (shouldWarm) ready.then(async (h) => {
107
+ try {
108
+ const reg = (globalThis as unknown as {
109
+ __oboeLoaders?: { warmUrls?: () => Promise<unknown> };
110
+ }).__oboeLoaders;
111
+ const extra = await reg?.warmUrls?.();
112
+ if (Array.isArray(extra)) {
113
+ warmList.push(
114
+ ...extra.filter((u): u is string => typeof u === 'string' && u.startsWith('/')).slice(0, 8),
115
+ );
116
+ }
117
+ } catch { /* registry absent or the lookup failed - fixed routes still warm */ }
118
+ let next = 0;
119
+ await Promise.all(Array.from({ length: Math.min(4, warmList.length) }, async () => {
120
+ while (next < warmList.length) {
121
+ const r = warmList[next++];
122
+ try {
123
+ const res = await runIsolated(() => h(new Request('http://localhost' + r)));
124
+ const buf = await res.arrayBuffer();
125
+ const suspect = res.status === 200 && res.headers.get('content-type')?.includes('text/html') && buf.byteLength < 2048;
126
+ console.log('[rosefc] warm', r, 'status=' + res.status, 'bytes=' + buf.byteLength, suspect ? 'SUSPECT-EMPTY' : '');
127
+ } catch (e) {
128
+ console.error('[rosefc] warm', r, 'FAILED:', (e as Error)?.stack ?? e);
129
+ }
130
+ }
131
+ }));
132
+ }).catch((e) => console.error('[rosefc] warm loop aborted:', (e as Error)?.stack ?? e));
133
+
134
+ const hotCache = new Map<string, { raw: Buffer; br?: Buffer; gzip?: Buffer; headers: Record<string, string | string[]>; etag: string; fetchedAt: number; status?: number; refreshing?: boolean }>();
135
+ const HOT_CACHE_MAX = 300;
136
+ function hotCacheEvict(): void {
137
+ while (hotCache.size >= HOT_CACHE_MAX) {
138
+ const oldest = hotCache.keys().next().value as string | undefined;
139
+ if (oldest === undefined) break;
140
+ hotCache.delete(oldest);
141
+ }
142
+ }
143
+ const HOT_TTL = 10_000;
144
+
145
+ // An invalidation is only sound if it lands AFTER the mutation commits. The
146
+ // clear used to sit at the top of the non-GET handler - before the app's
147
+ // write, which commits inside handle() below - so a warm render that started
148
+ // between the clear and the commit stored pre-mutation bytes into a cache that
149
+ // had already been cleared: stale bytes resurrected for a whole TTL, and the
150
+ // window renews itself while the warm storm keeps firing. The generation
151
+ // counter closes the other half of the same hole: a store (refetchHot or
152
+ // fillHot) that straddled ANY invalidation abandons its write, so bytes
153
+ // rendered before the world changed can never outlive the change.
154
+ let hotGen = 0;
155
+ function invalidateHot(): void {
156
+ hotGen++;
157
+ hotCache.clear();
158
+ const fn = (mod as any).invalidateRenderCache;
159
+ if (typeof fn === 'function') fn();
160
+ }
161
+ // An app that mutates state OUTSIDE a request - a queue consumer, a cron, a
162
+ // $store write from a websocket handler - has no non-GET request to hang the
163
+ // invalidation on, so it calls this at its commit point instead. The request
164
+ // path needs no hook: the handler schedules its own invalidation on the
165
+ // response's finish (below).
166
+ (globalThis as unknown as { __rosefcInvalidateHot?: () => void }).__rosefcInvalidateHot = invalidateHot;
167
+
168
+ const gzipFallback: zlib.ZlibOptions = { level: 6 };
169
+
170
+ function pickEncoding(raw: Buffer, accept: string): 'br' | 'gzip' | null {
171
+ if (raw.length <= 1024) return null;
172
+ return /br\b/i.test(accept) ? 'br' : /gzip\b/i.test(accept) ? 'gzip' : null;
173
+ }
174
+
175
+ // Vary is a list header, and two independent parties write it: the server
176
+ // negotiates the body on Accept-Encoding, and a route may declare its own
177
+ // dimensions (an i18n redirect varies on Accept-Language). setHeader replaces
178
+ // by name, so a naive write on either side silently drops the other - merge,
179
+ // deduped case-insensitively.
180
+ function addVary(res: http.ServerResponse, dim: string): void {
181
+ const have = res.getHeader('vary');
182
+ const raw = have == null ? '' : Array.isArray(have) ? have.join(', ') : String(have);
183
+ const parts = raw.split(',').map((s: string) => s.trim()).filter(Boolean);
184
+ if (!parts.some((p: string) => p.toLowerCase() === dim.toLowerCase())) parts.push(dim);
185
+ res.setHeader('Vary', parts.join(', '));
186
+ }
187
+
188
+ const NO_COMPRESS_EXT = new Set([
189
+ '.png', '.jpg', '.jpeg', '.webp', '.avif', '.gif', '.ico',
190
+ '.woff', '.woff2', '.mp4', '.webm', '.mp3', '.pdf', '.zip', '.gz', '.br',
191
+ ]);
192
+
193
+ /**
194
+ * asset (writeBrotli). Compressing those bytes again here costs the same
195
+ * multi-hundred-millisecond synchronous pass - measured: ~290 ms blocked for a
196
+ * 194 KB vendor bundle, ~3.3 s for a 2.2 MB one - on the event loop, inside a
197
+ * request. Reuse the sibling instead. Freshness is the whole safety argument:
198
+ * the sibling must be at least as new as the raw file, so a file replaced in
199
+ * place after the build (or an ISR re-render that rewrote it) is newer than its
200
+ * sibling and falls back to compressing in-process. A stale sibling is
201
+ * therefore unservable - the worst case is the old cost, never wrong bytes.
202
+ */
203
+ function prebuiltBro(filePath: string, rawMtimeMs: number): Buffer | null {
204
+ try {
205
+ const brPath = filePath + '.br';
206
+ const s = fs.statSync(brPath);
207
+ if (!s.isFile() || s.mtimeMs < rawMtimeMs) return null;
208
+ return fs.readFileSync(brPath);
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ function encodedBody(entry: { raw: Buffer; br?: Buffer; gzip?: Buffer }, enc: 'br' | 'gzip'): Buffer {
215
+ if (enc === 'br') return (entry.br ??= zlib.brotliCompressSync(entry.raw, brotliFast));
216
+ return (entry.gzip ??= zlib.gzipSync(entry.raw, gzipFallback));
217
+ }
218
+
219
+ const brotliAsync = promisify(zlib.brotliCompress);
220
+ const gzipAsync = promisify(zlib.gzip);
221
+ async function encodedBodyAsync(
222
+ entry: { raw: Buffer; br?: Buffer; gzip?: Buffer },
223
+ enc: 'br' | 'gzip',
224
+ ): Promise<Buffer> {
225
+ if (enc === 'br') return (entry.br ??= await brotliAsync(entry.raw, brotliFast));
226
+ return (entry.gzip ??= await gzipAsync(entry.raw, gzipFallback));
227
+ }
228
+
229
+ /**
230
+ * One access line per request on stdout - the same greppable shape the CLI's
231
+ *
232
+ * prod-server had NO access log at all: the platform request log showed a
233
+ * status and a duration, and the server itself said nothing, so a blank first
234
+ * visit was reported repeatedly with nothing to read. Every response exit in
235
+ * the http handler calls this, which makes a log export readable line by line
236
+ * against what the render actually produced.
237
+ */
238
+ function writeAccessLine(req: any, res: any, bytes: number): void {
239
+ console.log(`${new Date().toISOString().slice(0, 19)}Z ${req.method} ${req.url} ${res.statusCode} ${bytes}B`);
240
+ }
241
+
242
+ function fnv(s: string): string {
243
+ let h = 0x811c9dc5;
244
+ for (let i = 0; i < s.length; i++) {
245
+ h ^= s.charCodeAt(i);
246
+ h = (h * 0x01000193) >>> 0;
247
+ }
248
+ return h.toString(16);
249
+ }
250
+
251
+ // Normalize a request target: absolute-form request lines (proxy style, which
252
+ // RFC allows) must not be joined onto an origin twice - strip to path+search.
253
+ function relTarget(u: string): string {
254
+ if (u.startsWith('http://') || u.startsWith('https://')) {
255
+ try {
256
+ const p = new URL(u);
257
+ return p.pathname + p.search;
258
+ } catch {
259
+ return '/';
260
+ }
261
+ }
262
+ return u;
263
+ }
264
+
265
+ // The hot cache is a SHARED store, so its key must name the representation
266
+ // exactly: same path, same cookie, same negotiated language. It used to be
267
+ // pathname + cookie only - and a representation negotiated on Accept-Language
268
+ // is a different representation per language, so the first language rendered
269
+ // was replayed to every other language for a whole TTL. Boot warm sends no
270
+ // accept-language at all, so the app's default won the shared key and an
271
+ // English browser got the Chinese installer (BUG.md AB). Folding the header
272
+ // into the key partitions the store: warm fills its own header-less slot
273
+ // lazily, and every real language renders on demand. A route can name further
274
+ // headers of its own - `export const hotVary = ['x-rv-url']` - and they ride
275
+ // along in the same fingerprint, which is what lets an app retire the query-tag
276
+ // workaround (/?l=en) and keep the cache.
277
+ function hotKey(pathname: string, cookieHeader: string, dims: string[]): string {
278
+ return pathname + '|' + fnv(cookieHeader + '\u0000' + dims.join('\u0000'));
279
+ }
280
+
281
+ // The headers the matched route declared with `export const hotVary`. Matching
282
+ // is on pathname (never the query), so one declaration covers every URL a
283
+ // dynamic route serves.
284
+ function hotVaryNames(target: string): string[] {
285
+ return mod?.hotVaryFor?.(target.split('?')[0]!) ?? [];
286
+ }
287
+
288
+ // A response that declares it must not be STORED by a shared cache - or that
289
+ // varies on a dimension the key does not carry - must never enter the store:
290
+ // it would be replayed to requests that negotiate differently for a whole
291
+ // TTL. The key carries pathname, cookie and accept-language, so
292
+ // accept-language is the one app-declared dimension that is already safe;
293
+ // anything else (`*`, origin, a custom header) is not - the /api/data loader
294
+ // fragment and the installer were both poisoned this way (BUG.md AB). A route
295
+ // makes any of them safe by declaring it (`export const hotVary`): a declared
296
+ // dimension is carried by the key too, and is passed in here so the guard lets
297
+ // it through - refusing a carried dimension would only switch the cache off
298
+ // for that route, which is the blunt instrument the declaration exists to
299
+ // replace.
300
+ // accept-encoding needs no mention: the store keeps the raw bytes and
301
+ // re-negotiates the encoding per request (pickEncoding), which is why the
302
+ // framework's own Accept-Encoding merge on the node response stays out of
303
+ // this check - this reads the app's Response.
304
+ //
305
+ // no-store and private are the storage prohibitions, and they are the app's
306
+ // escape valves. Revalidation-freshness directives (no-cache, max-age=0) are
307
+ // deliberately NOT refusals: the framework's own dynamic documents carry
308
+ // `cache-control: no-cache` (browsers must revalidate HTML on every visit),
309
+ // and the hot cache is the server-side revalidation layer for exactly those
310
+ // documents - a 10s TTL plus generation invalidation on every mutation.
311
+ // Refusing no-cache would disable the cache for its primary workload, which
312
+ // was never the reported defect.
313
+ function refusesStore(response: Response, carried: string[] = []): boolean {
314
+ const cc = (response.headers.get('cache-control') || '').toLowerCase();
315
+ for (const tok of cc.split(/[,\s]+/)) {
316
+ if (tok === 'no-store' || tok === 'private') return true;
317
+ }
318
+ const vary = response.headers.get('vary');
319
+ if (vary) {
320
+ for (const dim of vary.split(',')) {
321
+ const d = dim.trim().toLowerCase();
322
+ if (d === '' || d === 'accept-encoding') continue;
323
+ // Carried dimensions are partitioned in the store, so declaring them is
324
+ // a reason to KEEP the response, not to drop it: accept-language is
325
+ // carried unconditionally, the rest because the route declared them.
326
+ if (d === 'accept-language' || carried.includes(d)) continue;
327
+ return true;
328
+ }
329
+ }
330
+ return false;
331
+ }
332
+
333
+ async function refetchHot(pathname: string, cookieHeader: string, dims: string[], names: string[]): Promise<void> {
334
+ const gen = hotGen;
335
+ const headers: Record<string, string> = { 'user-agent': 'rosefc-hot' };
336
+ if (cookieHeader) headers.cookie = cookieHeader;
337
+ // The warm render must negotiate the SAME dimensions the entry it fills is
338
+ // keyed by: a header-less warm render stored under a language (or a declared
339
+ // header) key would serve default bytes to that partition for a whole TTL.
340
+ if (dims[0]) headers['accept-language'] = dims[0];
341
+ for (let i = 0; i < names.length; i++) if (dims[i + 1]) headers[names[i]!] = dims[i + 1]!;
342
+ const response = await runIsolated(() => handle!(new Request('http://localhost' + pathname, { headers })));
343
+ const csp = response.headers.get('content-security-policy') || '';
344
+ const key = hotKey(pathname, cookieHeader, dims);
345
+ if (!cacheable(response.status) || csp.includes('nonce-') || refusesStore(response, names)) {
346
+ hotCache.delete(key);
347
+ return;
348
+ }
349
+ // set-cookie is a list header: forEach delivers it per value, so a plain
350
+ // object assignment keeps only the last one. Capture the whole array and
351
+ // store it under the name node's setHeader replays as multiple headers.
352
+ const head: Record<string, string | string[]> = {};
353
+ const cookies = response.headers.getSetCookie?.() ?? [];
354
+ response.headers.forEach((v, k) => { if (k !== 'content-length' && k !== 'date' && k !== 'content-encoding') { if (cookies.length && k.toLowerCase() === 'set-cookie') return; head[k] = v; } });
355
+ if (cookies.length) head['set-cookie'] = cookies;
356
+ // AC (BUG.md): same cross-visitor Set-Cookie leak on the warm path. The
357
+ // boot/refresh warm render carries no cookie, so its key is the anonymous
358
+ // slot - refuse to store any Set-Cookie it produces under that key.
359
+ if (!cookieHeader && cookies.length) { hotCache.delete(key); return; }
360
+ const raw = Buffer.from(await response.arrayBuffer());
361
+ // An invalidation landed while this render was in flight: the bytes may
362
+ // predate it, and the cache was already cleared - storing them would
363
+ // resurrect exactly the staleness the invalidation published. Drop them; the
364
+ // next request renders fresh.
365
+ if (gen !== hotGen) return;
366
+ hotCacheEvict();
367
+ hotCache.set(key, {
368
+ raw,
369
+ headers: head,
370
+ // AM (BUG.md): the warm render stores the same validator the request path
371
+ // would have derived from these bytes - a length-based one let two
372
+ // equal-length representations share a tag, so a client holding the older
373
+ // tag revalidated into 304 and kept stale bytes for the whole TTL.
374
+ etag: responseEtag(response, raw),
375
+ fetchedAt: Date.now(),
376
+ status: response.status,
377
+ });
378
+ }
379
+
380
+ // In-flight warm dedup: a cookieless GET storm against an empty cache used to
381
+ // replay the whole warmList per request (the re-warm trigger has no in-flight
382
+ // set), so N concurrent first-hits rendered N x warmList routes - and a failed
383
+ // wave left the cache empty, so every subsequent GET fired another full wave.
384
+ const warming = new Set<string>();
385
+ function warmHot(pathname: string, cookieHeader: string, dims: string[], names: string[]): void {
386
+ const wkey = hotKey(pathname, cookieHeader, dims);
387
+ if (warming.has(wkey)) return;
388
+ warming.add(wkey);
389
+ refetchHot(pathname, cookieHeader, dims, names)
390
+ .catch(() => {
391
+ const e = hotCache.get(wkey);
392
+ if (e) { e.refreshing = false; e.fetchedAt = Date.now() - HOT_TTL + 2000; }
393
+ })
394
+ .finally(() => {
395
+ warming.delete(wkey);
396
+ const e = hotCache.get(wkey);
397
+ if (e) e.refreshing = false;
398
+ });
399
+ }
400
+
401
+ async function tryHot(req: http.IncomingMessage, res: http.ServerResponse): Promise<boolean> {
402
+ if (req.method !== 'GET') return false;
403
+ const q = relTarget(req.url || '/');
404
+ const cookieHeader = String(req.headers.cookie || '');
405
+ // Same normalization the request path below applies: a key is per
406
+ // representation, and `en-US` / `en-us` are the same representation. The
407
+ // declared headers ride along, so a route's private negotiation dimension
408
+ // gets its own partition instead of poisoning the shared one.
409
+ const names = hotVaryNames(q);
410
+ const dims = hotVaryValues(q.split('?')[0]!, mod?.hotVaryFor, (n) => req.headers[n] as string | undefined);
411
+ const key = hotKey(q, cookieHeader, dims);
412
+ const entry = hotCache.get(key);
413
+ if (!entry) return false;
414
+ const age = Date.now() - entry.fetchedAt;
415
+ if (age > HOT_TTL && !entry.refreshing) {
416
+ entry.refreshing = true;
417
+ warmHot(q, cookieHeader, dims, names);
418
+ }
419
+ res.statusCode = entry.status ?? 200;
420
+ for (const k in entry.headers) res.setHeader(k, entry.headers[k]);
421
+ // The memoized headers come from the edge response, which carries the app's
422
+ // own dimensions but never the server's negotiation dimension - the dynamic
423
+ // path adds it to `res` after the fact, so a hot-cache replay used to serve
424
+ // bytes negotiated on Accept-Encoding while telling caches the response does
425
+ // not vary by it. Merge it back in here.
426
+ addVary(res, 'Accept-Encoding');
427
+ res.setHeader('ETag', entry.etag);
428
+ if (ifNoneMatch(req.headers['if-none-match'], entry.etag)) {
429
+ res.statusCode = 304;
430
+ res.end();
431
+ writeAccessLine(req, res, 0);
432
+ return true;
433
+ }
434
+ const enc = pickEncoding(entry.raw, String(req.headers['accept-encoding'] || ''));
435
+ if (enc) {
436
+ res.setHeader('Content-Encoding', enc);
437
+ const body = await encodedBodyAsync(entry, enc);
438
+ res.end(body);
439
+ writeAccessLine(req, res, body.length);
440
+ } else {
441
+ res.end(entry.raw);
442
+ writeAccessLine(req, res, entry.raw.length);
443
+ }
444
+ return true;
445
+ }
446
+
447
+ function cacheable(status: number): boolean {
448
+ return status === 200 || status === 404;
449
+ }
450
+
451
+ function fillHot(pathname: string, cookieHeader: string, dims: string[], names: string[], response: Response, memo: { raw: Buffer; br?: Buffer; gzip?: Buffer }, etag: string, gen: number): void {
452
+ // The render behind this response may predate an invalidation that landed
453
+ // while it ran (the request path awaits the body and the compression before
454
+ // storing). Stale bytes must not enter the cache it was just dropped from.
455
+ if (gen !== hotGen) return;
456
+ if (!cacheable(response.status)) return;
457
+ const csp = response.headers.get('content-security-policy') || '';
458
+ if (csp.includes('nonce-')) return;
459
+ // The declared cacheability contract (BUG.md AB): a response that says it
460
+ // must not be stored, or that varies on a dimension the key does not carry,
461
+ // never enters the shared store.
462
+ if (refusesStore(response, names)) return;
463
+ if (response.status === 404 && hotCache.size >= HOT_CACHE_MAX) return;
464
+ // set-cookie is a list header: forEach delivers it per value, so a plain
465
+ // object assignment keeps only the last one. Capture the whole array and
466
+ // store it under the name node's setHeader replays as multiple headers.
467
+ const head: Record<string, string | string[]> = {};
468
+ const cookies = response.headers.getSetCookie?.() ?? [];
469
+ response.headers.forEach((v, k) => { if (k !== 'content-length' && k !== 'date' && k !== 'content-encoding') { if (cookies.length && k.toLowerCase() === 'set-cookie') return; head[k] = v; } });
470
+ if (cookies.length) head['set-cookie'] = cookies;
471
+ // AC (BUG.md): a Set-Cookie replayed from the anonymous (empty-cookie) key
472
+ // is a cross-visitor leak - session fixation, or a shared, readable CSRF
473
+ // token. Set-Cookie is per-request state; the shared slot must not carry it.
474
+ // Authenticated keys already isolate by cookie, so only the anonymous slot
475
+ // is refused (the conservative variant the report proposed as the fallback).
476
+ if (!cookieHeader && cookies.length) return;
477
+ hotCacheEvict();
478
+ hotCache.set(hotKey(pathname, cookieHeader, dims), { ...memo, headers: head, etag, fetchedAt: Date.now(), status: response.status });
479
+ }
480
+
481
+ const fileCache = new Map<string, { filePath: string; raw: Buffer; br?: Buffer; gzip?: Buffer; compressed: boolean; etag: string; mtimeMs: number; checkedAt: number }>();
482
+ // Bounded re-stat window: the per-request statSync is a cache-invalidation
483
+ // probe only (bytes always come from the in-memory entry), and the freshness
484
+ // it guards is already bounded by the headers themselves - chunks/?v= assets
485
+ // are immutable, uploads carry max-age=3600, entry docs change only on a
486
+ // deploy that restarts the process. See the deployment repo's prod-server.ts
487
+ // for the full account and tools/static-hot-bench.mts for the measurement.
488
+ const STATIC_RESTAT_MS = 2_000;
489
+ const TYPES: Record<string, string> = {
490
+ '.html': 'text/html; charset=utf-8',
491
+ '.js': 'application/javascript; charset=utf-8',
492
+ '.css': 'text/css; charset=utf-8',
493
+ '.json': 'application/json; charset=utf-8',
494
+ '.svg': 'image/svg+xml',
495
+ '.png': 'image/png',
496
+ '.jpg': 'image/jpeg',
497
+ '.webp': 'image/webp',
498
+ '.ico': 'image/x-icon',
499
+ '.woff2': 'font/woff2',
500
+ '.txt': 'text/plain; charset=utf-8',
501
+ '.xml': 'application/xml',
502
+ '.webmanifest': 'application/manifest+json',
503
+ };
504
+
505
+ try {
506
+ const raw = fs.readFileSync(new URL('../.env', import.meta.url), 'utf-8');
507
+ for (const line of raw.split(/\r?\n/)) {
508
+ const t = line.trim();
509
+ if (!t || t.startsWith('#')) continue;
510
+ const eq = t.indexOf('=');
511
+ if (eq <= 0) continue;
512
+ const k = t.slice(0, eq).trim();
513
+ let v = t.slice(eq + 1).trim();
514
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
515
+ if (!(k in process.env)) process.env[k] = v;
516
+ }
517
+ } catch { /* no .env: fine */ }
518
+ const REVALIDATE_TOKEN = process.env.ROSEFC_REVALIDATE_TOKEN || '';
519
+ let revalidateMod: any = null;
520
+
521
+ function handleRevalidate(req: http.IncomingMessage, res: http.ServerResponse): boolean {
522
+ if (req.method !== 'POST' || !req.url!.startsWith('/__rosefc/revalidate')) return false;
523
+ if (!REVALIDATE_TOKEN) { res.statusCode = 404; res.end(); return true; }
524
+ if (req.headers['x-rosefc-token'] !== REVALIDATE_TOKEN) {
525
+ res.statusCode = 403; res.setHeader('content-type', 'application/json');
526
+ res.end(JSON.stringify({ error: 'invalid token' })); return true;
527
+ }
528
+ let body = '';
529
+ req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
530
+ req.on('end', () => {
531
+ try {
532
+ if (!revalidateMod) revalidateMod = mod;
533
+ const payload = JSON.parse(body || '{}');
534
+ // The endpoint short-circuits before the handler's non-GET branch, so it
535
+ // owes its own invalidation - and it used to drop only the render memo,
536
+ // leaving the hot cache serving the pre-revalidation bytes for a whole
537
+ // TTL. Both caches go, per path or all at once: the endpoint is an
538
+ // explicit admin action and the hot cache repopulates on the next
539
+ // requests.
540
+ if (payload.all) {
541
+ invalidateHot();
542
+ res.end(JSON.stringify({ revalidated: true, scope: 'all' }));
543
+ } else {
544
+ let n = 0;
545
+ for (const p of payload.paths || []) n += revalidateMod.invalidatePath(String(p));
546
+ invalidateHot();
547
+ res.end(JSON.stringify({ revalidated: true, paths: (payload.paths || []).length, dropped: n }));
548
+ }
549
+ } catch (e: any) {
550
+ res.statusCode = 400; res.end(JSON.stringify({ error: String(e).slice(0, 120) }));
551
+ }
552
+ });
553
+ return true;
554
+ }
555
+
556
+ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, pathname: string | null, rawUrl: string): boolean {
557
+ if (pathname === null) return false;
558
+ if (pathname.includes('\0') || pathname.includes('..')) return false;
559
+ const ext = path.extname(pathname).toLowerCase();
560
+ if (!TYPES[ext]) return false;
561
+ let entry = fileCache.get(pathname);
562
+ let st: fs.Stats | null = null;
563
+ let valid = false;
564
+ if (entry) {
565
+ if (Date.now() - entry.checkedAt < STATIC_RESTAT_MS) {
566
+ valid = true;
567
+ } else {
568
+ entry.checkedAt = Date.now();
569
+ try {
570
+ const s = fs.statSync(entry.filePath);
571
+ if (s.isFile() && s.mtimeMs === entry.mtimeMs) { st = s; valid = true; }
572
+ } catch {
573
+ // moved or deleted since it was cached: re-resolve below
574
+ }
575
+ }
576
+ }
577
+ if (!valid) {
578
+ let filePath = '';
579
+ for (const root of resolveStaticRoots()) {
580
+ const candidate = path.join(root, pathname);
581
+ if (!candidate.startsWith(root)) continue;
582
+ try {
583
+ const s = fs.statSync(candidate);
584
+ if (!s.isFile()) continue;
585
+ filePath = candidate;
586
+ st = s;
587
+ break;
588
+ } catch {
589
+ // not under this root; try the next one
590
+ }
591
+ }
592
+ if (!st) return false;
593
+ const raw = fs.readFileSync(filePath);
594
+ const compressed = raw.length > 1024 && !NO_COMPRESS_EXT.has(ext);
595
+ entry = {
596
+ filePath,
597
+ raw,
598
+ br: compressed ? (prebuiltBro(filePath, st.mtimeMs) ?? undefined) : undefined,
599
+ gzip: undefined,
600
+ compressed,
601
+ etag: `W/"${st.size}-${st.mtimeMs}"`,
602
+ mtimeMs: st.mtimeMs,
603
+ checkedAt: Date.now(),
604
+ };
605
+ fileCache.set(pathname, entry);
606
+ if (fileCache.size > 500) fileCache.delete(fileCache.keys().next().value as string);
607
+ }
608
+ res.statusCode = 200;
609
+ res.setHeader('Content-Type', TYPES[ext]);
610
+ res.setHeader('ETag', entry.etag);
611
+ addVary(res, 'Accept-Encoding');
612
+ const isEntryDoc = pathname === '/' || pathname.endsWith('/') || TYPES[ext].startsWith('text/html');
613
+ const immutableAsset =
614
+ pathname.startsWith('/chunks/') ||
615
+ (pathname.startsWith('/public/') && rawUrl.includes('?v='));
616
+ res.setHeader(
617
+ 'Cache-Control',
618
+ isEntryDoc
619
+ ? 'public, max-age=0, must-revalidate'
620
+ : immutableAsset
621
+ ? 'public, max-age=31536000, immutable'
622
+ : 'public, max-age=3600',
623
+ );
624
+ if (ifNoneMatch(req.headers['if-none-match'], entry.etag)) {
625
+ res.statusCode = 304;
626
+ res.end();
627
+ writeAccessLine(req, res, 0);
628
+ return true;
629
+ }
630
+ const accept = String(req.headers['accept-encoding'] || '');
631
+ const enc = entry.compressed ? pickEncoding(entry.raw, accept) : null;
632
+ if (enc) {
633
+ res.setHeader('Content-Encoding', enc);
634
+ const body = encodedBody(entry, enc);
635
+ res.end(body);
636
+ writeAccessLine(req, res, body.length);
637
+ } else {
638
+ res.end(entry.raw);
639
+ writeAccessLine(req, res, entry.raw.length);
640
+ }
641
+ return true;
642
+ }
643
+
644
+ const server = http.createServer(async (req, res) => {
645
+ // The generation this request's renders belong to: captured before handle()
646
+ // runs, so a store at the end of the request can tell whether an invalidation
647
+ // landed while it was rendering.
648
+ const reqGen = hotGen;
649
+ try {
650
+ await ready;
651
+ if (handleRevalidate(req, res)) return;
652
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
653
+ // The invalidation moves to the response's finish (or the client's
654
+ // disconnect): the app's mutation commits INSIDE handle() below, so a
655
+ // clear at the top of the handler ran BEFORE the write it exists to
656
+ // publish - and a warm render starting in between stored pre-mutation
657
+ // bytes into the already-cleared cache. Until this response finishes, a
658
+ // concurrent GET keeps being served the previous bytes, which is exactly
659
+ // right for a mutation that has not committed yet; the moment it has,
660
+ // everything drops and the next render is fresh. Both events are guarded
661
+ // by one flag (close always follows finish), and every path that ends the
662
+ // response - including the catch's 500 - fires one of them.
663
+ let invalidated = false;
664
+ const invalidateOnce = () => {
665
+ if (invalidated) return;
666
+ invalidated = true;
667
+ invalidateHot();
668
+ };
669
+ res.on('finish', invalidateOnce);
670
+ res.on('close', invalidateOnce);
671
+ }
672
+ if (hotCache.size > 0 && (await tryHot(req, res))) return;
673
+ if (req.method === 'GET' && !req.headers.cookie && hotCache.size === 0) {
674
+ for (const r of warmList) warmHot(r, '', [''], hotVaryNames(r));
675
+ }
676
+ if (req.method === 'GET' || req.method === 'HEAD') {
677
+ const rawUrl = relTarget(req.url || '/');
678
+ let pathname: string | null = null;
679
+ try {
680
+ pathname = decodeURIComponent(new URL('http://localhost' + rawUrl).pathname);
681
+ } catch {
682
+ pathname = null;
683
+ }
684
+ if (serveStatic(req, res, pathname, rawUrl)) return;
685
+ const p = pathname ?? rawUrl.split('?')[0]!;
686
+ if (isBakedAssetPath(p)) {
687
+ res.statusCode = 404;
688
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
689
+ res.setHeader('Cache-Control', 'public, max-age=60');
690
+ res.end('not found');
691
+ writeAccessLine(req, res, 0);
692
+ return;
693
+ }
694
+ }
695
+ const url = requestOrigin(req, port) + relTarget(req.url || '/');
696
+ const init: RequestInit = { method: req.method, headers: req.headers as any };
697
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
698
+ (init as any).body = Readable.toWeb(req) as any;
699
+ (init as any).duplex = 'half';
700
+ }
701
+ const cookieHeader = String(req.headers.cookie || '');
702
+ // The negotiated dimensions are part of the representation's identity:
703
+ // they name the hot-cache key below and the warm render that fills it.
704
+ // accept-language always, plus whatever the route declared with hotVary.
705
+ const hotTarget = relTarget(req.url || '/');
706
+ const names = hotVaryNames(hotTarget);
707
+ const dims = hotVaryValues(hotTarget.split('?')[0]!, mod?.hotVaryFor, (n) => req.headers[n] as string | undefined);
708
+ if (earlyHintLinks.length && req.method === 'GET' && String(req.headers.accept ?? '').includes('text/html')) {
709
+ try { res.writeEarlyHints({ link: earlyHintLinks }); } catch { /* client gone: the render still finishes */ }
710
+ }
711
+ const replayable = req.method === 'GET' || req.method === 'HEAD';
712
+ let response: Response;
713
+ try {
714
+ response = await runIsolated(() => handle!(new Request(url, init)));
715
+ } catch (err) {
716
+ if (!replayable) throw err;
717
+ console.warn('[rosefc] render failed, retrying once:', (err as Error)?.message ?? err);
718
+ await new Promise((r) => setTimeout(r, 200));
719
+ response = await runIsolated(() => handle!(new Request(url, init)));
720
+ }
721
+ res.statusCode = response.status;
722
+ // set-cookie is a list header: forEach delivers it per value and setHeader
723
+ // replaces, so a multi-cookie response (session + csrf, say) would keep
724
+ // only the last one. Emit the whole array through node's native
725
+ // multi-value form instead.
726
+ const setCookies = response.headers.getSetCookie?.() ?? [];
727
+ response.headers.forEach((v, k) => { if (setCookies.length && k.toLowerCase() === 'set-cookie') return; res.setHeader(k, v); });
728
+ if (setCookies.length) res.setHeader('set-cookie', setCookies);
729
+ // The bytes below are content-negotiated on Accept-Encoding, so a cache
730
+ // must know the response varies by it. Merged, not written: the edge
731
+ // response may carry the app's own dimensions (an i18n redirect varies on
732
+ // Accept-Language) and setHeader would drop them.
733
+ addVary(res, 'Accept-Encoding');
734
+ const htmlDoc = (response.headers.get('content-type') ?? '').startsWith('text/html');
735
+ const accept = String(req.headers['accept-encoding'] ?? '');
736
+ const raw = response.body ? Buffer.from(await response.arrayBuffer()) : null;
737
+ if (raw) {
738
+ // AM (BUG.md): the validator is no longer HTML-only. A feed, a sitemap,
739
+ // a manifest and a JSON API are documents a client revalidates too - an
740
+ // RSS reader polling every five minutes used to re-transfer the whole
741
+ // body every time because the response it cached carried no validator.
742
+ // It is also content-derived rather than length-derived: an anonymous
743
+ // read's cookie is empty, so `len-fnv(cookie)` only moved with the byte
744
+ // count and handed two equal-length, different-content representations
745
+ // the same tag - a false 304 that kept a client on stale bytes until the
746
+ // length happened to change. An app that publishes its own ETag keeps it.
747
+ const etag = responseEtag(response, raw);
748
+ // RFC 9110: a 304 must carry the validators a 200 would have carried.
749
+ res.setHeader('ETag', etag);
750
+ if (req.method === 'GET' && ifNoneMatch(req.headers['if-none-match'], etag)) {
751
+ res.statusCode = 304;
752
+ res.end();
753
+ writeAccessLine(req, res, 0);
754
+ return;
755
+ }
756
+ // Only an HTML document is compressed here: the rest are the small
757
+ // machine-read documents (a robots.txt, a JSON payload an agent polls)
758
+ // where the compress-on-miss cost outweighs the bytes saved.
759
+ if (htmlDoc) {
760
+ const memo: { raw: Buffer; br?: Buffer; gzip?: Buffer } = { raw };
761
+ const enc = pickEncoding(raw, accept);
762
+ const body = enc ? await encodedBodyAsync(memo, enc) : raw;
763
+ if (enc) {
764
+ res.setHeader('Content-Encoding', enc);
765
+ res.setHeader('Content-Length', String(body.length));
766
+ }
767
+ if (req.method === 'GET') fillHot(hotTarget, cookieHeader, dims, names, response, memo, etag, reqGen);
768
+ res.end(body);
769
+ writeAccessLine(req, res, body.length);
770
+ } else {
771
+ if (req.method === 'GET') fillHot(hotTarget, cookieHeader, dims, names, response, { raw }, etag, reqGen);
772
+ res.end(raw);
773
+ writeAccessLine(req, res, raw.length);
774
+ }
775
+ } else {
776
+ res.end();
777
+ writeAccessLine(req, res, 0);
778
+ }
779
+ } catch (err) {
780
+ console.error('[rosefc] request failed:', (err as Error)?.stack ?? err);
781
+ if (!res.headersSent) {
782
+ res.statusCode = 500;
783
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
784
+ }
785
+ res.end('server error');
786
+ writeAccessLine(req, res, 13);
787
+ }
788
+ });
789
+
790
+ if (!process.env.VERCEL) {
791
+ server.listen(port, () => {
792
+ console.log(`🌹 Rosefc prod server on http://localhost:${port}`);
793
+ });
794
+
795
+ let stopping = false;
796
+ const stop = (signal: NodeJS.Signals) => {
797
+ if (stopping) return;
798
+ stopping = true;
799
+ const force = setTimeout(() => process.exit(1), 10_000);
800
+ force.unref?.();
801
+ server.close(() => {
802
+ void runShutdownHooks(signal).finally(() => {
803
+ clearTimeout(force);
804
+ process.exit(0);
805
+ });
806
+ });
807
+ server.closeIdleConnections?.();
808
+ };
809
+ process.on('SIGINT', () => stop('SIGINT'));
810
+ process.on('SIGTERM', () => stop('SIGTERM'));
811
+ }
812
+
813
+ export default server;
814
+
815
+ export { onShutdown, runShutdownHooks };
816
+
817
+ void mod?.invalidateRenderCache;