@timber-js/app 0.2.0-alpha.163 → 0.2.0-alpha.164
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{actions-cjklt63G.js → actions-CSDD6x7U.js} +2 -2
- package/dist/_chunks/{actions-cjklt63G.js.map → actions-CSDD6x7U.js.map} +1 -1
- package/dist/_chunks/{cache-api-CzYUlgXA.js → cache-api-eb1gydM7.js} +41 -13
- package/dist/_chunks/cache-api-eb1gydM7.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js → cli-schema-sync-mGfRbjh2.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js.map → cli-schema-sync-mGfRbjh2.js.map} +1 -1
- package/dist/_chunks/{plugin-context-DeAxFRMq.js → plugin-context-BnaiU_cF.js} +37 -2
- package/dist/_chunks/plugin-context-BnaiU_cF.js.map +1 -0
- package/dist/_chunks/{walkers-9mz9T7mb.js → walkers-BL3MCMgO.js} +2 -2
- package/dist/_chunks/{walkers-9mz9T7mb.js.map → walkers-BL3MCMgO.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.d.ts +1 -0
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/nitro.d.ts +11 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +77 -64
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.d.ts +3 -0
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cache/redis-handler.d.ts +1 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/tag-aware-handler.d.ts +1 -0
- package/dist/cache/tag-aware-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/slot-context.d.ts +29 -0
- package/dist/client/slot-context.d.ts.map +1 -0
- package/dist/client/slot-outlet.d.ts +16 -0
- package/dist/client/slot-outlet.d.ts.map +1 -0
- package/dist/client/slot-provider.d.ts +20 -0
- package/dist/client/slot-provider.d.ts.map +1 -0
- package/dist/config-types.d.ts +2 -1
- package/dist/config-types.d.ts.map +1 -1
- package/dist/dev-tools/logs.d.ts.map +1 -1
- package/dist/index.js +293 -124
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts +27 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
- package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
- package/dist/plugins/prebuilt.d.ts.map +1 -1
- package/dist/plugins/prerender-sugar.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/html-injector-core.d.ts +30 -9
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +346 -51
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/prebuilt/cache-key.d.ts +4 -0
- package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
- package/dist/server/prebuilt/key-discipline.d.ts +23 -0
- package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
- package/dist/server/prebuilt/slots.d.ts +74 -0
- package/dist/server/prebuilt/slots.d.ts.map +1 -0
- package/dist/server/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/prebuilt-runtime.d.ts +12 -2
- package/dist/server/prebuilt-runtime.d.ts.map +1 -1
- package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
- package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/docs/api/34-api-config.mdx +165 -3
- package/docs/learn/00-introduction.mdx +78 -44
- package/docs/learn/13-configuration.mdx +27 -8
- package/package.json +3 -2
- package/src/adapters/cloudflare-kv-cache.ts +5 -1
- package/src/adapters/nitro.ts +82 -64
- package/src/cache/index.ts +25 -2
- package/src/cache/redis-handler.ts +27 -7
- package/src/cache/tag-aware-handler.ts +5 -1
- package/src/cache/timber-cache.ts +21 -10
- package/src/client/slot-context.ts +48 -0
- package/src/client/slot-outlet.tsx +22 -0
- package/src/client/slot-provider.tsx +25 -0
- package/src/config-types.ts +2 -1
- package/src/dev-tools/logs.ts +7 -0
- package/src/plugin-context.ts +54 -0
- package/src/plugins/cache.ts +1 -2
- package/src/plugins/client-chunks.ts +42 -1
- package/src/plugins/dev-server.ts +12 -69
- package/src/plugins/prebuilt-options-analysis.ts +175 -0
- package/src/plugins/prebuilt.ts +82 -127
- package/src/plugins/prerender-sugar.ts +1 -2
- package/src/server/html-injector-core.ts +85 -27
- package/src/server/html-injectors.ts +5 -1
- package/src/server/node-stream-transforms.ts +6 -1
- package/src/server/pipeline-phases.ts +4 -1
- package/src/server/prebuilt/cache-key.ts +74 -0
- package/src/server/prebuilt/key-discipline.ts +53 -0
- package/src/server/prebuilt/slots.ts +167 -0
- package/src/server/prebuilt-builder.ts +57 -23
- package/src/server/prebuilt-runtime.ts +144 -60
- package/src/server/rsc-entry/deny-fallback.ts +92 -0
- package/src/server/rsc-entry/index.ts +16 -70
- package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
- package/dist/_chunks/plugin-context-DeAxFRMq.js.map +0 -1
|
@@ -122,14 +122,33 @@ Server actions still work — HTML forms submit natively via POST without JavaSc
|
|
|
122
122
|
|
|
123
123
|
## All Options
|
|
124
124
|
|
|
125
|
-
| Option
|
|
126
|
-
|
|
|
127
|
-
| `output`
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `clientJavascript`
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
125
|
+
| Option | Type | Default | Description |
|
|
126
|
+
| ------------------- | --------------------------- | ---------------------------- | ---------------------------------------- |
|
|
127
|
+
| `output` | `'server' \| 'static'` | `'server'` | Output mode |
|
|
128
|
+
| `debug` | `boolean` | `false` | Enable timber debug logging in prod |
|
|
129
|
+
| `buildDir` | `string` | `'.timber/dist'` | Build output directory |
|
|
130
|
+
| `clientJavascript` | `boolean \| object` | `true` | Control client-side JS |
|
|
131
|
+
| `adapter` | `TimberPlatformAdapter` | — | Deployment adapter |
|
|
132
|
+
| `cacheHandler` | `CacheHandler` | `MemoryCacheHandler` | Cache backend |
|
|
133
|
+
| `cdnPurge` | `CdnPurgeHandler` | — | CDN purge on revalidation |
|
|
134
|
+
| `serverTiming` | `'detailed' \| 'total' \| false` | `'detailed'` / `'total'` | Server-Timing header |
|
|
135
|
+
| `allowedOrigins` | `string[]` | — | CORS / CSRF allowed origins |
|
|
136
|
+
| `csrf` | `boolean` | `true` | CSRF protection |
|
|
137
|
+
| `limits` | `object` | — | Request body size limits |
|
|
138
|
+
| `actions` | `object` | — | Server action behavior |
|
|
139
|
+
| `forms` | `object` | — | Form handling (sensitive field stripping) |
|
|
140
|
+
| `pageExtensions` | `string[]` | `['tsx', 'ts', 'jsx', 'js']` | File extensions for pages |
|
|
141
|
+
| `slowRequestMs` | `number` | `3000` | Slow request warning threshold (ms) |
|
|
142
|
+
| `renderTimeoutMs` | `number` | `30000` | Render abort timeout (ms) |
|
|
143
|
+
| `devBrowserLogs` | `string` | `'warn'` | Forward browser console to server in dev |
|
|
144
|
+
| `dev` | `object` | — | Dev-mode options |
|
|
145
|
+
| `budget` | `object` | — | Build-time performance budgets |
|
|
146
|
+
| `appDir` | `string` | auto-detected | Override app directory location |
|
|
147
|
+
| `mdx` | `object` | — | MDX remark/rehype plugins |
|
|
148
|
+
| `actionEncryption` | `object` | — | Server action bound args encryption |
|
|
149
|
+
| `reactCompiler` | `boolean \| object` | `false` | React Compiler auto-memoization |
|
|
150
|
+
| `sitemap` | `object` | — | Auto-generated sitemap.xml |
|
|
151
|
+
| `topLoader` | `object` | enabled | Navigation progress bar |
|
|
133
152
|
|
|
134
153
|
For the full type definition, see the [Config API Reference](/docs/api-config).
|
|
135
154
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.164",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -156,7 +156,8 @@
|
|
|
156
156
|
"@opentelemetry/sdk-trace-base": "^2.8.0",
|
|
157
157
|
"cookie": "^1.1.1",
|
|
158
158
|
"magic-string": "^0.30.21",
|
|
159
|
-
"nitro": "3.0.260610-beta"
|
|
159
|
+
"nitro": "3.0.260610-beta",
|
|
160
|
+
"srvx": "^0.11.17"
|
|
160
161
|
},
|
|
161
162
|
"peerDependencies": {
|
|
162
163
|
"@content-collections/core": "^0.14.0 || ^0.15.0",
|
|
@@ -112,7 +112,11 @@ export class CloudflareKVCacheHandler implements CacheHandler {
|
|
|
112
112
|
return { value: entry.value, stale };
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
async set(
|
|
115
|
+
async set(
|
|
116
|
+
key: string,
|
|
117
|
+
value: unknown,
|
|
118
|
+
opts: { ttl: number; tags: string[]; generation?: number }
|
|
119
|
+
): Promise<void> {
|
|
116
120
|
const kv = this.getKV();
|
|
117
121
|
const entry: KVCacheEntry = {
|
|
118
122
|
value,
|
package/src/adapters/nitro.ts
CHANGED
|
@@ -195,11 +195,14 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
|
|
|
195
195
|
publicDirName: 'public',
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
-
// Write
|
|
198
|
+
// Write runtime helper modules used by the preview server.
|
|
199
199
|
// See design/25-production-deployments.md — self-hosted deployments
|
|
200
200
|
// need application-level compression (Cloudflare handles it at the edge).
|
|
201
201
|
await writeFile(join(outDir, '_compress.mjs'), await generateCompressModule());
|
|
202
202
|
|
|
203
|
+
// Web→Node response bridge with backpressure. See TIM-1154.
|
|
204
|
+
await writeFile(join(outDir, '_send-response.mjs'), generateSendResponseModule());
|
|
205
|
+
|
|
203
206
|
// Prepend the manifest assignment directly into the RSC entry so
|
|
204
207
|
// globalThis.__TIMBER_BUILD_MANIFEST__ is set before any module reads it.
|
|
205
208
|
// This must be top-level code, not an import, because rollup tree-shakes
|
|
@@ -378,6 +381,9 @@ const { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}'
|
|
|
378
381
|
// Import compression helper for self-hosted response compression.
|
|
379
382
|
const { compressResponse } = await import('./_compress.mjs');
|
|
380
383
|
|
|
384
|
+
// Web→Node response bridge with backpressure (TIM-1154).
|
|
385
|
+
const { sendNodeResponse } = await import('./_send-response.mjs');
|
|
386
|
+
|
|
381
387
|
const MIME_TYPES = {
|
|
382
388
|
'.html': 'text/html',
|
|
383
389
|
'.js': 'application/javascript',
|
|
@@ -414,7 +420,7 @@ const publicDir = join(__dirname, '${publicDir}');
|
|
|
414
420
|
const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : null;
|
|
415
421
|
const portIsExplicit = envPort != null && Number.isFinite(envPort) && envPort > 0;
|
|
416
422
|
const startPort = portIsExplicit ? envPort : 3000;
|
|
417
|
-
const host = process.env.HOST ||
|
|
423
|
+
const host = process.env.HOST || 'localhost';
|
|
418
424
|
|
|
419
425
|
// Set after listenWithBump() resolves so request handlers can build
|
|
420
426
|
// absolute URLs from the actual bound port (which may differ from
|
|
@@ -501,68 +507,11 @@ const server = createServer(async (req, res) => {
|
|
|
501
507
|
// Compress the response for self-hosted deployments.
|
|
502
508
|
const webResponse = compressResponse(webRequest, rawResponse);
|
|
503
509
|
|
|
504
|
-
// Write the response
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
// collapse them into one malformed Set-Cookie header that browsers reject
|
|
510
|
-
// (or only honor partially). Use getSetCookie() to preserve individual
|
|
511
|
-
// values, and pass them as an array — Node's writeHead accepts
|
|
512
|
-
// Record<string, string | string[]> and emits one header per array entry.
|
|
513
|
-
//
|
|
514
|
-
// Without this, EVERY cookie set by the framework on Node—/Nitro deployments
|
|
515
|
-
// is silently dropped: defineCookie().setCookie() in actions, middleware
|
|
516
|
-
// cookie writes, the framework's own session cookies, all of it. See
|
|
517
|
-
// LOCAL-741.
|
|
518
|
-
const responseHeaders = {};
|
|
519
|
-
webResponse.headers.forEach((value, key) => {
|
|
520
|
-
if (key.toLowerCase() !== 'set-cookie') {
|
|
521
|
-
responseHeaders[key] = value;
|
|
522
|
-
}
|
|
523
|
-
});
|
|
524
|
-
const setCookies = webResponse.headers.getSetCookie();
|
|
525
|
-
if (setCookies.length > 0) {
|
|
526
|
-
responseHeaders['set-cookie'] = setCookies;
|
|
527
|
-
}
|
|
528
|
-
res.writeHead(webResponse.status, responseHeaders);
|
|
529
|
-
|
|
530
|
-
if (webResponse.body) {
|
|
531
|
-
const reader = webResponse.body.getReader();
|
|
532
|
-
|
|
533
|
-
// Cancel the reader when the client disconnects. This causes any
|
|
534
|
-
// pending reader.read() to reject, breaking the pump loop. Critical
|
|
535
|
-
// for SSE and other infinite streams — without this, disconnected
|
|
536
|
-
// clients leak readers.
|
|
537
|
-
let clientDisconnected = false;
|
|
538
|
-
const onClose = () => {
|
|
539
|
-
clientDisconnected = true;
|
|
540
|
-
reader.cancel('Client disconnected').catch(() => {});
|
|
541
|
-
};
|
|
542
|
-
res.on('close', onClose);
|
|
543
|
-
|
|
544
|
-
try {
|
|
545
|
-
while (true) {
|
|
546
|
-
const { done, value } = await reader.read();
|
|
547
|
-
if (done) break;
|
|
548
|
-
res.write(value);
|
|
549
|
-
}
|
|
550
|
-
} catch (err) {
|
|
551
|
-
// reader.cancel() from the close handler causes read() to reject.
|
|
552
|
-
// This is expected on client disconnect — not an error.
|
|
553
|
-
if (!clientDisconnected) {
|
|
554
|
-
throw err;
|
|
555
|
-
}
|
|
556
|
-
} finally {
|
|
557
|
-
res.off('close', onClose);
|
|
558
|
-
reader.releaseLock();
|
|
559
|
-
if (!res.writableEnded) {
|
|
560
|
-
res.end();
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
} else {
|
|
564
|
-
res.end();
|
|
565
|
-
}
|
|
510
|
+
// Write the response to Node's ServerResponse. sendNodeResponse handles
|
|
511
|
+
// status, headers (including Set-Cookie splitting via [...headers]),
|
|
512
|
+
// body streaming with backpressure, and client disconnect cleanup.
|
|
513
|
+
// See TIM-1154.
|
|
514
|
+
await sendNodeResponse(res, webResponse);
|
|
566
515
|
} catch (err) {
|
|
567
516
|
console.error('[timber preview] Request error:', err);
|
|
568
517
|
if (!res.headersSent) {
|
|
@@ -702,6 +651,75 @@ function spawnNitroPreview(command: string, args: string[], cwd: string): Promis
|
|
|
702
651
|
});
|
|
703
652
|
}
|
|
704
653
|
|
|
654
|
+
// ─── Send Response Module ───────────────────────────────────────────────────
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Generate a standalone ESM module that exports sendNodeResponse.
|
|
658
|
+
*
|
|
659
|
+
* Mirrors srvx/node's sendNodeResponse: status + headers via writeHead,
|
|
660
|
+
* body streaming with backpressure (waits for drain on write() === false),
|
|
661
|
+
* and client disconnect cleanup. Written to `_send-response.mjs` during
|
|
662
|
+
* buildOutput for use by the preview server script. See TIM-1154.
|
|
663
|
+
*
|
|
664
|
+
* @internal Exported for testing.
|
|
665
|
+
*/
|
|
666
|
+
export function generateSendResponseModule(): string {
|
|
667
|
+
return `// Generated by @timber-js/app — Web→Node response bridge.
|
|
668
|
+
// Do not edit — this file is regenerated on each build.
|
|
669
|
+
// Mirrors srvx/node's sendNodeResponse with backpressure support.
|
|
670
|
+
|
|
671
|
+
export function sendNodeResponse(nodeRes, webRes) {
|
|
672
|
+
if (!webRes) {
|
|
673
|
+
nodeRes.statusCode = 500;
|
|
674
|
+
return new Promise((resolve) => nodeRes.end(resolve));
|
|
675
|
+
}
|
|
676
|
+
const rawHeaders = [...webRes.headers];
|
|
677
|
+
const writeHeaders = rawHeaders.flat();
|
|
678
|
+
if (!nodeRes.headersSent) {
|
|
679
|
+
if (nodeRes.req?.httpVersion === '2.0') {
|
|
680
|
+
nodeRes.writeHead(webRes.status, writeHeaders);
|
|
681
|
+
} else {
|
|
682
|
+
nodeRes.writeHead(webRes.status, webRes.statusText, writeHeaders);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (!webRes.body) {
|
|
686
|
+
return new Promise((resolve) => nodeRes.end(resolve));
|
|
687
|
+
}
|
|
688
|
+
// Stream with backpressure: pause reading when the kernel buffer is
|
|
689
|
+
// full (write() returns false) and resume on 'drain'.
|
|
690
|
+
if (nodeRes.destroyed) {
|
|
691
|
+
webRes.body.cancel();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const reader = webRes.body.getReader();
|
|
695
|
+
function streamCancel(error) {
|
|
696
|
+
reader.cancel(error).catch(() => {});
|
|
697
|
+
if (error) nodeRes.destroy(error);
|
|
698
|
+
}
|
|
699
|
+
function streamHandle({ done, value }) {
|
|
700
|
+
try {
|
|
701
|
+
if (done) {
|
|
702
|
+
nodeRes.end();
|
|
703
|
+
} else if (nodeRes.write(value)) {
|
|
704
|
+
reader.read().then(streamHandle, streamCancel);
|
|
705
|
+
} else {
|
|
706
|
+
nodeRes.once('drain', () => reader.read().then(streamHandle, streamCancel));
|
|
707
|
+
}
|
|
708
|
+
} catch (error) {
|
|
709
|
+
streamCancel(error instanceof Error ? error : undefined);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
nodeRes.on('close', streamCancel);
|
|
713
|
+
nodeRes.on('error', streamCancel);
|
|
714
|
+
reader.read().then(streamHandle, streamCancel);
|
|
715
|
+
return reader.closed.catch(streamCancel).finally(() => {
|
|
716
|
+
nodeRes.off('close', streamCancel);
|
|
717
|
+
nodeRes.off('error', streamCancel);
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
`;
|
|
721
|
+
}
|
|
722
|
+
|
|
705
723
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
706
724
|
|
|
707
725
|
/**
|
package/src/cache/index.ts
CHANGED
|
@@ -4,7 +4,11 @@ import { estimateByteSize } from './sizeof.js';
|
|
|
4
4
|
|
|
5
5
|
export interface CacheHandler {
|
|
6
6
|
get(key: string): Promise<{ value: unknown; stale: boolean } | null>;
|
|
7
|
-
set(
|
|
7
|
+
set(
|
|
8
|
+
key: string,
|
|
9
|
+
value: unknown,
|
|
10
|
+
opts: { ttl: number; tags: string[]; generation?: number }
|
|
11
|
+
): Promise<void>;
|
|
8
12
|
invalidate(opts: { key?: string; tag?: string }): Promise<void>;
|
|
9
13
|
}
|
|
10
14
|
|
|
@@ -38,6 +42,7 @@ export class MemoryCacheHandler implements CacheHandler {
|
|
|
38
42
|
string,
|
|
39
43
|
{ value: unknown; expiresAt: number; tags: string[]; byteSize: number }
|
|
40
44
|
>();
|
|
45
|
+
private generations = new Map<string, number>();
|
|
41
46
|
private maxEntries: number;
|
|
42
47
|
private maxBytes: number | undefined;
|
|
43
48
|
private maxEntryBytes: number | undefined;
|
|
@@ -64,7 +69,17 @@ export class MemoryCacheHandler implements CacheHandler {
|
|
|
64
69
|
return { value: entry.value, stale };
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
async set(
|
|
72
|
+
async set(
|
|
73
|
+
key: string,
|
|
74
|
+
value: unknown,
|
|
75
|
+
opts: { ttl: number; tags: string[]; generation?: number }
|
|
76
|
+
) {
|
|
77
|
+
// CAS guard: skip write if a newer generation has already been written
|
|
78
|
+
if (opts.generation !== undefined) {
|
|
79
|
+
const current = this.generations.get(key) ?? 0;
|
|
80
|
+
if (opts.generation < current) return;
|
|
81
|
+
}
|
|
82
|
+
|
|
68
83
|
const byteSize = this._trackBytes ? estimateByteSize(value) : 0;
|
|
69
84
|
|
|
70
85
|
// Reject entries exceeding per-entry byte limit
|
|
@@ -95,6 +110,11 @@ export class MemoryCacheHandler implements CacheHandler {
|
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
112
|
|
|
113
|
+
// Record generation only after all admission checks pass
|
|
114
|
+
if (opts.generation !== undefined) {
|
|
115
|
+
this.generations.set(key, opts.generation);
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
this.store.set(key, {
|
|
99
119
|
value,
|
|
100
120
|
expiresAt: Date.now() + opts.ttl * 1000,
|
|
@@ -111,12 +131,14 @@ export class MemoryCacheHandler implements CacheHandler {
|
|
|
111
131
|
this.currentBytes -= entry.byteSize;
|
|
112
132
|
this.store.delete(opts.key);
|
|
113
133
|
}
|
|
134
|
+
this.generations.delete(opts.key);
|
|
114
135
|
}
|
|
115
136
|
if (opts.tag) {
|
|
116
137
|
for (const [key, entry] of this.store) {
|
|
117
138
|
if (entry.tags.includes(opts.tag)) {
|
|
118
139
|
this.currentBytes -= entry.byteSize;
|
|
119
140
|
this.store.delete(key);
|
|
141
|
+
this.generations.delete(key);
|
|
120
142
|
}
|
|
121
143
|
}
|
|
122
144
|
}
|
|
@@ -139,6 +161,7 @@ export class MemoryCacheHandler implements CacheHandler {
|
|
|
139
161
|
const entry = this.store.get(oldest)!;
|
|
140
162
|
this.currentBytes -= entry.byteSize;
|
|
141
163
|
this.store.delete(oldest);
|
|
164
|
+
this.generations.delete(oldest);
|
|
142
165
|
}
|
|
143
166
|
}
|
|
144
167
|
}
|
|
@@ -141,7 +141,11 @@ export class RedisCacheHandler implements CacheHandler {
|
|
|
141
141
|
return { value: entry.value, stale };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
async set(
|
|
144
|
+
async set(
|
|
145
|
+
key: string,
|
|
146
|
+
value: unknown,
|
|
147
|
+
opts: { ttl: number; tags: string[]; generation?: number }
|
|
148
|
+
): Promise<void> {
|
|
145
149
|
const ck = this.cacheKey(key);
|
|
146
150
|
const expiresAt = Date.now() + opts.ttl * 1000;
|
|
147
151
|
const payload = JSON.stringify({ value, expiresAt, tags: opts.tags });
|
|
@@ -186,13 +190,29 @@ export class RedisCacheHandler implements CacheHandler {
|
|
|
186
190
|
const tk = this.tagKey(opts.tag);
|
|
187
191
|
const keys = await this.client.smembers(tk);
|
|
188
192
|
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
await Promise.all(
|
|
193
|
+
// Re-check each member before deleting — the tag set can contain stale
|
|
194
|
+
// memberships from entries whose tags changed since they were added.
|
|
195
|
+
// Mirrors TagAwareCacheHandler's eager strategy (tag-aware-handler.ts:183-198).
|
|
196
|
+
await Promise.all(
|
|
197
|
+
keys.map(async (k) => {
|
|
198
|
+
const raw = await this.client.get(this.cacheKey(k));
|
|
199
|
+
if (raw === null) {
|
|
200
|
+
await this.client.srem(tk, k);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const entry = JSON.parse(raw) as { tags?: string[] };
|
|
204
|
+
if (entry.tags?.includes(opts.tag!)) {
|
|
205
|
+
await this.client.del(this.cacheKey(k));
|
|
206
|
+
}
|
|
207
|
+
await this.client.srem(tk, k);
|
|
208
|
+
})
|
|
209
|
+
);
|
|
193
210
|
|
|
194
|
-
// Clean up the tag set
|
|
195
|
-
await this.client.
|
|
211
|
+
// Clean up the now-empty tag set so it doesn't consume memory
|
|
212
|
+
const remaining = await this.client.smembers(tk);
|
|
213
|
+
if (remaining.length === 0) {
|
|
214
|
+
await this.client.del(tk);
|
|
215
|
+
}
|
|
196
216
|
}
|
|
197
217
|
}
|
|
198
218
|
}
|
|
@@ -105,7 +105,11 @@ export class TagAwareCacheHandler implements CacheHandler {
|
|
|
105
105
|
return { value: entry.value, stale };
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
async set(
|
|
108
|
+
async set(
|
|
109
|
+
key: string,
|
|
110
|
+
value: unknown,
|
|
111
|
+
opts: { ttl: number; tags: string[]; generation?: number }
|
|
112
|
+
): Promise<void> {
|
|
109
113
|
const physicalTtl = Math.max(opts.ttl * 2 + 60, 120);
|
|
110
114
|
|
|
111
115
|
if (this.strategy === 'native') {
|
|
@@ -13,6 +13,22 @@ import { fnv1aHash } from './fast-hash.js';
|
|
|
13
13
|
|
|
14
14
|
let defaultSingleflight = createSingleflight();
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Per-key monotonic generation counter for CAS writes (TIM-1158).
|
|
18
|
+
*
|
|
19
|
+
* Unbounded, like the singleflight map — the key space is the same set of
|
|
20
|
+
* cache keys the handler already tracks. Pruning was removed because it
|
|
21
|
+
* desyncs from MemoryCacheHandler.generations: a pruned key restarts at 1
|
|
22
|
+
* while the handler still holds the old higher generation, causing fresh
|
|
23
|
+
* writes to be rejected as stale.
|
|
24
|
+
*/
|
|
25
|
+
const writeGenerations = new Map<string, number>();
|
|
26
|
+
function nextGeneration(key: string): number {
|
|
27
|
+
const gen = (writeGenerations.get(key) ?? 0) + 1;
|
|
28
|
+
writeGenerations.set(key, gen);
|
|
29
|
+
return gen;
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
/**
|
|
17
33
|
* Set the timeout for the module-level default singleflight.
|
|
18
34
|
* Called at framework boot with renderTimeoutMs from timber.config.ts so that
|
|
@@ -164,21 +180,16 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
164
180
|
* - the key/tags were invalidated after fn started (TIM-1028), or
|
|
165
181
|
* - the singleflight timed out (signal aborted) — the timed-out
|
|
166
182
|
* flight's write could overwrite a newer value from a subsequent
|
|
167
|
-
* flight (TIM-1092)
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* handler.set() is slow enough that the timeout fires during the
|
|
171
|
-
* set, the write lands with stale data. This window is narrow
|
|
172
|
-
* (requires set latency to straddle the timeout boundary) and
|
|
173
|
-
* self-healing (next cache miss triggers a fresh write). Fully
|
|
174
|
-
* closing it requires CAS/versioned writes on CacheHandler —
|
|
175
|
-
* tracked in TIM-1158.
|
|
183
|
+
* flight (TIM-1092), or
|
|
184
|
+
* - the handler rejects the write because a newer generation has
|
|
185
|
+
* already been stored (TIM-1158 CAS guard).
|
|
176
186
|
*/
|
|
177
187
|
const executeAndStore = async (signal: AbortSignal): Promise<Awaited<ReturnType<Fn>>> => {
|
|
178
188
|
const startEpoch = currentInvalidationEpoch();
|
|
189
|
+
const generation = nextGeneration(key);
|
|
179
190
|
const result = await fn(...args);
|
|
180
191
|
if (!signal.aborted && !wasInvalidatedSince(startEpoch, key, tags)) {
|
|
181
|
-
await getHandler().set(key, result, { ttl: opts.ttl, tags });
|
|
192
|
+
await getHandler().set(key, result, { ttl: opts.ttl, tags, generation });
|
|
182
193
|
}
|
|
183
194
|
return result;
|
|
184
195
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SlotContext — delivers live slot content to SlotOutlet holes inside
|
|
3
|
+
* cached component shells (TIM-1173).
|
|
4
|
+
*
|
|
5
|
+
* A `cache.component(Fn, { slots: [...] })` shell is captured with a
|
|
6
|
+
* stable <SlotOutlet slot={name} /> client reference in place of each
|
|
7
|
+
* declared slot prop. At request time the revived shell is wrapped in a
|
|
8
|
+
* SlotsProvider carrying the live slot values; each outlet reads its
|
|
9
|
+
* value from this context by name.
|
|
10
|
+
*
|
|
11
|
+
* The value is a per-instance record — the NEAREST provider wins, so two
|
|
12
|
+
* instances of the same cached component on one page each resolve their
|
|
13
|
+
* own children, and a slot component nested inside another slot
|
|
14
|
+
* component's children reads its own provider, not the outer one.
|
|
15
|
+
*
|
|
16
|
+
* Generalizes the ChildSegmentContext pattern (TIM-1181) from a single
|
|
17
|
+
* implicit `children` hole on layouts to explicitly declared, named slot
|
|
18
|
+
* props on any cached component.
|
|
19
|
+
*
|
|
20
|
+
* SINGLETON GUARANTEE: globalThis + Symbol.for — the RSC client bundler
|
|
21
|
+
* can duplicate this module across chunks; globalThis guarantees a single
|
|
22
|
+
* context instance. Same pattern as ChildSegmentContext.
|
|
23
|
+
*
|
|
24
|
+
* See design/45-cache-lifetimes.md §Slot Components.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use client';
|
|
28
|
+
|
|
29
|
+
import React, { type ReactNode } from 'react';
|
|
30
|
+
|
|
31
|
+
export type SlotValues = Record<string, ReactNode>;
|
|
32
|
+
|
|
33
|
+
const CTX_KEY = Symbol.for('__timber_slot_ctx');
|
|
34
|
+
|
|
35
|
+
function getOrCreateContext(): React.Context<SlotValues | null> {
|
|
36
|
+
const existing = (globalThis as Record<symbol, unknown>)[CTX_KEY] as
|
|
37
|
+
| React.Context<SlotValues | null>
|
|
38
|
+
| undefined;
|
|
39
|
+
if (existing !== undefined) return existing;
|
|
40
|
+
if (typeof React.createContext === 'function') {
|
|
41
|
+
const ctx = React.createContext<SlotValues | null>(null);
|
|
42
|
+
(globalThis as Record<symbol, unknown>)[CTX_KEY] = ctx;
|
|
43
|
+
return ctx;
|
|
44
|
+
}
|
|
45
|
+
return undefined as unknown as React.Context<SlotValues | null>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const SlotContext = getOrCreateContext();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SlotOutlet — the hole in a cached component shell (TIM-1173).
|
|
3
|
+
*
|
|
4
|
+
* At capture time the prebuilt runtime renders the wrapped component with
|
|
5
|
+
* <SlotOutlet slot={name} /> in place of each declared slot prop. Being a
|
|
6
|
+
* client component, it serializes into the flight payload as a stable,
|
|
7
|
+
* deterministic client reference — the same bytes regardless of what live
|
|
8
|
+
* content will fill the hole. At request time the revived shell sits under
|
|
9
|
+
* a SlotsProvider and each outlet reads its live value from SlotContext.
|
|
10
|
+
*
|
|
11
|
+
* See design/45-cache-lifetimes.md §Slot Components.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use client';
|
|
15
|
+
|
|
16
|
+
import { useContext } from 'react';
|
|
17
|
+
import { SlotContext } from './slot-context.js';
|
|
18
|
+
|
|
19
|
+
export function SlotOutlet({ slot }: { slot: string }) {
|
|
20
|
+
const values = useContext(SlotContext);
|
|
21
|
+
return values ? (values[slot] ?? null) : null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SlotsProvider — wraps a revived cached shell to supply live slot content
|
|
3
|
+
* to the SlotOutlet holes inside it (TIM-1173).
|
|
4
|
+
*
|
|
5
|
+
* Tree structure per cached-component instance:
|
|
6
|
+
* SlotsProvider(values={children: <Live />})
|
|
7
|
+
* └── revived shell
|
|
8
|
+
* └── SlotOutlet(slot="children") → reads context → <Live />
|
|
9
|
+
*
|
|
10
|
+
* See design/45-cache-lifetimes.md §Slot Components.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use client';
|
|
14
|
+
|
|
15
|
+
import { createElement, type ReactNode } from 'react';
|
|
16
|
+
import { SlotContext, type SlotValues } from './slot-context.js';
|
|
17
|
+
|
|
18
|
+
interface SlotsProviderProps {
|
|
19
|
+
values: SlotValues;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function SlotsProvider({ values, children }: SlotsProviderProps) {
|
|
24
|
+
return createElement(SlotContext.Provider, { value: values }, children);
|
|
25
|
+
}
|
package/src/config-types.ts
CHANGED
|
@@ -137,7 +137,8 @@ export interface TimberUserConfig {
|
|
|
137
137
|
* the connection is closed. Protects against hung fetches and Suspense
|
|
138
138
|
* components that never resolve.
|
|
139
139
|
*
|
|
140
|
-
*
|
|
140
|
+
* Setting 0 uses a 120-second safety ceiling rather than disabling
|
|
141
|
+
* the timeout entirely.
|
|
141
142
|
* Default: 30000 (30 seconds).
|
|
142
143
|
*
|
|
143
144
|
* See design/02-rendering-pipeline.md §"Streaming Constraints".
|
package/src/dev-tools/logs.ts
CHANGED
|
@@ -228,6 +228,13 @@ function patchConsole(server: ViteDevServer, projectRoot: string): () => void {
|
|
|
228
228
|
// Server runtime logs (render errors, action errors, etc.) are preserved.
|
|
229
229
|
if (isFrameworkInternalCaller()) return;
|
|
230
230
|
|
|
231
|
+
// Break the server→browser→server console forwarding loop. Vite's
|
|
232
|
+
// forwardConsole re-logs browser console output on the server via
|
|
233
|
+
// `logger.error("[console.error] ...")`. Our patch would send it back
|
|
234
|
+
// to the browser, Vite's client patch forwards it again → exponential
|
|
235
|
+
// log spam. Detect the `[console.<level>]` prefix Vite adds and skip.
|
|
236
|
+
if (typeof args[0] === 'string' && args[0].includes('[console.')) return;
|
|
237
|
+
|
|
231
238
|
// Serialize and forward to browser
|
|
232
239
|
try {
|
|
233
240
|
const payload: ServerLogPayload = {
|
package/src/plugin-context.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
13
13
|
import { createRequire } from 'node:module';
|
|
14
|
+
import { parseAst } from 'vite';
|
|
14
15
|
import type { RouteTree } from './routing/types';
|
|
15
16
|
import type { BuildManifest } from './server/build-manifest';
|
|
16
17
|
import type { CaptureSummary } from './server/prebuilt-builder';
|
|
@@ -18,6 +19,7 @@ import type { StartupTimer } from './utils/startup-timer';
|
|
|
18
19
|
import { createStartupTimer } from './utils/startup-timer';
|
|
19
20
|
import type { TimberUserConfig, ClientJavascriptConfig } from './config-types.js';
|
|
20
21
|
import type { HoldingServer } from './dev-tools/holding-server.js';
|
|
22
|
+
import type { ProgramNode } from './plugins/callsite-ast.js';
|
|
21
23
|
|
|
22
24
|
// Re-export for sub-plugin convenience — they import from plugin-context.ts
|
|
23
25
|
export type { TimberUserConfig, ClientJavascriptConfig } from './config-types.js';
|
|
@@ -147,6 +149,57 @@ export interface PluginContext {
|
|
|
147
149
|
prebuiltLazyChunks?: Map<string, string>;
|
|
148
150
|
/** Post-build prebuilt capture summary (populated by timber-prebuilt, consumed by build-report). */
|
|
149
151
|
captureSummary?: CaptureSummary;
|
|
152
|
+
/**
|
|
153
|
+
* Content-keyed AST parse memo shared across cache/prebuilt/prerender-sugar
|
|
154
|
+
* transforms. Collapses both cross-plugin and cross-environment duplicate
|
|
155
|
+
* parses: identical code hits, rewritten code misses (correct because Vite
|
|
156
|
+
* transforms chain — a rewrite produces different code). Bounded LRU (32
|
|
157
|
+
* entries) so it doesn't hold programs for the whole build.
|
|
158
|
+
*
|
|
159
|
+
* Consumers MUST treat the returned ProgramNode as read-only — they collect
|
|
160
|
+
* ranges and rewrite CODE via MagicString; the AST itself is never mutated.
|
|
161
|
+
*/
|
|
162
|
+
parseCached?: ParseMemo;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── AST parse memo ───────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
const PARSE_MEMO_CAPACITY = 32;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Content-keyed LRU memo for `parseAst`. Transforms for the same module run
|
|
171
|
+
* back-to-back across plugins and environments, so a tiny window captures the
|
|
172
|
+
* wins. The returned ProgramNode is shared — callers MUST NOT mutate it.
|
|
173
|
+
*/
|
|
174
|
+
export class ParseMemo {
|
|
175
|
+
private cache = new Map<string, ProgramNode>();
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parse code, returning a cached ProgramNode if the same code was parsed
|
|
179
|
+
* before. The returned AST MUST NOT be mutated — it may be shared across
|
|
180
|
+
* multiple consumers.
|
|
181
|
+
*/
|
|
182
|
+
parse(code: string): ProgramNode {
|
|
183
|
+
const existing = this.cache.get(code);
|
|
184
|
+
if (existing) {
|
|
185
|
+
// LRU refresh: move to end
|
|
186
|
+
this.cache.delete(code);
|
|
187
|
+
this.cache.set(code, existing);
|
|
188
|
+
return existing;
|
|
189
|
+
}
|
|
190
|
+
const program = parseAst(code, { lang: 'tsx' }) as unknown as ProgramNode;
|
|
191
|
+
if (this.cache.size >= PARSE_MEMO_CAPACITY) {
|
|
192
|
+
// Evict oldest (first inserted)
|
|
193
|
+
const firstKey = this.cache.keys().next().value!;
|
|
194
|
+
this.cache.delete(firstKey);
|
|
195
|
+
}
|
|
196
|
+
this.cache.set(code, program);
|
|
197
|
+
return program;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
get size(): number {
|
|
201
|
+
return this.cache.size;
|
|
202
|
+
}
|
|
150
203
|
}
|
|
151
204
|
|
|
152
205
|
// ── App directory resolution ──────────────────────────────────────────────
|
|
@@ -197,6 +250,7 @@ export function createPluginContext(config?: TimberUserConfig, root?: string): P
|
|
|
197
250
|
timer: createStartupTimer(),
|
|
198
251
|
holdingServer: null,
|
|
199
252
|
buildDir: resolveBuildDir(projectRoot, resolvedConfig.buildDir),
|
|
253
|
+
parseCached: new ParseMemo(),
|
|
200
254
|
};
|
|
201
255
|
}
|
|
202
256
|
|