@timber-js/app 0.2.0-alpha.3 → 0.2.0-alpha.31
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/compress-module.d.ts.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 +26 -9
- 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/rsc-runtime/ssr.d.ts +12 -0
- package/dist/rsc-runtime/ssr.d.ts.map +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/node-stream-transforms.d.ts +65 -0
- package/dist/server/node-stream-transforms.d.ts.map +1 -0
- 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/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +34 -21
- package/dist/server/ssr-render.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/compress-module.ts +21 -4
- package/src/adapters/nitro.ts +31 -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 +8 -7
- 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/rsc-runtime/ssr.ts +50 -0
- package/src/rsc-runtime/vendor-types.d.ts +7 -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/node-stream-transforms.ts +315 -0
- 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/ssr-entry.ts +130 -8
- package/src/server/ssr-render.ts +168 -57
- 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
|
@@ -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);
|
|
@@ -45,15 +45,45 @@ export async function buildRscPayloadResponse(
|
|
|
45
45
|
skippedSegments?: string[]
|
|
46
46
|
): Promise<Response> {
|
|
47
47
|
// Read the first chunk from the RSC stream before committing headers.
|
|
48
|
+
// Race the first read against signal detection — if an async component
|
|
49
|
+
// throws a RedirectSignal or DenySignal, the onError callback fires
|
|
50
|
+
// signals.onSignal() and we can react immediately without waiting for
|
|
51
|
+
// the full macrotask queue.
|
|
52
|
+
//
|
|
53
|
+
// The rejection chain for an async-wrapped page component:
|
|
54
|
+
// 1. PageComponent throws RedirectSignal
|
|
55
|
+
// 2. withSpan catches and re-throws (microtask 1)
|
|
56
|
+
// 3. TracedPage promise rejects (microtask 2)
|
|
57
|
+
// 4. React Flight rejection handler → onError (microtask 3+)
|
|
58
|
+
//
|
|
59
|
+
// Promise.race reacts the instant onError fires, eliminating the
|
|
60
|
+
// per-request setTimeout(0) macrotask delay for the common case
|
|
61
|
+
// (no signal). A 50ms ceiling timeout guards against edge cases
|
|
62
|
+
// where onError never fires.
|
|
48
63
|
const reader = rscStream.getReader();
|
|
49
|
-
const
|
|
64
|
+
const signalDetected = new Promise<void>((resolve) => {
|
|
65
|
+
signals.onSignal = resolve;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
type RaceResult =
|
|
69
|
+
| { type: 'data'; chunk: ReadableStreamReadResult<Uint8Array> }
|
|
70
|
+
| { type: 'signal' };
|
|
71
|
+
|
|
72
|
+
const first: RaceResult = await Promise.race([
|
|
73
|
+
reader.read().then((chunk) => ({ type: 'data' as const, chunk })),
|
|
74
|
+
signalDetected.then(() => ({ type: 'signal' as const })),
|
|
75
|
+
]);
|
|
50
76
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
77
|
+
// If data arrived first, still check signals — they may have fired
|
|
78
|
+
// concurrently. Also do a final ceiling timeout check for edge cases
|
|
79
|
+
// where the signal fires just after the first read resolves.
|
|
80
|
+
if (first.type === 'data' && !signals.redirectSignal && !signals.denySignal) {
|
|
81
|
+
// Brief yield to let any in-flight microtask rejections complete.
|
|
82
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Detach the callback — no longer needed after this point.
|
|
86
|
+
signals.onSignal = undefined;
|
|
57
87
|
|
|
58
88
|
// Check for redirect/deny signals detected during initial rendering
|
|
59
89
|
const trackedRedirect = signals.redirectSignal as RedirectSignal | null;
|
|
@@ -72,6 +102,11 @@ export async function buildRscPayloadResponse(
|
|
|
72
102
|
);
|
|
73
103
|
}
|
|
74
104
|
|
|
105
|
+
// Extract the first chunk from the race result.
|
|
106
|
+
// If the signal won the race, read the first chunk now (the stream
|
|
107
|
+
// was already cancelled above, but we need a firstRead shape below).
|
|
108
|
+
const firstRead = first.type === 'data' ? first.chunk : await reader.read();
|
|
109
|
+
|
|
75
110
|
// Reconstruct the stream: prepend the buffered first chunk,
|
|
76
111
|
// then continue piping from the original reader.
|
|
77
112
|
const patchedStream = new ReadableStream<Uint8Array>({
|
|
@@ -17,17 +17,24 @@ import { DenySignal, RedirectSignal, RenderError } from '#/server/primitives.js'
|
|
|
17
17
|
import { checkAndWarnRscPropError } from '#/server/rsc-prop-warnings.js';
|
|
18
18
|
|
|
19
19
|
import { createDebugChannelSink, isAbortError } from './helpers.js';
|
|
20
|
+
import { isDebug } from '#/server/debug.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Mutable signal state captured during RSC rendering.
|
|
23
24
|
*
|
|
24
25
|
* Signals fire asynchronously via `onError` during stream consumption.
|
|
25
26
|
* The first signal of each type wins — subsequent signals are ignored.
|
|
27
|
+
*
|
|
28
|
+
* `onSignal` is an optional callback fired when a DenySignal or
|
|
29
|
+
* RedirectSignal is captured. Consumers use it with Promise.race to
|
|
30
|
+
* react immediately instead of polling with setTimeout/queueMicrotask.
|
|
26
31
|
*/
|
|
27
32
|
export interface RenderSignals {
|
|
28
33
|
denySignal: DenySignal | null;
|
|
29
34
|
redirectSignal: RedirectSignal | null;
|
|
30
35
|
renderError: { error: unknown; status: number } | null;
|
|
36
|
+
/** Callback fired when a redirect or deny signal is captured in onError. */
|
|
37
|
+
onSignal?: () => void;
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
export interface RscStreamResult {
|
|
@@ -66,11 +73,13 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
|
|
|
66
73
|
if (isAbortError(error) || req.signal?.aborted) return;
|
|
67
74
|
if (error instanceof DenySignal) {
|
|
68
75
|
signals.denySignal = error;
|
|
76
|
+
signals.onSignal?.();
|
|
69
77
|
// Return structured digest for client-side error boundaries
|
|
70
78
|
return JSON.stringify({ type: 'deny', status: error.status, data: error.data });
|
|
71
79
|
}
|
|
72
80
|
if (error instanceof RedirectSignal) {
|
|
73
81
|
signals.redirectSignal = error;
|
|
82
|
+
signals.onSignal?.();
|
|
74
83
|
return JSON.stringify({
|
|
75
84
|
type: 'redirect',
|
|
76
85
|
location: error.location,
|
|
@@ -97,11 +106,7 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
|
|
|
97
106
|
// directive isn't at the very top of the file, or the component is
|
|
98
107
|
// re-exported through a barrel file without 'use client'.
|
|
99
108
|
// See LOCAL-297.
|
|
100
|
-
if (
|
|
101
|
-
process.env.NODE_ENV !== 'production' &&
|
|
102
|
-
error instanceof Error &&
|
|
103
|
-
error.message.includes('Invalid hook call')
|
|
104
|
-
) {
|
|
109
|
+
if (isDebug() && error instanceof Error && error.message.includes('Invalid hook call')) {
|
|
105
110
|
console.error(
|
|
106
111
|
'[timber] A React hook was called during RSC rendering. This usually means a ' +
|
|
107
112
|
"'use client' component is being executed as a server component instead of " +
|
|
@@ -156,12 +156,19 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
156
156
|
try {
|
|
157
157
|
const ssrResponse = await callSsr(ssrStream, navContext);
|
|
158
158
|
|
|
159
|
-
// Signal promotion:
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
159
|
+
// Signal promotion: check if any signals were captured during rendering
|
|
160
|
+
// inside Suspense boundaries. If no signals are present yet, yield one
|
|
161
|
+
// microtask so async component rejections propagate to the RSC onError
|
|
162
|
+
// callback before we commit the response.
|
|
163
|
+
//
|
|
164
|
+
// When signals are already captured (onSignal already fired), skip the
|
|
165
|
+
// yield entirely — react immediately. Uses queueMicrotask instead of
|
|
166
|
+
// setTimeout(0) for the fallback to avoid yielding to the full event
|
|
167
|
+
// loop (timers phase).
|
|
163
168
|
// See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
|
|
164
|
-
|
|
169
|
+
if (!signals.redirectSignal && !signals.denySignal && !signals.renderError) {
|
|
170
|
+
await new Promise<void>((r) => queueMicrotask(r));
|
|
171
|
+
}
|
|
165
172
|
|
|
166
173
|
const promoted = checkCapturedSignals(/* skipHandledDeny */ true);
|
|
167
174
|
if (promoted) {
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* Task: TIM-358
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { isDebug } from './debug.js';
|
|
20
|
+
|
|
19
21
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
20
22
|
|
|
21
23
|
export interface NonSerializableTypeInfo {
|
|
@@ -165,7 +167,7 @@ export function formatRscPropWarning(
|
|
|
165
167
|
* @returns true if a warning was emitted
|
|
166
168
|
*/
|
|
167
169
|
export function checkAndWarnRscPropError(error: unknown, requestPath: string): boolean {
|
|
168
|
-
if (
|
|
170
|
+
if (!isDebug()) return false;
|
|
169
171
|
if (!(error instanceof Error)) return false;
|
|
170
172
|
|
|
171
173
|
const info = detectNonSerializableType(error.message);
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -14,10 +14,20 @@
|
|
|
14
14
|
|
|
15
15
|
// @ts-expect-error — virtual module provided by timber-entries plugin
|
|
16
16
|
import config from 'virtual:timber-config';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createFromReadableStream,
|
|
19
|
+
createFromNodeStream,
|
|
20
|
+
hasNodeStreamDecode,
|
|
21
|
+
} from '#/rsc-runtime/ssr.js';
|
|
18
22
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
19
23
|
|
|
20
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
renderSsrStream,
|
|
26
|
+
renderSsrNodeStream,
|
|
27
|
+
nodeReadableToWeb,
|
|
28
|
+
useNodeStreams,
|
|
29
|
+
buildSsrResponse,
|
|
30
|
+
} from './ssr-render.js';
|
|
21
31
|
import { formatSsrError } from './error-formatter.js';
|
|
22
32
|
import { SsrStreamError } from './primitives.js';
|
|
23
33
|
import { injectHead, injectRscPayload } from './html-injectors.js';
|
|
@@ -26,6 +36,41 @@ import { withSpan } from './tracing.js';
|
|
|
26
36
|
import { setCurrentParams } from '#/client/use-params.js';
|
|
27
37
|
import { registerSsrDataProvider, type SsrData } from '#/client/ssr-data.js';
|
|
28
38
|
|
|
39
|
+
// Pre-import Node.js stream modules at module load time — not per-request.
|
|
40
|
+
// Dynamic imports of node-stream-transforms and node:stream/promises were
|
|
41
|
+
// costing 3-17ms per request due to module resolution overhead.
|
|
42
|
+
let _nodeStreamImports: {
|
|
43
|
+
createNodeHeadInjector: typeof import('./node-stream-transforms.js').createNodeHeadInjector;
|
|
44
|
+
createNodeFlightInjector: typeof import('./node-stream-transforms.js').createNodeFlightInjector;
|
|
45
|
+
createNodeErrorHandler: typeof import('./node-stream-transforms.js').createNodeErrorHandler;
|
|
46
|
+
pipeline: typeof import('node:stream/promises').pipeline;
|
|
47
|
+
PassThrough: typeof import('node:stream').PassThrough;
|
|
48
|
+
ReadableFromWeb: (
|
|
49
|
+
webStream: import('stream/web').ReadableStream
|
|
50
|
+
) => import('node:stream').Readable;
|
|
51
|
+
} | null = null;
|
|
52
|
+
|
|
53
|
+
if (useNodeStreams) {
|
|
54
|
+
try {
|
|
55
|
+
const [transforms, streamPromises, stream] = await Promise.all([
|
|
56
|
+
import('./node-stream-transforms.js'),
|
|
57
|
+
import('node:stream/promises'),
|
|
58
|
+
import('node:stream'),
|
|
59
|
+
]);
|
|
60
|
+
_nodeStreamImports = {
|
|
61
|
+
createNodeHeadInjector: transforms.createNodeHeadInjector,
|
|
62
|
+
createNodeFlightInjector: transforms.createNodeFlightInjector,
|
|
63
|
+
createNodeErrorHandler: transforms.createNodeErrorHandler,
|
|
64
|
+
pipeline: streamPromises.pipeline,
|
|
65
|
+
PassThrough: stream.PassThrough,
|
|
66
|
+
ReadableFromWeb: (webStream: import('stream/web').ReadableStream) =>
|
|
67
|
+
stream.Readable.fromWeb(webStream) as import('node:stream').Readable,
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
// Fall back to Web Streams path
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
29
74
|
// ─── SSR Data ALS ─────────────────────────────────────────────────────────
|
|
30
75
|
//
|
|
31
76
|
// Per-request SSR data stored in AsyncLocalStorage, ensuring correct
|
|
@@ -137,7 +182,25 @@ export async function handleSsr(
|
|
|
137
182
|
// createFromReadableStream resolves client component references
|
|
138
183
|
// (from "use client" modules) using the SSR environment's module
|
|
139
184
|
// map, importing the actual components for server-side rendering.
|
|
140
|
-
const
|
|
185
|
+
const _s0 = performance.now();
|
|
186
|
+
// eslint-disable-next-line no-console
|
|
187
|
+
console.log(
|
|
188
|
+
`[diag] nodeImports=${!!_nodeStreamImports} nodeStreamDecode=${hasNodeStreamDecode} rscStream=${rscStream?.constructor?.name}`
|
|
189
|
+
);
|
|
190
|
+
// Decode the RSC stream into a React element tree.
|
|
191
|
+
// On Node.js: convert Web ReadableStream → Node Readable → createFromNodeStream
|
|
192
|
+
// (eliminates Promise-per-chunk overhead from Web Streams reader)
|
|
193
|
+
// On Workers: createFromReadableStream (Web Streams are V8-native C++ there)
|
|
194
|
+
let element: React.ReactNode;
|
|
195
|
+
if (hasNodeStreamDecode && _nodeStreamImports) {
|
|
196
|
+
const nodeRscStream = _nodeStreamImports.ReadableFromWeb(
|
|
197
|
+
rscStream as import('stream/web').ReadableStream
|
|
198
|
+
);
|
|
199
|
+
element = createFromNodeStream(nodeRscStream) as React.ReactNode;
|
|
200
|
+
} else {
|
|
201
|
+
element = createFromReadableStream(rscStream) as React.ReactNode;
|
|
202
|
+
}
|
|
203
|
+
const _s1 = performance.now();
|
|
141
204
|
|
|
142
205
|
// Wrap with a server-safe nuqs adapter so that 'use client' components
|
|
143
206
|
// that call nuqs hooks (useQueryStates, useQueryState) can SSR correctly.
|
|
@@ -145,12 +208,76 @@ export async function handleSsr(
|
|
|
145
208
|
// over after hydration. This provider supplies the request's search params
|
|
146
209
|
// as a static snapshot so nuqs renders the right initial values on the server.
|
|
147
210
|
const wrappedElement = withNuqsSsrAdapter(navContext.searchParams, element);
|
|
211
|
+
const _s2 = performance.now();
|
|
148
212
|
|
|
149
213
|
// Render to HTML stream (waits for onShellReady).
|
|
150
214
|
// Pass bootstrapScriptContent so React injects a non-deferred <script>
|
|
151
215
|
// in the shell HTML. This executes immediately during parsing — even
|
|
152
216
|
// while Suspense boundaries are still streaming — triggering module
|
|
153
217
|
// loading via dynamic import() so hydration can start early.
|
|
218
|
+
//
|
|
219
|
+
// Two paths based on platform:
|
|
220
|
+
// - Node.js: renderToPipeableStream → Node Transform pipeline → Readable.toWeb() → Response
|
|
221
|
+
// Entire pipeline stays in C++ native streams until the Response boundary.
|
|
222
|
+
// - Workers: renderToReadableStream → Web TransformStream pipeline → Response
|
|
223
|
+
// Web Streams are V8-native C++ built-ins on Workers.
|
|
224
|
+
if (_nodeStreamImports) {
|
|
225
|
+
// Node.js fast path: full pipeline in native streams
|
|
226
|
+
const {
|
|
227
|
+
createNodeHeadInjector,
|
|
228
|
+
createNodeFlightInjector,
|
|
229
|
+
createNodeErrorHandler,
|
|
230
|
+
pipeline,
|
|
231
|
+
PassThrough,
|
|
232
|
+
} = _nodeStreamImports;
|
|
233
|
+
|
|
234
|
+
const _s3 = performance.now();
|
|
235
|
+
let nodeHtmlStream: import('node:stream').Readable;
|
|
236
|
+
try {
|
|
237
|
+
nodeHtmlStream = await renderSsrNodeStream(wrappedElement, {
|
|
238
|
+
bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
|
|
239
|
+
deferSuspenseFor: navContext.deferSuspenseFor,
|
|
240
|
+
signal: navContext.signal,
|
|
241
|
+
});
|
|
242
|
+
} catch (renderError) {
|
|
243
|
+
console.error(
|
|
244
|
+
'[timber] SSR shell failed from RSC stream error:',
|
|
245
|
+
formatSsrError(renderError)
|
|
246
|
+
);
|
|
247
|
+
throw new SsrStreamError(
|
|
248
|
+
'SSR renderToReadableStream failed due to RSC stream error',
|
|
249
|
+
renderError
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Build Node.js Transform pipeline: errorHandler → headInjector → flightInjector → gzip
|
|
254
|
+
const errorHandler = createNodeErrorHandler(navContext.signal);
|
|
255
|
+
const headInjector = createNodeHeadInjector(navContext.headHtml);
|
|
256
|
+
const flightInjector = createNodeFlightInjector(navContext.rscStream);
|
|
257
|
+
|
|
258
|
+
// Pipe through the chain. pipeline() handles backpressure and error propagation.
|
|
259
|
+
// The last stream in the chain is the output — convert to Web ReadableStream
|
|
260
|
+
// only at the Response boundary.
|
|
261
|
+
// Note: gzip compression is still handled by compressResponse() in the Nitro
|
|
262
|
+
// entry via Web Streams CompressionStream. Moving it into this Node.js pipeline
|
|
263
|
+
// requires the request headers (Accept-Encoding) which NavContext doesn't carry.
|
|
264
|
+
// TODO: pass request headers through NavContext to enable inline Node.js gzip.
|
|
265
|
+
const output = new PassThrough();
|
|
266
|
+
pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
|
|
267
|
+
// Pipeline errors are handled by errorHandler transform
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const _s4 = performance.now();
|
|
271
|
+
// eslint-disable-next-line no-console
|
|
272
|
+
console.log(
|
|
273
|
+
`[ssr-perf] decode=${(_s1 - _s0).toFixed(1)}ms nuqs=${(_s2 - _s1).toFixed(1)}ms imports=${(_s3 - _s2).toFixed(1)}ms renderToPipeable=${(_s4 - _s3).toFixed(1)}ms pipeline=${(performance.now() - _s4).toFixed(1)}ms total=${(performance.now() - _s0).toFixed(1)}ms`
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const webStream = nodeReadableToWeb(output);
|
|
277
|
+
return buildSsrResponse(webStream, navContext.statusCode, navContext.responseHeaders);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Web Streams path (CF Workers / fallback)
|
|
154
281
|
let htmlStream: ReadableStream<Uint8Array>;
|
|
155
282
|
try {
|
|
156
283
|
htmlStream = await renderSsrStream(wrappedElement, {
|
|
@@ -159,11 +286,6 @@ export async function handleSsr(
|
|
|
159
286
|
signal: navContext.signal,
|
|
160
287
|
});
|
|
161
288
|
} catch (renderError) {
|
|
162
|
-
// SSR shell rendering failed — the RSC stream contained an error
|
|
163
|
-
// that wasn't caught by any error boundary in the decoded tree.
|
|
164
|
-
// Wrap in SsrStreamError so the RSC entry can handle it without
|
|
165
|
-
// re-executing server components via renderDenyPage.
|
|
166
|
-
// See LOCAL-293.
|
|
167
289
|
console.error(
|
|
168
290
|
'[timber] SSR shell failed from RSC stream error:',
|
|
169
291
|
formatSsrError(renderError)
|