@timber-js/app 0.2.0-alpha.3 → 0.2.0-alpha.5
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/{als-registry-k-AtAQ9R.js → als-registry-B7DbZ2hS.js} +1 -1
- package/dist/_chunks/{als-registry-k-AtAQ9R.js.map → als-registry-B7DbZ2hS.js.map} +1 -1
- package/dist/_chunks/debug-gwlJkDuf.js +108 -0
- package/dist/_chunks/debug-gwlJkDuf.js.map +1 -0
- package/dist/_chunks/{format-DNt20Kt8.js → format-DviM89f0.js} +3 -2
- package/dist/_chunks/format-DviM89f0.js.map +1 -0
- package/dist/_chunks/{interception-DGDIjDbR.js → interception-BOoWmLUA.js} +2 -2
- package/dist/_chunks/{interception-DGDIjDbR.js.map → interception-BOoWmLUA.js.map} +1 -1
- package/dist/_chunks/{metadata-routes-CQCnF4VK.js → metadata-routes-Cjmvi3rQ.js} +1 -1
- package/dist/_chunks/{metadata-routes-CQCnF4VK.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
- package/dist/_chunks/{request-context-CRj2Zh1E.js → request-context-DIkVh_jG.js} +5 -4
- package/dist/_chunks/request-context-DIkVh_jG.js.map +1 -0
- package/dist/_chunks/{ssr-data-DLnbYpj1.js → ssr-data-MjmprTmO.js} +1 -1
- package/dist/_chunks/{ssr-data-DLnbYpj1.js.map → ssr-data-MjmprTmO.js.map} +1 -1
- package/dist/_chunks/{tracing-DF0G3FB7.js → tracing-Cwn7697K.js} +2 -2
- package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-Cwn7697K.js.map} +1 -1
- package/dist/_chunks/{use-cookie-dDbpCTx-.js → use-cookie-DX-l1_5E.js} +2 -2
- package/dist/_chunks/{use-cookie-dDbpCTx-.js.map → use-cookie-DX-l1_5E.js.map} +1 -1
- package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-D5KaffOK.js} +1 -1
- package/dist/_chunks/{use-query-states-DAhgj8Gx.js.map → use-query-states-D5KaffOK.js.map} +1 -1
- package/dist/cache/index.js +2 -2
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.js +3 -3
- package/dist/cookies/index.js +4 -4
- package/dist/fonts/local.d.ts +4 -2
- package/dist/fonts/local.d.ts.map +1 -1
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +211 -14
- package/dist/index.js.map +1 -1
- package/dist/plugins/build-report.d.ts +11 -1
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +7 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +2 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/mdx.d.ts +6 -0
- package/dist/plugins/mdx.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/search-params/index.js +1 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/debug.d.ts +82 -0
- package/dist/server/debug.d.ts.map +1 -0
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/dev-warnings.d.ts.map +1 -1
- package/dist/server/index.js +14 -13
- package/dist/server/index.js.map +1 -1
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/response-cache.d.ts +53 -0
- package/dist/server/response-cache.d.ts.map +1 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
- package/dist/shims/image.d.ts +15 -15
- package/package.json +1 -1
- package/src/client/stale-reload.ts +1 -1
- package/src/fonts/local.ts +7 -3
- package/src/index.ts +40 -0
- package/src/plugins/build-report.ts +23 -3
- package/src/plugins/entries.ts +9 -4
- package/src/plugins/fonts.ts +106 -5
- package/src/plugins/mdx.ts +9 -5
- package/src/plugins/server-bundle.ts +4 -0
- package/src/server/access-gate.tsx +3 -2
- package/src/server/action-client.ts +8 -4
- package/src/server/debug.ts +137 -0
- package/src/server/deny-renderer.ts +3 -2
- package/src/server/dev-warnings.ts +2 -1
- package/src/server/logger.ts +4 -3
- package/src/server/primitives.ts +2 -1
- package/src/server/request-context.ts +3 -2
- package/src/server/response-cache.ts +277 -0
- package/src/server/rsc-entry/index.ts +51 -13
- package/src/server/rsc-entry/rsc-payload.ts +4 -1
- package/src/server/rsc-entry/rsc-stream.ts +2 -1
- package/src/server/rsc-entry/ssr-renderer.ts +6 -2
- package/src/server/rsc-prop-warnings.ts +3 -1
- package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
- package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
|
@@ -184,6 +184,7 @@ async function runActionMiddleware<TCtx>(
|
|
|
184
184
|
// Re-export parseFormData for use throughout the framework
|
|
185
185
|
import { parseFormData } from './form-data.js';
|
|
186
186
|
import { formatSize } from '#/utils/format.js';
|
|
187
|
+
import { isDebug, isDevMode } from './debug.js';
|
|
187
188
|
|
|
188
189
|
/**
|
|
189
190
|
* Extract validation errors from a schema error.
|
|
@@ -246,12 +247,15 @@ export function handleActionError(error: unknown): ActionResult<never> {
|
|
|
246
247
|
};
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
// In dev, include the message for debugging
|
|
250
|
-
|
|
250
|
+
// In dev, include the message for debugging.
|
|
251
|
+
// Uses isDevMode() — NOT isDebug() — because this data is sent to the
|
|
252
|
+
// browser. TIMBER_DEBUG must never cause error messages to leak to clients.
|
|
253
|
+
// See design/13-security.md principle 4: "Errors don't leak."
|
|
254
|
+
const devMode = isDevMode();
|
|
251
255
|
return {
|
|
252
256
|
serverError: {
|
|
253
257
|
code: 'INTERNAL_ERROR',
|
|
254
|
-
...(
|
|
258
|
+
...(devMode && error instanceof Error ? { data: { message: error.message } } : {}),
|
|
255
259
|
},
|
|
256
260
|
};
|
|
257
261
|
}
|
|
@@ -413,7 +417,7 @@ export function validated<TInput, TData>(
|
|
|
413
417
|
* In production, validation errors are only returned to the client.
|
|
414
418
|
*/
|
|
415
419
|
function logValidationFailure(errors: ValidationErrors): void {
|
|
416
|
-
const isDev =
|
|
420
|
+
const isDev = isDebug();
|
|
417
421
|
if (!isDev) return;
|
|
418
422
|
|
|
419
423
|
const fields = Object.entries(errors)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime debug flag for timber.js.
|
|
3
|
+
*
|
|
4
|
+
* Two distinct functions for two distinct security levels:
|
|
5
|
+
*
|
|
6
|
+
* ## `isDebug()` — server-side logging only
|
|
7
|
+
*
|
|
8
|
+
* Returns true when timber's debug/warning messages should be written to
|
|
9
|
+
* stderr / the server console. This NEVER affects what is sent to the
|
|
10
|
+
* client (no error details, no timing headers, no stack traces).
|
|
11
|
+
*
|
|
12
|
+
* Active when any of:
|
|
13
|
+
* - `NODE_ENV !== 'production'` (standard dev mode)
|
|
14
|
+
* - `TIMBER_DEBUG` env var is set to a truthy value at runtime
|
|
15
|
+
* - `timber.config.ts` has `debug: true`
|
|
16
|
+
*
|
|
17
|
+
* ## `isDevMode()` — client-visible dev behavior
|
|
18
|
+
*
|
|
19
|
+
* Returns true ONLY when `NODE_ENV !== 'production'`. This gates anything
|
|
20
|
+
* that changes what clients can observe:
|
|
21
|
+
* - Dev error pages with stack traces (fallback-error.ts)
|
|
22
|
+
* - Detailed Server-Timing headers (pipeline.ts)
|
|
23
|
+
* - Error messages in action INTERNAL_ERROR payloads (action-client.ts)
|
|
24
|
+
* - Pipeline error handler wiring (Vite overlay)
|
|
25
|
+
*
|
|
26
|
+
* `isDevMode()` is statically replaced in production builds → the guarded
|
|
27
|
+
* code is tree-shaken to zero bytes. TIMBER_DEBUG cannot enable it.
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* In Cloudflare Workers wrangler.toml:
|
|
31
|
+
* [vars]
|
|
32
|
+
* TIMBER_DEBUG = "1"
|
|
33
|
+
*
|
|
34
|
+
* In Node.js:
|
|
35
|
+
* TIMBER_DEBUG=1 node server.js
|
|
36
|
+
*
|
|
37
|
+
* In timber.config.ts:
|
|
38
|
+
* export default { debug: true }
|
|
39
|
+
*
|
|
40
|
+
* See design/13-security.md for the security taxonomy.
|
|
41
|
+
* See design/18-build-system.md for build pipeline details.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
// ─── Dev Mode (client-visible) ──────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if the application is running in development mode.
|
|
48
|
+
*
|
|
49
|
+
* This is the ONLY function that should gate client-visible dev behavior:
|
|
50
|
+
* - Dev error pages with stack traces
|
|
51
|
+
* - Detailed Server-Timing response headers
|
|
52
|
+
* - Error messages in action `INTERNAL_ERROR` payloads
|
|
53
|
+
* - Pipeline error handler wiring (Vite overlay)
|
|
54
|
+
*
|
|
55
|
+
* Returns `process.env.NODE_ENV !== 'production'`, which is statically
|
|
56
|
+
* replaced by the bundler in production builds. Code guarded by this
|
|
57
|
+
* function is tree-shaken to zero bytes in production.
|
|
58
|
+
*
|
|
59
|
+
* TIMBER_DEBUG does NOT enable this — that would leak server internals
|
|
60
|
+
* to clients. Use `isDebug()` for server-side-only logging.
|
|
61
|
+
*/
|
|
62
|
+
export function isDevMode(): boolean {
|
|
63
|
+
return process.env.NODE_ENV !== 'production';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Debug Flag (server-side logging only) ──────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Config-level debug override. Set via `setDebugFromConfig()` during
|
|
70
|
+
* initialization when timber.config.ts has `debug: true`.
|
|
71
|
+
*/
|
|
72
|
+
let _configDebug = false;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Set the debug flag from timber.config.ts.
|
|
76
|
+
* Called during handler initialization.
|
|
77
|
+
*/
|
|
78
|
+
export function setDebugFromConfig(debug: boolean): void {
|
|
79
|
+
_configDebug = debug;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if timber debug logging is active (server-side only).
|
|
84
|
+
*
|
|
85
|
+
* Returns true if ANY of these conditions hold:
|
|
86
|
+
* - NODE_ENV is not 'production' (standard dev mode)
|
|
87
|
+
* - TIMBER_DEBUG environment variable is set to a truthy value at runtime
|
|
88
|
+
* - timber.config.ts has `debug: true`
|
|
89
|
+
*
|
|
90
|
+
* This function controls ONLY server-side logging — messages written to
|
|
91
|
+
* stderr or the server console. It NEVER affects client-visible behavior
|
|
92
|
+
* (error pages, response headers, action payloads). For client-visible
|
|
93
|
+
* behavior, use `isDevMode()`.
|
|
94
|
+
*
|
|
95
|
+
* The TIMBER_DEBUG check is deliberately written as a dynamic property
|
|
96
|
+
* access so bundlers cannot statically replace it.
|
|
97
|
+
*/
|
|
98
|
+
export function isDebug(): boolean {
|
|
99
|
+
// Fast path: dev mode (statically replaced to `true` in dev, `false` in prod)
|
|
100
|
+
if (process.env.NODE_ENV !== 'production') return true;
|
|
101
|
+
|
|
102
|
+
// Config override
|
|
103
|
+
if (_configDebug) return true;
|
|
104
|
+
|
|
105
|
+
// Runtime env var check — uses dynamic access to prevent static replacement.
|
|
106
|
+
// In production builds, process.env.NODE_ENV is statically replaced, but
|
|
107
|
+
// TIMBER_DEBUG must survive as a runtime check. The dynamic key access
|
|
108
|
+
// pattern ensures the bundler treats this as opaque.
|
|
109
|
+
return _readTimberDebugEnv();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read TIMBER_DEBUG from the environment at runtime.
|
|
114
|
+
*
|
|
115
|
+
* Extracted to a separate function to:
|
|
116
|
+
* 1. Prevent bundler inlining (cross-module function calls are not inlined)
|
|
117
|
+
* 2. Handle platforms where `process` may not exist (Cloudflare Workers)
|
|
118
|
+
* 3. Support globalThis.__TIMBER_DEBUG for programmatic control
|
|
119
|
+
*/
|
|
120
|
+
function _readTimberDebugEnv(): boolean {
|
|
121
|
+
// globalThis override — useful for programmatic control and testing
|
|
122
|
+
if ((globalThis as Record<string, unknown>).__TIMBER_DEBUG) return true;
|
|
123
|
+
|
|
124
|
+
// process.env — works in Node.js and platforms that polyfill process
|
|
125
|
+
try {
|
|
126
|
+
const key = 'TIMBER_DEBUG';
|
|
127
|
+
const val =
|
|
128
|
+
typeof process !== 'undefined' && process.env
|
|
129
|
+
? (process.env as Record<string, string | undefined>)[key]
|
|
130
|
+
: undefined;
|
|
131
|
+
if (val && val !== '0' && val !== 'false') return true;
|
|
132
|
+
} catch {
|
|
133
|
+
// process may not exist or env may throw — safe to ignore
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
@@ -20,6 +20,7 @@ import { renderToReadableStream } from '#/rsc-runtime/rsc.js';
|
|
|
20
20
|
|
|
21
21
|
import { DenySignal } from './primitives.js';
|
|
22
22
|
import { logRenderError } from './logger.js';
|
|
23
|
+
import { isDebug } from './debug.js';
|
|
23
24
|
import { resolveMetadata, renderMetadataToElements } from './metadata.js';
|
|
24
25
|
import { resolveManifestStatusFile } from './manifest-status-resolver.js';
|
|
25
26
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
@@ -94,7 +95,7 @@ export async function renderDenyPage(
|
|
|
94
95
|
|
|
95
96
|
// Dev warning: JSON status file exists but is shadowed by the component chain.
|
|
96
97
|
// This helps developers understand why their .json file isn't being served.
|
|
97
|
-
if (
|
|
98
|
+
if (isDebug()) {
|
|
98
99
|
const jsonResolution = resolveManifestStatusFile(deny.status, segments, 'json');
|
|
99
100
|
if (jsonResolution) {
|
|
100
101
|
console.warn(
|
|
@@ -133,7 +134,7 @@ export async function renderDenyPage(
|
|
|
133
134
|
const { component } = layoutsToWrap[i];
|
|
134
135
|
element = h(component, null, element);
|
|
135
136
|
}
|
|
136
|
-
} else if (
|
|
137
|
+
} else if (isDebug()) {
|
|
137
138
|
// Dev-mode: warn if shell=false might conflict with Suspense
|
|
138
139
|
// The actual Suspense boundary check happens at render time in the pipeline.
|
|
139
140
|
// This is a preemptive log for developer awareness.
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ViteDevServer } from 'vite';
|
|
18
|
+
import { isDebug } from './debug.js';
|
|
18
19
|
|
|
19
20
|
// ─── Warning IDs ───────────────────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -54,7 +55,7 @@ export function setViteServer(server: ViteDevServer | null): void {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
function isDev(): boolean {
|
|
57
|
-
return
|
|
58
|
+
return isDebug();
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
package/src/server/logger.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { getTraceStore } from './tracing.js';
|
|
12
12
|
import { formatSsrError } from './error-formatter.js';
|
|
13
|
+
import { isDebug } from './debug.js';
|
|
13
14
|
|
|
14
15
|
// ─── Logger Interface ─────────────────────────────────────────────────────
|
|
15
16
|
|
|
@@ -103,7 +104,7 @@ export function logMiddlewareShortCircuit(data: {
|
|
|
103
104
|
export function logMiddlewareError(data: { method: string; path: string; error: unknown }): void {
|
|
104
105
|
if (_logger) {
|
|
105
106
|
_logger.error('unhandled error in middleware phase', withTraceContext(data));
|
|
106
|
-
} else if (
|
|
107
|
+
} else if (isDebug()) {
|
|
107
108
|
console.error('[timber] middleware error', data.error);
|
|
108
109
|
}
|
|
109
110
|
}
|
|
@@ -112,7 +113,7 @@ export function logMiddlewareError(data: { method: string; path: string; error:
|
|
|
112
113
|
export function logRenderError(data: { method: string; path: string; error: unknown }): void {
|
|
113
114
|
if (_logger) {
|
|
114
115
|
_logger.error('unhandled render-phase error', withTraceContext(data));
|
|
115
|
-
} else if (
|
|
116
|
+
} else if (isDebug()) {
|
|
116
117
|
// No logger configured — fall back to console.error in dev with
|
|
117
118
|
// cleaned-up error messages (vendor paths rewritten, hints added).
|
|
118
119
|
console.error('[timber] render error:', formatSsrError(data.error));
|
|
@@ -123,7 +124,7 @@ export function logRenderError(data: { method: string; path: string; error: unkn
|
|
|
123
124
|
export function logProxyError(data: { error: unknown }): void {
|
|
124
125
|
if (_logger) {
|
|
125
126
|
_logger.error('proxy.ts threw uncaught error', withTraceContext(data));
|
|
126
|
-
} else if (
|
|
127
|
+
} else if (isDebug()) {
|
|
127
128
|
console.error('[timber] proxy error', data.error);
|
|
128
129
|
}
|
|
129
130
|
}
|
package/src/server/primitives.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { JsonSerializable } from './types.js';
|
|
7
7
|
import { getWaitUntil as _getWaitUntil } from './waituntil-bridge.js';
|
|
8
|
+
import { isDebug } from './debug.js';
|
|
8
9
|
|
|
9
10
|
// ─── Dev-mode validation ────────────────────────────────────────────────────
|
|
10
11
|
|
|
@@ -83,7 +84,7 @@ export function findNonSerializable(value: unknown, path = 'data'): string | nul
|
|
|
83
84
|
* No-op in production.
|
|
84
85
|
*/
|
|
85
86
|
function warnIfNotSerializable(data: unknown, callerName: string): void {
|
|
86
|
-
if (
|
|
87
|
+
if (!isDebug()) return;
|
|
87
88
|
if (data === undefined) return;
|
|
88
89
|
|
|
89
90
|
const issue = findNonSerializable(data);
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
14
14
|
import type { Routes } from '#/index.js';
|
|
15
15
|
import { requestContextAls, type RequestContextStore, type CookieEntry } from './als-registry.js';
|
|
16
|
+
import { isDebug } from './debug.js';
|
|
16
17
|
|
|
17
18
|
// Re-export the ALS for framework-internal consumers that need direct access.
|
|
18
19
|
export { requestContextAls };
|
|
@@ -117,7 +118,7 @@ export function cookies(): RequestCookies {
|
|
|
117
118
|
set(name: string, value: string, options?: CookieOptions): void {
|
|
118
119
|
assertMutable(store, 'set');
|
|
119
120
|
if (store.flushed) {
|
|
120
|
-
if (
|
|
121
|
+
if (isDebug()) {
|
|
121
122
|
console.warn(
|
|
122
123
|
`[timber] warn: cookies().set('${name}') called after response headers were committed.\n` +
|
|
123
124
|
` The cookie will NOT be sent. Move cookie mutations to middleware.ts, a server action,\n` +
|
|
@@ -146,7 +147,7 @@ export function cookies(): RequestCookies {
|
|
|
146
147
|
delete(name: string, options?: Pick<CookieOptions, 'path' | 'domain'>): void {
|
|
147
148
|
assertMutable(store, 'delete');
|
|
148
149
|
if (store.flushed) {
|
|
149
|
-
if (
|
|
150
|
+
if (isDebug()) {
|
|
150
151
|
console.warn(
|
|
151
152
|
`[timber] warn: cookies().delete('${name}') called after response headers were committed.\n` +
|
|
152
153
|
` The cookie will NOT be deleted. Move cookie mutations to middleware.ts, a server action,\n` +
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-level response deduplication and short-TTL LRU cache.
|
|
3
|
+
*
|
|
4
|
+
* Two layers of optimization:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Singleflight** — concurrent requests to the same URL share a single
|
|
7
|
+
* render. Uses createSingleflight() from cache/singleflight.ts.
|
|
8
|
+
*
|
|
9
|
+
* 2. **LRU cache** — recently rendered responses are reused without
|
|
10
|
+
* re-executing the RSC-to-SSR pipeline. Entries have a short TTL
|
|
11
|
+
* (default 5s) and the cache has a bounded size (default 150 entries).
|
|
12
|
+
*
|
|
13
|
+
* Cache keys are compound: method + pathname + isRscPayload. Responses
|
|
14
|
+
* with Set-Cookie headers are never cached (they contain user-specific
|
|
15
|
+
* state). When `publicOnly` is true (default), requests with Cookie or
|
|
16
|
+
* Authorization headers bypass the cache entirely.
|
|
17
|
+
*
|
|
18
|
+
* See design/02-rendering-pipeline.md, design/31-benchmarking.md.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createSingleflight } from '#/cache/singleflight.js';
|
|
22
|
+
|
|
23
|
+
// ─── Configuration ─────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface ResponseCacheConfig {
|
|
26
|
+
/** Maximum number of entries in the LRU cache. Default: 150. */
|
|
27
|
+
maxSize?: number;
|
|
28
|
+
/** TTL for cached entries in milliseconds. Default: 5000 (5s). */
|
|
29
|
+
ttlMs?: number;
|
|
30
|
+
/**
|
|
31
|
+
* When true (default), requests with Cookie or Authorization headers
|
|
32
|
+
* bypass the cache entirely. This prevents sharing user-specific
|
|
33
|
+
* responses across requests.
|
|
34
|
+
*/
|
|
35
|
+
publicOnly?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ResolvedResponseCacheConfig {
|
|
39
|
+
maxSize: number;
|
|
40
|
+
ttlMs: number;
|
|
41
|
+
publicOnly: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveResponseCacheConfig(
|
|
45
|
+
config?: ResponseCacheConfig | false
|
|
46
|
+
): ResolvedResponseCacheConfig | null {
|
|
47
|
+
// Explicitly disabled
|
|
48
|
+
if (config === false) return null;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
maxSize: config?.maxSize ?? 150,
|
|
52
|
+
ttlMs: config?.ttlMs ?? 5000,
|
|
53
|
+
publicOnly: config?.publicOnly ?? true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Cache Entry ───────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
interface CacheEntry {
|
|
60
|
+
/** The cached response body as an ArrayBuffer (already consumed). */
|
|
61
|
+
body: ArrayBuffer;
|
|
62
|
+
/** Response status code. */
|
|
63
|
+
status: number;
|
|
64
|
+
/** Response headers (serialized). */
|
|
65
|
+
headers: [string, string][];
|
|
66
|
+
/** Timestamp when this entry was created. */
|
|
67
|
+
createdAt: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Singleflight Result ───────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/** Internal type: singleflight returns either a raw response or a cache entry. */
|
|
73
|
+
interface SingleflightResult {
|
|
74
|
+
/** Non-null when the response wasn't cacheable — only the first caller gets it. */
|
|
75
|
+
response: Response | null;
|
|
76
|
+
/** Non-null when the response was cached — all callers construct from this. */
|
|
77
|
+
entry: CacheEntry | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── LRU Cache ─────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Simple LRU cache backed by a Map (insertion order = access order).
|
|
84
|
+
* On get, we delete and re-insert to move the entry to the end (most recent).
|
|
85
|
+
* On eviction, we delete from the beginning (least recent).
|
|
86
|
+
*/
|
|
87
|
+
class LruCache {
|
|
88
|
+
private readonly map = new Map<string, CacheEntry>();
|
|
89
|
+
private readonly maxSize: number;
|
|
90
|
+
private readonly ttlMs: number;
|
|
91
|
+
|
|
92
|
+
constructor(maxSize: number, ttlMs: number) {
|
|
93
|
+
this.maxSize = maxSize;
|
|
94
|
+
this.ttlMs = ttlMs;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get(key: string): CacheEntry | undefined {
|
|
98
|
+
const entry = this.map.get(key);
|
|
99
|
+
if (!entry) return undefined;
|
|
100
|
+
|
|
101
|
+
// Check TTL
|
|
102
|
+
if (Date.now() - entry.createdAt > this.ttlMs) {
|
|
103
|
+
this.map.delete(key);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Move to end (most recently used)
|
|
108
|
+
this.map.delete(key);
|
|
109
|
+
this.map.set(key, entry);
|
|
110
|
+
return entry;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
set(key: string, entry: CacheEntry): void {
|
|
114
|
+
// If key exists, remove to re-insert at end
|
|
115
|
+
this.map.delete(key);
|
|
116
|
+
|
|
117
|
+
// Evict oldest if at capacity
|
|
118
|
+
if (this.map.size >= this.maxSize) {
|
|
119
|
+
const oldest = this.map.keys().next().value;
|
|
120
|
+
if (oldest !== undefined) {
|
|
121
|
+
this.map.delete(oldest);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.map.set(key, entry);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get size(): number {
|
|
129
|
+
return this.map.size;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
clear(): void {
|
|
133
|
+
this.map.clear();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Response Cache ────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
export interface ResponseCache {
|
|
140
|
+
/**
|
|
141
|
+
* Wrap a render function with singleflight dedup + LRU caching.
|
|
142
|
+
* Returns the cached Response or executes the render function.
|
|
143
|
+
*/
|
|
144
|
+
getOrRender(
|
|
145
|
+
req: Request,
|
|
146
|
+
isRscPayload: boolean,
|
|
147
|
+
renderFn: () => Promise<Response>
|
|
148
|
+
): Promise<Response>;
|
|
149
|
+
|
|
150
|
+
/** Number of entries currently in the LRU cache. */
|
|
151
|
+
readonly size: number;
|
|
152
|
+
|
|
153
|
+
/** Clear all cached entries. */
|
|
154
|
+
clear(): void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Create a response cache with singleflight deduplication and LRU caching.
|
|
159
|
+
*/
|
|
160
|
+
export function createResponseCache(config: ResolvedResponseCacheConfig): ResponseCache {
|
|
161
|
+
const lru = new LruCache(config.maxSize, config.ttlMs);
|
|
162
|
+
const singleflight = createSingleflight();
|
|
163
|
+
|
|
164
|
+
function buildCacheKey(req: Request, isRscPayload: boolean): string | null {
|
|
165
|
+
// When publicOnly is true, skip caching for authenticated requests
|
|
166
|
+
if (config.publicOnly) {
|
|
167
|
+
if (req.headers.has('Cookie') || req.headers.has('Authorization')) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const url = new URL(req.url);
|
|
173
|
+
return `${req.method}:${url.pathname}:${isRscPayload ? 'rsc' : 'html'}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Check if a response is cacheable.
|
|
178
|
+
* Responses with Set-Cookie headers are never cached — they contain
|
|
179
|
+
* user-specific state that must not be shared across requests.
|
|
180
|
+
*/
|
|
181
|
+
function isCacheable(response: Response): boolean {
|
|
182
|
+
// Don't cache error responses
|
|
183
|
+
if (response.status >= 400) return false;
|
|
184
|
+
|
|
185
|
+
// Don't cache redirects
|
|
186
|
+
if (response.status >= 300 && response.status < 400) return false;
|
|
187
|
+
|
|
188
|
+
// Don't cache responses with Set-Cookie (user-specific state)
|
|
189
|
+
if (response.headers.has('Set-Cookie')) return false;
|
|
190
|
+
|
|
191
|
+
// Only cache responses with a body
|
|
192
|
+
if (!response.body) return false;
|
|
193
|
+
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Construct a fresh Response from a cache entry (each caller gets their own). */
|
|
198
|
+
function responseFromEntry(entry: CacheEntry): Response {
|
|
199
|
+
// slice(0) creates a copy so each caller owns their buffer
|
|
200
|
+
return new Response(entry.body.slice(0), {
|
|
201
|
+
status: entry.status,
|
|
202
|
+
headers: entry.headers,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
async getOrRender(
|
|
208
|
+
req: Request,
|
|
209
|
+
isRscPayload: boolean,
|
|
210
|
+
renderFn: () => Promise<Response>
|
|
211
|
+
): Promise<Response> {
|
|
212
|
+
const cacheKey = buildCacheKey(req, isRscPayload);
|
|
213
|
+
|
|
214
|
+
// No cache key = skip caching entirely
|
|
215
|
+
if (cacheKey === null) {
|
|
216
|
+
return renderFn();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Check LRU cache first
|
|
220
|
+
const cached = lru.get(cacheKey);
|
|
221
|
+
if (cached) {
|
|
222
|
+
return responseFromEntry(cached);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Singleflight: concurrent requests to the same key share one render.
|
|
226
|
+
// The singleflight returns a SingleflightResult so all waiters
|
|
227
|
+
// can construct their own Response from the same cached data.
|
|
228
|
+
const result: SingleflightResult = await singleflight.do(cacheKey, async () => {
|
|
229
|
+
const response = await renderFn();
|
|
230
|
+
|
|
231
|
+
if (!isCacheable(response)) {
|
|
232
|
+
return { response, entry: null };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Buffer the response body for caching.
|
|
236
|
+
// The original Response body is consumed here — callers get copies
|
|
237
|
+
// from the cached ArrayBuffer.
|
|
238
|
+
const body = await response.arrayBuffer();
|
|
239
|
+
const headers: [string, string][] = [];
|
|
240
|
+
response.headers.forEach((value, key) => {
|
|
241
|
+
headers.push([key, value]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const entry: CacheEntry = {
|
|
245
|
+
body,
|
|
246
|
+
status: response.status,
|
|
247
|
+
headers,
|
|
248
|
+
createdAt: Date.now(),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
lru.set(cacheKey, entry);
|
|
252
|
+
|
|
253
|
+
return { response: null, entry };
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Non-cacheable response — only the first caller gets the original.
|
|
257
|
+
// For singleflight, this means concurrent waiters get the same promise
|
|
258
|
+
// result. The first caller already consumed the body, so subsequent
|
|
259
|
+
// callers would get an empty body. This is acceptable: non-cacheable
|
|
260
|
+
// responses (errors, redirects, Set-Cookie) are rare under concurrent
|
|
261
|
+
// identical requests, and the status + headers are still correct.
|
|
262
|
+
if (result.response) {
|
|
263
|
+
return result.response;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return responseFromEntry(result.entry!);
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
get size() {
|
|
270
|
+
return lru.size;
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
clear() {
|
|
274
|
+
lru.clear();
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -63,10 +63,16 @@ import {
|
|
|
63
63
|
isRscPayloadRequest,
|
|
64
64
|
} from './helpers.js';
|
|
65
65
|
import { parseClientStateTree } from '#/server/state-tree-diff.js';
|
|
66
|
+
import {
|
|
67
|
+
createResponseCache,
|
|
68
|
+
resolveResponseCacheConfig,
|
|
69
|
+
type ResponseCache,
|
|
70
|
+
} from '#/server/response-cache.js';
|
|
66
71
|
import { buildRscPayloadResponse } from './rsc-payload.js';
|
|
67
72
|
import { renderRscStream } from './rsc-stream.js';
|
|
68
73
|
import { renderSsrResponse } from './ssr-renderer.js';
|
|
69
74
|
import { callSsr } from './ssr-bridge.js';
|
|
75
|
+
import { isDebug, isDevMode, setDebugFromConfig } from '#/server/debug.js';
|
|
70
76
|
|
|
71
77
|
// Dev-only pipeline error handler, set by the dev server after import.
|
|
72
78
|
// In production this is always undefined — no overhead.
|
|
@@ -121,13 +127,31 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
121
127
|
buildManifest: buildManifest as BuildManifest,
|
|
122
128
|
});
|
|
123
129
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
130
|
+
// Initialize debug flag from config before anything else.
|
|
131
|
+
// This allows timber.config.ts `debug: true` to enable debug logging
|
|
132
|
+
// in production without the TIMBER_DEBUG env var.
|
|
133
|
+
if ((runtimeConfig as Record<string, unknown>).debug) {
|
|
134
|
+
setDebugFromConfig(true);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Two separate flags for two different security levels:
|
|
138
|
+
//
|
|
139
|
+
// isDev (isDevMode) — gates client-visible behavior: dev error pages with
|
|
140
|
+
// stack traces, detailed Server-Timing headers, error messages in action
|
|
141
|
+
// payloads. Statically replaced in production → tree-shaken to zero.
|
|
142
|
+
// TIMBER_DEBUG cannot enable this.
|
|
143
|
+
//
|
|
144
|
+
// debugEnabled (isDebug) — gates server-side logging only: stderr warnings,
|
|
145
|
+
// OTEL dev tracing, console.error fallbacks. TIMBER_DEBUG enables this.
|
|
146
|
+
// Never affects what clients see.
|
|
147
|
+
const isDev = isDevMode();
|
|
148
|
+
const debugEnabled = isDebug();
|
|
128
149
|
const slowPhaseMs = (runtimeConfig as Record<string, unknown>).slowPhaseMs as number | undefined;
|
|
129
150
|
|
|
130
|
-
|
|
151
|
+
// Dev logging — initialize OTEL-based dev tracing once at handler creation.
|
|
152
|
+
// In production with TIMBER_DEBUG, this enables server-side tracing output
|
|
153
|
+
// without exposing anything to clients.
|
|
154
|
+
if (debugEnabled) {
|
|
131
155
|
const devLogMode = resolveLogMode();
|
|
132
156
|
if (devLogMode !== 'quiet') {
|
|
133
157
|
await initDevTracing({ mode: devLogMode, slowPhaseMs });
|
|
@@ -140,6 +164,17 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
140
164
|
|
|
141
165
|
const typedBuildManifest = buildManifest as BuildManifest;
|
|
142
166
|
|
|
167
|
+
// Initialize response-level caching and singleflight deduplication.
|
|
168
|
+
// See design/31-benchmarking.md for performance motivation.
|
|
169
|
+
const responseCacheRaw = (runtimeConfig as Record<string, unknown>).responseCache as
|
|
170
|
+
| { maxSize?: number; ttlMs?: number; publicOnly?: boolean }
|
|
171
|
+
| false
|
|
172
|
+
| undefined;
|
|
173
|
+
const responseCacheConfig = resolveResponseCacheConfig(responseCacheRaw);
|
|
174
|
+
const responseCache: ResponseCache | null = responseCacheConfig
|
|
175
|
+
? createResponseCache(responseCacheConfig)
|
|
176
|
+
: null;
|
|
177
|
+
|
|
143
178
|
const pipelineConfig: PipelineConfig = {
|
|
144
179
|
proxyLoader: manifest.proxy?.load,
|
|
145
180
|
matchRoute,
|
|
@@ -170,14 +205,17 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
170
205
|
_requestHeaderOverlay: Headers,
|
|
171
206
|
interception?: InterceptionContext
|
|
172
207
|
) => {
|
|
173
|
-
|
|
174
|
-
req,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
208
|
+
const doRender = () =>
|
|
209
|
+
renderRoute(req, match, responseHeaders, clientBootstrap, clientJsDisabled, interception);
|
|
210
|
+
|
|
211
|
+
// Response cache wraps the render with singleflight + LRU.
|
|
212
|
+
// Interception requests (modals) are excluded — they depend on
|
|
213
|
+
// X-Timber-URL which makes caching semantics ambiguous.
|
|
214
|
+
if (responseCache && !interception) {
|
|
215
|
+
const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
|
|
216
|
+
return responseCache.getOrRender(req, isRsc, doRender);
|
|
217
|
+
}
|
|
218
|
+
return doRender();
|
|
181
219
|
},
|
|
182
220
|
renderNoMatch: async (req: Request, responseHeaders: Headers) => {
|
|
183
221
|
return renderNoMatchPage(req, manifest.root, responseHeaders, clientBootstrap);
|
|
@@ -53,7 +53,10 @@ export async function buildRscPayloadResponse(
|
|
|
53
53
|
// propagate to the onError callback before we check the signals.
|
|
54
54
|
// The rejected Promise from an async component resolves in the next
|
|
55
55
|
// microtask after read(), so we need at least one tick.
|
|
56
|
-
|
|
56
|
+
//
|
|
57
|
+
// Uses queueMicrotask instead of setTimeout(0) to stay within the
|
|
58
|
+
// same tick — no full event loop round-trip needed.
|
|
59
|
+
await new Promise<void>((r) => queueMicrotask(r));
|
|
57
60
|
|
|
58
61
|
// Check for redirect/deny signals detected during initial rendering
|
|
59
62
|
const trackedRedirect = signals.redirectSignal as RedirectSignal | null;
|