@timber-js/app 0.2.0-alpha.2 → 0.2.0-alpha.21
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-B3Gypr3D.js +108 -0
- package/dist/_chunks/debug-B3Gypr3D.js.map +1 -0
- package/dist/_chunks/{format-DNt20Kt8.js → format-RyoGQL74.js} +3 -2
- package/dist/_chunks/format-RyoGQL74.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-BQUC8PHn.js} +5 -4
- package/dist/_chunks/request-context-BQUC8PHn.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-CemImE6h.js} +17 -3
- package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-CemImE6h.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/adapters/nitro.d.ts +17 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +17 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/fast-hash.d.ts +22 -0
- package/dist/cache/fast-hash.d.ts.map +1 -0
- package/dist/cache/index.js +52 -10
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/register-cached-function.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/segment-context.d.ts +1 -1
- package/dist/client/segment-context.d.ts.map +1 -1
- package/dist/client/segment-merger.d.ts.map +1 -1
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +1 -1
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/cookies/index.js +4 -4
- package/dist/fonts/css.d.ts +1 -0
- package/dist/fonts/css.d.ts.map +1 -1
- package/dist/fonts/local.d.ts +4 -2
- package/dist/fonts/local.d.ts.map +1 -1
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +249 -21
- 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 +9 -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/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +32 -23
- package/dist/server/index.js.map +1 -1
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +7 -4
- package/dist/server/pipeline.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/route-element-builder.d.ts.map +1 -1
- 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 +6 -0
- 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/server/tracing.d.ts +10 -0
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/server/waituntil-bridge.d.ts.map +1 -1
- package/dist/shims/image.d.ts +15 -15
- package/package.json +1 -1
- package/src/adapters/nitro.ts +43 -5
- package/src/cache/fast-hash.ts +34 -0
- package/src/cache/register-cached-function.ts +7 -3
- package/src/cache/timber-cache.ts +17 -10
- package/src/client/browser-entry.ts +10 -6
- package/src/client/link.tsx +14 -9
- package/src/client/router.ts +4 -6
- package/src/client/segment-context.ts +6 -1
- package/src/client/segment-merger.ts +2 -8
- package/src/client/stale-reload.ts +5 -7
- package/src/client/top-loader.tsx +23 -19
- package/src/client/transition-root.tsx +7 -1
- package/src/fonts/css.ts +2 -1
- package/src/fonts/local.ts +7 -3
- package/src/index.ts +35 -2
- package/src/plugins/build-report.ts +23 -3
- package/src/plugins/entries.ts +9 -4
- package/src/plugins/fonts.ts +171 -19
- 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 +15 -5
- 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/html-injectors.ts +30 -10
- package/src/server/logger.ts +4 -3
- package/src/server/pipeline.ts +34 -20
- package/src/server/primitives.ts +2 -1
- package/src/server/request-context.ts +3 -2
- package/src/server/route-element-builder.ts +1 -6
- package/src/server/rsc-entry/index.ts +50 -7
- package/src/server/rsc-entry/rsc-payload.ts +42 -7
- package/src/server/rsc-entry/rsc-stream.ts +10 -5
- package/src/server/rsc-entry/ssr-renderer.ts +12 -5
- package/src/server/rsc-prop-warnings.ts +3 -1
- package/src/server/tracing.ts +23 -0
- package/src/server/waituntil-bridge.ts +4 -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
|
}
|
|
@@ -291,8 +295,14 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
291
295
|
// Determine input — either FormData (from useActionState) or direct arg
|
|
292
296
|
let rawInput: unknown;
|
|
293
297
|
if (args.length === 2 && args[1] instanceof FormData) {
|
|
294
|
-
// Called as (prevState, formData) by React useActionState
|
|
298
|
+
// Called as (prevState, formData) by React useActionState (with-JS path)
|
|
295
299
|
rawInput = schema ? parseFormData(args[1]) : args[1];
|
|
300
|
+
} else if (args.length === 1 && args[0] instanceof FormData) {
|
|
301
|
+
// No-JS path: React's decodeAction binds FormData as the sole argument.
|
|
302
|
+
// The form POSTs without JavaScript, decodeAction resolves the server
|
|
303
|
+
// reference and binds the FormData, then executeAction calls fn() with
|
|
304
|
+
// no additional args — so the bound FormData arrives as args[0].
|
|
305
|
+
rawInput = schema ? parseFormData(args[0]) : args[0];
|
|
296
306
|
} else {
|
|
297
307
|
// Direct call: action(input)
|
|
298
308
|
rawInput = args[0];
|
|
@@ -413,7 +423,7 @@ export function validated<TInput, TData>(
|
|
|
413
423
|
* In production, validation errors are only returned to the client.
|
|
414
424
|
*/
|
|
415
425
|
function logValidationFailure(errors: ValidationErrors): void {
|
|
416
|
-
const isDev =
|
|
426
|
+
const isDev = isDebug();
|
|
417
427
|
if (!isDev) return;
|
|
418
428
|
|
|
419
429
|
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
|
+
* - Server-Timing mode default (`'detailed'` in dev, `'total'` in prod)
|
|
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
|
/**
|
|
@@ -196,15 +196,25 @@ function createFlightInjectionTransform(
|
|
|
196
196
|
// Once the suffix is stripped, all content is body-level and
|
|
197
197
|
// scripts can safely be drained after any HTML chunk.
|
|
198
198
|
let foundSuffix = false;
|
|
199
|
+
// Set to true in flush() — once all HTML chunks have been emitted,
|
|
200
|
+
// there's no need to yield between RSC reads. This eliminates
|
|
201
|
+
// ~36 macrotask yields per request (18 chunks × 2 yields each)
|
|
202
|
+
// that were the primary source of SSR overhead vs Next.js.
|
|
203
|
+
let htmlStreamFinished = false;
|
|
199
204
|
|
|
200
205
|
// RSC script chunks waiting to be injected at the body level.
|
|
201
206
|
const pending: Uint8Array[] = [];
|
|
202
207
|
|
|
203
208
|
async function pullLoop(): Promise<void> {
|
|
204
|
-
//
|
|
205
|
-
// transform()
|
|
206
|
-
//
|
|
207
|
-
|
|
209
|
+
// Yield once so the first HTML shell chunk flows through
|
|
210
|
+
// transform() before we start reading RSC data. Uses
|
|
211
|
+
// setImmediate (check phase — end of current event loop
|
|
212
|
+
// iteration) instead of setTimeout(0) (timer phase — next
|
|
213
|
+
// iteration). Under concurrency, setTimeout(0) yields to
|
|
214
|
+
// ALL pending timer callbacks from other requests, adding
|
|
215
|
+
// 1-4ms per yield. setImmediate fires before timers.
|
|
216
|
+
// Available on both Node.js and Cloudflare Workers.
|
|
217
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
208
218
|
|
|
209
219
|
try {
|
|
210
220
|
for (;;) {
|
|
@@ -215,10 +225,12 @@ function createFlightInjectionTransform(
|
|
|
215
225
|
}
|
|
216
226
|
pending.push(value);
|
|
217
227
|
// Yield between reads so HTML chunks get a chance to flow
|
|
218
|
-
// through transform() first
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
228
|
+
// through transform() first — but only while HTML is still
|
|
229
|
+
// streaming. Once flush() fires (all HTML emitted), drain
|
|
230
|
+
// remaining RSC chunks without yielding.
|
|
231
|
+
if (!htmlStreamFinished) {
|
|
232
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
233
|
+
}
|
|
222
234
|
}
|
|
223
235
|
} catch (err) {
|
|
224
236
|
pullError = err;
|
|
@@ -240,7 +252,11 @@ function createFlightInjectionTransform(
|
|
|
240
252
|
|
|
241
253
|
return new TransformStream<Uint8Array, Uint8Array>({
|
|
242
254
|
transform(chunk, controller) {
|
|
243
|
-
//
|
|
255
|
+
// Pull-based start: don't begin reading RSC until the first
|
|
256
|
+
// HTML chunk flows through. This matches Next.js's approach
|
|
257
|
+
// and ensures the shell HTML is enqueued before any RSC
|
|
258
|
+
// script tags. Without this, the pull loop starts eagerly
|
|
259
|
+
// and may read RSC data before the browser has any HTML.
|
|
244
260
|
if (!pullPromise) {
|
|
245
261
|
pullPromise = pullLoop();
|
|
246
262
|
}
|
|
@@ -274,7 +290,11 @@ function createFlightInjectionTransform(
|
|
|
274
290
|
}
|
|
275
291
|
},
|
|
276
292
|
flush(controller) {
|
|
277
|
-
// HTML
|
|
293
|
+
// All HTML chunks have been emitted. Signal the pull loop to
|
|
294
|
+
// stop yielding between RSC reads — no more HTML to interleave.
|
|
295
|
+
htmlStreamFinished = true;
|
|
296
|
+
|
|
297
|
+
// Drain remaining RSC chunks at body level
|
|
278
298
|
const finish = () => {
|
|
279
299
|
drainPending(controller);
|
|
280
300
|
// Re-emit the suffix at the very end so HTML is well-formed
|
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/pipeline.ts
CHANGED
|
@@ -117,12 +117,15 @@ export interface PipelineConfig {
|
|
|
117
117
|
*/
|
|
118
118
|
interceptionRewrites?: import('#/routing/interception.js').InterceptionRewrite[];
|
|
119
119
|
/**
|
|
120
|
-
*
|
|
121
|
-
* Only enable in dev mode — exposes internal timing data.
|
|
120
|
+
* Control Server-Timing header output.
|
|
122
121
|
*
|
|
123
|
-
*
|
|
122
|
+
* - `'detailed'` — per-phase breakdown (proxy, middleware, render).
|
|
123
|
+
* - `'total'` — single `total;dur=N` entry (production-safe).
|
|
124
|
+
* - `false` — no Server-Timing header at all.
|
|
125
|
+
*
|
|
126
|
+
* Default: `'total'`.
|
|
124
127
|
*/
|
|
125
|
-
|
|
128
|
+
serverTiming?: 'detailed' | 'total' | false;
|
|
126
129
|
/**
|
|
127
130
|
* Dev pipeline error callback — called when a pipeline phase (proxy,
|
|
128
131
|
* middleware, render) catches an unhandled error. Used to wire the error
|
|
@@ -165,7 +168,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
165
168
|
earlyHints,
|
|
166
169
|
stripTrailingSlash = true,
|
|
167
170
|
slowRequestMs = 3000,
|
|
168
|
-
|
|
171
|
+
serverTiming = 'total',
|
|
169
172
|
onPipelineError,
|
|
170
173
|
} = config;
|
|
171
174
|
|
|
@@ -216,25 +219,25 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
216
219
|
// DevSpanProcessor reads this for tree/summary output.
|
|
217
220
|
await setSpanAttribute('http.response.status_code', result.status);
|
|
218
221
|
|
|
219
|
-
// Append Server-Timing header.
|
|
220
|
-
// In dev mode: detailed per-phase breakdown (proxy, middleware, render).
|
|
221
|
-
// In production: single total duration — safe to expose, no phase names.
|
|
222
|
+
// Append Server-Timing header based on configured mode.
|
|
222
223
|
// Response.redirect() creates immutable headers, so we must
|
|
223
224
|
// ensure mutability before writing Server-Timing.
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
if (serverTiming === 'detailed') {
|
|
226
|
+
// Detailed: per-phase breakdown (proxy, middleware, render).
|
|
227
|
+
const timingHeader = getServerTimingHeader();
|
|
228
|
+
if (timingHeader) {
|
|
227
229
|
result = ensureMutableResponse(result);
|
|
228
|
-
result.headers.set('Server-Timing',
|
|
230
|
+
result.headers.set('Server-Timing', timingHeader);
|
|
229
231
|
}
|
|
230
|
-
} else {
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
232
|
+
} else if (serverTiming === 'total') {
|
|
233
|
+
// Total only: single `total;dur=N` — no phase names.
|
|
234
|
+
// Prevents information disclosure while giving browser
|
|
235
|
+
// DevTools useful timing data.
|
|
234
236
|
const totalMs = Math.round(performance.now() - startTime);
|
|
235
237
|
result = ensureMutableResponse(result);
|
|
236
238
|
result.headers.set('Server-Timing', `total;dur=${totalMs}`);
|
|
237
239
|
}
|
|
240
|
+
// serverTiming === false: no header at all
|
|
238
241
|
|
|
239
242
|
return result;
|
|
240
243
|
}
|
|
@@ -254,7 +257,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
254
257
|
return response;
|
|
255
258
|
};
|
|
256
259
|
|
|
257
|
-
return
|
|
260
|
+
return serverTiming === 'detailed' ? runWithTimingCollector(runRequest) : runRequest();
|
|
258
261
|
});
|
|
259
262
|
});
|
|
260
263
|
};
|
|
@@ -272,7 +275,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
272
275
|
}
|
|
273
276
|
const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(req, method, path));
|
|
274
277
|
return await withSpan('timber.proxy', {}, () =>
|
|
275
|
-
|
|
278
|
+
serverTiming === 'detailed' ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
|
|
276
279
|
);
|
|
277
280
|
} catch (error) {
|
|
278
281
|
// Uncaught proxy.ts error → bare HTTP 500
|
|
@@ -421,7 +424,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
421
424
|
setMutableCookieContext(true);
|
|
422
425
|
const middlewareFn = () => runMiddleware(match.middleware!, ctx);
|
|
423
426
|
const middlewareResponse = await withSpan('timber.middleware', {}, () =>
|
|
424
|
-
|
|
427
|
+
serverTiming === 'detailed'
|
|
428
|
+
? withTiming('mw', 'middleware.ts', middlewareFn)
|
|
429
|
+
: middlewareFn()
|
|
425
430
|
);
|
|
426
431
|
setMutableCookieContext(false);
|
|
427
432
|
if (middlewareResponse) {
|
|
@@ -476,7 +481,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
476
481
|
const renderFn = () =>
|
|
477
482
|
render(req, match, responseHeaders, requestHeaderOverlay, interception);
|
|
478
483
|
const response = await withSpan('timber.render', { 'http.route': canonicalPathname }, () =>
|
|
479
|
-
|
|
484
|
+
serverTiming === 'detailed'
|
|
485
|
+
? withTiming('render', 'RSC + SSR render', renderFn)
|
|
486
|
+
: renderFn()
|
|
480
487
|
);
|
|
481
488
|
markResponseFlushed();
|
|
482
489
|
return response;
|
|
@@ -487,7 +494,14 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
487
494
|
return new Response(null, { status: error.status });
|
|
488
495
|
}
|
|
489
496
|
// RedirectSignal leaked from render — honour the redirect.
|
|
497
|
+
// For RSC payload requests, return 204 + X-Timber-Redirect so the
|
|
498
|
+
// client router can perform a soft SPA redirect (same as middleware path).
|
|
490
499
|
if (error instanceof RedirectSignal) {
|
|
500
|
+
const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
|
|
501
|
+
if (isRsc) {
|
|
502
|
+
responseHeaders.set('X-Timber-Redirect', error.location);
|
|
503
|
+
return new Response(null, { status: 204, headers: responseHeaders });
|
|
504
|
+
}
|
|
491
505
|
responseHeaders.set('Location', error.location);
|
|
492
506
|
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
493
507
|
}
|
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` +
|
|
@@ -352,12 +352,7 @@ export async function buildRouteElement(
|
|
|
352
352
|
// same urlPath (e.g., /(marketing) and /(app) both have "/"),
|
|
353
353
|
// which would cause the wrong cached layout to be reused
|
|
354
354
|
const skip =
|
|
355
|
-
shouldSkipSegment(
|
|
356
|
-
segment.urlPath,
|
|
357
|
-
layoutComponent,
|
|
358
|
-
isLeaf,
|
|
359
|
-
clientStateTree ?? null
|
|
360
|
-
) &&
|
|
355
|
+
shouldSkipSegment(segment.urlPath, layoutComponent, isLeaf, clientStateTree ?? null) &&
|
|
361
356
|
hasRenderedLayoutBelow &&
|
|
362
357
|
segment.segmentType !== 'group';
|
|
363
358
|
|
|
@@ -67,6 +67,22 @@ import { buildRscPayloadResponse } from './rsc-payload.js';
|
|
|
67
67
|
import { renderRscStream } from './rsc-stream.js';
|
|
68
68
|
import { renderSsrResponse } from './ssr-renderer.js';
|
|
69
69
|
import { callSsr } from './ssr-bridge.js';
|
|
70
|
+
import { isDebug, isDevMode, setDebugFromConfig } from '#/server/debug.js';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the Server-Timing mode from timber.config.ts.
|
|
74
|
+
*
|
|
75
|
+
* If the user set `serverTiming` explicitly, use that value.
|
|
76
|
+
* Otherwise: `'detailed'` in dev, `'total'` in production.
|
|
77
|
+
*/
|
|
78
|
+
function resolveServerTimingMode(
|
|
79
|
+
config: Record<string, unknown>,
|
|
80
|
+
isDev: boolean
|
|
81
|
+
): 'detailed' | 'total' | false {
|
|
82
|
+
const userValue = config.serverTiming as 'detailed' | 'total' | false | undefined;
|
|
83
|
+
if (userValue !== undefined) return userValue;
|
|
84
|
+
return isDev ? 'detailed' : 'total';
|
|
85
|
+
}
|
|
70
86
|
|
|
71
87
|
// Dev-only pipeline error handler, set by the dev server after import.
|
|
72
88
|
// In production this is always undefined — no overhead.
|
|
@@ -121,13 +137,31 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
121
137
|
buildManifest: buildManifest as BuildManifest,
|
|
122
138
|
});
|
|
123
139
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
140
|
+
// Initialize debug flag from config before anything else.
|
|
141
|
+
// This allows timber.config.ts `debug: true` to enable debug logging
|
|
142
|
+
// in production without the TIMBER_DEBUG env var.
|
|
143
|
+
if ((runtimeConfig as Record<string, unknown>).debug) {
|
|
144
|
+
setDebugFromConfig(true);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Two separate flags for two different security levels:
|
|
148
|
+
//
|
|
149
|
+
// isDev (isDevMode) — gates client-visible behavior: dev error pages with
|
|
150
|
+
// stack traces, detailed Server-Timing headers, error messages in action
|
|
151
|
+
// payloads. Statically replaced in production → tree-shaken to zero.
|
|
152
|
+
// TIMBER_DEBUG cannot enable this.
|
|
153
|
+
//
|
|
154
|
+
// debugEnabled (isDebug) — gates server-side logging only: stderr warnings,
|
|
155
|
+
// OTEL dev tracing, console.error fallbacks. TIMBER_DEBUG enables this.
|
|
156
|
+
// Never affects what clients see.
|
|
157
|
+
const isDev = isDevMode();
|
|
158
|
+
const debugEnabled = isDebug();
|
|
128
159
|
const slowPhaseMs = (runtimeConfig as Record<string, unknown>).slowPhaseMs as number | undefined;
|
|
129
160
|
|
|
130
|
-
|
|
161
|
+
// Dev logging — initialize OTEL-based dev tracing once at handler creation.
|
|
162
|
+
// In production with TIMBER_DEBUG, this enables server-side tracing output
|
|
163
|
+
// without exposing anything to clients.
|
|
164
|
+
if (debugEnabled) {
|
|
131
165
|
const devLogMode = resolveLogMode();
|
|
132
166
|
if (devLogMode !== 'quiet') {
|
|
133
167
|
await initDevTracing({ mode: devLogMode, slowPhaseMs });
|
|
@@ -186,7 +220,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
186
220
|
// Slow request threshold from timber.config.ts. Default 3000ms, 0 to disable.
|
|
187
221
|
// See design/17-logging.md §"slowRequestMs"
|
|
188
222
|
slowRequestMs: (runtimeConfig as Record<string, unknown>).slowRequestMs as number | undefined,
|
|
189
|
-
|
|
223
|
+
serverTiming: resolveServerTimingMode(runtimeConfig, isDev),
|
|
190
224
|
onPipelineError: isDev
|
|
191
225
|
? (error: Error, phase: string) => {
|
|
192
226
|
if (_devPipelineErrorHandler) _devPipelineErrorHandler(error, phase);
|
|
@@ -330,7 +364,8 @@ async function renderRoute(
|
|
|
330
364
|
throw error;
|
|
331
365
|
}
|
|
332
366
|
|
|
333
|
-
const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } =
|
|
367
|
+
const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } =
|
|
368
|
+
routeResult;
|
|
334
369
|
|
|
335
370
|
// Build head HTML for injection into the SSR output.
|
|
336
371
|
// Collects CSS, fonts, and modulepreload from the build manifest for matched segments.
|
|
@@ -347,6 +382,14 @@ async function renderRoute(
|
|
|
347
382
|
headHtml += buildCssLinkTags(cssUrls);
|
|
348
383
|
}
|
|
349
384
|
|
|
385
|
+
// Inline font CSS as a <style> tag — @font-face rules and scoped classes.
|
|
386
|
+
// The font CSS is set on globalThis by the transformed font file's
|
|
387
|
+
// side-effect import of virtual:timber-font-css-register.
|
|
388
|
+
const fontCss = (globalThis as Record<string, unknown>).__timber_font_css as string | undefined;
|
|
389
|
+
if (fontCss) {
|
|
390
|
+
headHtml += `<style data-timber-fonts>${fontCss}</style>`;
|
|
391
|
+
}
|
|
392
|
+
|
|
350
393
|
const fontEntries = collectRouteFonts(segments, typedManifest);
|
|
351
394
|
if (fontEntries.length > 0) {
|
|
352
395
|
headHtml += buildFontPreloadTags(fontEntries);
|