@timber-js/app 0.2.0-alpha.127 → 0.2.0-alpha.129
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{actions-DX2gkoOZ.js → actions-CdzSSeTh.js} +2 -2
- package/dist/_chunks/{actions-DX2gkoOZ.js.map → actions-CdzSSeTh.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-vdobDWwX.js → cli-schema-sync-CObqjGWF.js} +1 -1
- package/dist/_chunks/{cli-schema-sync-DFHBj6yZ.js → cli-schema-sync-q-Ce6-iG.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-DFHBj6yZ.js.map → cli-schema-sync-q-Ce6-iG.js.map} +1 -1
- package/dist/_chunks/{logger-DDmK5skc.js → logger-CMd9P1xQ.js} +2 -2
- package/dist/_chunks/{logger-DDmK5skc.js.map → logger-CMd9P1xQ.js.map} +1 -1
- package/dist/_chunks/{tracing-DvKLzCFa.js → tracing-DP5djRq5.js} +43 -14
- package/dist/_chunks/tracing-DP5djRq5.js.map +1 -0
- package/dist/_chunks/{walkers-E5ss0qES.js → walkers-BbEu2_Pp.js} +2 -2
- package/dist/_chunks/{walkers-E5ss0qES.js.map → walkers-BbEu2_Pp.js.map} +1 -1
- package/dist/_chunks/{warnings-B30wpfaS.js → warnings-CAzunaGo.js} +2 -2
- package/dist/_chunks/{warnings-B30wpfaS.js.map → warnings-CAzunaGo.js.map} +1 -1
- package/dist/cache/index.js +2 -2
- package/dist/cli.js +1 -1
- package/dist/index.js +21 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/als-registry.d.ts +17 -6
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +4 -4
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-bridge.d.ts +5 -1
- package/dist/server/rsc-entry/ssr-bridge.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +121 -0
- package/dist/server/ssr-bridge-types.d.ts.map +1 -0
- package/dist/server/ssr-entry.d.ts +1 -72
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +15 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/plugins/fonts.ts +9 -1
- package/src/plugins/routing.ts +15 -2
- package/src/server/als-registry.ts +53 -13
- package/src/server/deny-renderer.ts +2 -2
- package/src/server/rsc-entry/error-renderer.ts +14 -4
- package/src/server/rsc-entry/ssr-bridge.ts +5 -1
- package/src/server/rsc-entry/ssr-renderer.ts +25 -21
- package/src/server/ssr-bridge-types.ts +124 -0
- package/src/server/ssr-entry.ts +9 -77
- package/src/server/ssr-render.ts +40 -4
- package/dist/_chunks/tracing-DvKLzCFa.js.map +0 -1
|
@@ -21,10 +21,10 @@ import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
|
21
21
|
import { DenySignal, RenderError } from '../primitives.js';
|
|
22
22
|
import type { ClientBootstrapConfig } from '../html-injectors.js';
|
|
23
23
|
import { flightInitScript } from '../flight-scripts.js';
|
|
24
|
-
import { renderDenyPage } from '../deny-renderer.js';
|
|
24
|
+
import { renderDenyPage, renderDenyPageAsRsc } from '../deny-renderer.js';
|
|
25
25
|
import type { LayoutEntry } from '../deny-renderer.js';
|
|
26
|
-
import type { NavContext } from '../ssr-
|
|
27
|
-
import { createDebugChannelSink } from './helpers.js';
|
|
26
|
+
import type { NavContext } from '../ssr-bridge-types.js';
|
|
27
|
+
import { createDebugChannelSink, isRscPayloadRequest } from './helpers.js';
|
|
28
28
|
import { getCookiesForSsr } from '../cookie-context.js';
|
|
29
29
|
import { callSsr } from './ssr-bridge.js';
|
|
30
30
|
import { teeWithErrorPropagation } from '../stream-utils.js';
|
|
@@ -288,7 +288,7 @@ async function renderErrorPageViaRsc(
|
|
|
288
288
|
|
|
289
289
|
const navContext: NavContext = {
|
|
290
290
|
pathname: url.pathname,
|
|
291
|
-
|
|
291
|
+
segmentParams: match.segmentParams,
|
|
292
292
|
searchParams: Object.fromEntries(url.searchParams),
|
|
293
293
|
statusCode: status,
|
|
294
294
|
responseHeaders,
|
|
@@ -379,6 +379,16 @@ export async function renderNoMatchPage(
|
|
|
379
379
|
const deny = new DenySignal(404);
|
|
380
380
|
const match: RouteMatch = { segments, segmentParams: {}, middlewareChain: [] };
|
|
381
381
|
|
|
382
|
+
if (isRscPayloadRequest(req)) {
|
|
383
|
+
return renderDenyPageAsRsc(
|
|
384
|
+
deny,
|
|
385
|
+
segments,
|
|
386
|
+
layoutComponents,
|
|
387
|
+
responseHeaders,
|
|
388
|
+
createDebugChannelSink
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
382
392
|
return renderDenyPage(
|
|
383
393
|
deny,
|
|
384
394
|
segments,
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SSR Bridge — loads the SSR entry and passes the RSC stream for HTML rendering.
|
|
3
|
+
*
|
|
4
|
+
* This is the RSC → SSR environment hop. The two environments are separate
|
|
5
|
+
* Vite module graphs; `NavContext` is the serializable contract that crosses
|
|
6
|
+
* the boundary. See `../ssr-bridge-types.ts` for the contract definition.
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
9
|
/// <reference types="@vitejs/plugin-rsc/types" />
|
|
6
10
|
|
|
7
|
-
import type { NavContext } from '../ssr-
|
|
11
|
+
import type { NavContext } from '../ssr-bridge-types.js';
|
|
8
12
|
|
|
9
13
|
export async function callSsr(
|
|
10
14
|
rscStream: ReadableStream<Uint8Array>,
|
|
@@ -20,7 +20,7 @@ import type { RouteMatch } from '../pipeline.js';
|
|
|
20
20
|
import { SsrStreamError } from '../primitives.js';
|
|
21
21
|
import type { LayoutComponentEntry } from '../route-element-builder.js';
|
|
22
22
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
23
|
-
import type { NavContext } from '../ssr-
|
|
23
|
+
import type { NavContext } from '../ssr-bridge-types.js';
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
26
|
buildRedirectResponse,
|
|
@@ -149,7 +149,7 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
149
149
|
|
|
150
150
|
const navContext: NavContext = {
|
|
151
151
|
pathname: new URL(req.url).pathname,
|
|
152
|
-
|
|
152
|
+
segmentParams: match.segmentParams,
|
|
153
153
|
searchParams: Object.fromEntries(new URL(req.url).searchParams),
|
|
154
154
|
statusCode: 200,
|
|
155
155
|
responseHeaders,
|
|
@@ -235,33 +235,37 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
// Signal promotion: check if any signals were captured during rendering
|
|
238
|
-
// inside Suspense boundaries. If no signals are present yet,
|
|
239
|
-
//
|
|
240
|
-
// the full rejection chain to the RSC onError callback before
|
|
241
|
-
// commit the response.
|
|
238
|
+
// inside Suspense boundaries. If no signals are present yet, we may
|
|
239
|
+
// need to yield microtasks so async component rejections propagate
|
|
240
|
+
// through the full rejection chain to the RSC onError callback before
|
|
241
|
+
// we commit the response.
|
|
242
242
|
//
|
|
243
|
-
//
|
|
243
|
+
// Option 1 short-circuit: when Fizz's `stream.allReady` settled before
|
|
244
|
+
// the `deferSuspenseFor` hold ended (navContext._fizzAllReadyResolved),
|
|
245
|
+
// Flight has closed its stream, every `onError` already fired, and the
|
|
246
|
+
// signals object is fully populated. The retry loop would be a no-op.
|
|
247
|
+
// See design/spike-signal-promotion-protocol.md §"Option 1".
|
|
248
|
+
//
|
|
249
|
+
// Fallback path (hold timed out, no hold window, or shell-only return):
|
|
250
|
+
// the known rejection chain depth for a withSpan-wrapped page is 3+
|
|
251
|
+
// microtasks —
|
|
244
252
|
// 1. PageComponent throws DenySignal/RedirectSignal
|
|
245
253
|
// 2. withSpan catches and re-throws (microtask 1)
|
|
246
254
|
// 3. TracedPage promise rejects (microtask 2)
|
|
247
255
|
// 4. React Flight rejection handler → onError (microtask 3+)
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
// signal is detected. This tolerates future wrappers adding depth
|
|
253
|
-
// without requiring code changes.
|
|
254
|
-
//
|
|
255
|
-
// When signals are already captured (onSignal already fired), skip the
|
|
256
|
-
// yield entirely — react immediately. Uses queueMicrotask instead of
|
|
257
|
-
// setTimeout(0) to avoid yielding to the full event loop (timers phase).
|
|
256
|
+
// so a bounded retry loop (max 5) is required to catch signals whose
|
|
257
|
+
// propagation is still in-flight when callSsr returns. The loop exits
|
|
258
|
+
// early as soon as a signal is detected. Uses queueMicrotask instead
|
|
259
|
+
// of setTimeout(0) to avoid yielding to the full event loop.
|
|
258
260
|
// See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
|
|
259
261
|
if (!signals.redirectSignal && !signals.denySignal && !signals.renderError) {
|
|
260
262
|
let yieldsConsumed = 0;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
263
|
+
if (!navContext._fizzAllReadyResolved) {
|
|
264
|
+
for (let i = 0; i < SIGNAL_PROMOTION_MAX_YIELDS; i++) {
|
|
265
|
+
await new Promise<void>((r) => queueMicrotask(r));
|
|
266
|
+
yieldsConsumed++;
|
|
267
|
+
if (signals.redirectSignal || signals.denySignal || signals.renderError) break;
|
|
268
|
+
}
|
|
265
269
|
}
|
|
266
270
|
__test_only_signalPromotionDepth.value = yieldsConsumed;
|
|
267
271
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSC ↔ SSR Bridge Contract
|
|
3
|
+
*
|
|
4
|
+
* This file defines the payload that crosses the RSC → SSR environment
|
|
5
|
+
* boundary. The RSC environment calls `handleSsr(rscStream, navContext)`
|
|
6
|
+
* in the SSR environment via `import.meta.viteRsc.import(...)` (see
|
|
7
|
+
* `rsc-entry/ssr-bridge.ts`). RSC and SSR are separate Vite module graphs
|
|
8
|
+
* with separate module instances, so every per-request field that SSR
|
|
9
|
+
* needs must be declared here and passed explicitly.
|
|
10
|
+
*
|
|
11
|
+
* Rules for changes to this file:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Structurally serializable shape.** Fields must be values that can
|
|
14
|
+
* cross the environment boundary as plain data or as platform-neutral
|
|
15
|
+
* primitives (`Headers`, `ReadableStream`, `AbortSignal`, `Map`). Do
|
|
16
|
+
* not add closures, class instances tied to RSC-only modules, or
|
|
17
|
+
* anything whose identity must be preserved across the bridge.
|
|
18
|
+
*
|
|
19
|
+
* 2. **No runtime imports of RSC-only modules.** This file is read by
|
|
20
|
+
* both environments at type-check time. Keep it type-only. Importing
|
|
21
|
+
* an RSC-only runtime module here couples SSR to RSC internals.
|
|
22
|
+
*
|
|
23
|
+
* 3. **Shape changes are public-surface changes.** Adding, removing, or
|
|
24
|
+
* renaming a field is a change to the contract between two
|
|
25
|
+
* independently-built bundles. Update every caller in the same PR —
|
|
26
|
+
* do not rely on back-compat shims.
|
|
27
|
+
*
|
|
28
|
+
* See `design/02-rendering-pipeline.md` §"RSC → SSR → Client Hydration"
|
|
29
|
+
* and `CLAUDE.md` §"RSC and SSR Are Separate Vite Environments".
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Navigation context passed from the RSC environment to SSR.
|
|
34
|
+
*
|
|
35
|
+
* Per-request state must be explicitly passed across the RSC→SSR
|
|
36
|
+
* environment boundary since they are separate Vite module graphs.
|
|
37
|
+
*/
|
|
38
|
+
export interface NavContext {
|
|
39
|
+
/** The requested pathname */
|
|
40
|
+
pathname: string;
|
|
41
|
+
/** Extracted route segment params (catch-all segments produce string[]) */
|
|
42
|
+
segmentParams: Record<string, string | string[]>;
|
|
43
|
+
/** Search params from the URL */
|
|
44
|
+
searchParams: Record<string, string>;
|
|
45
|
+
/** The committed HTTP status code */
|
|
46
|
+
statusCode: number;
|
|
47
|
+
/** Response headers from middleware/proxy */
|
|
48
|
+
responseHeaders: Headers;
|
|
49
|
+
/** Pre-rendered metadata HTML to inject before </head> */
|
|
50
|
+
headHtml: string;
|
|
51
|
+
/** Inline JS for React's bootstrapScriptContent — kicks off module loading */
|
|
52
|
+
bootstrapScriptContent: string;
|
|
53
|
+
/** Tee'd RSC stream for client-side hydration (inlined into HTML) */
|
|
54
|
+
rscStream?: ReadableStream<Uint8Array>;
|
|
55
|
+
/** Max Suspense hold window (ms). SSR delays the first flush by this
|
|
56
|
+
* duration, racing allReady so that fast-resolving boundaries render inline
|
|
57
|
+
* without ever showing a fallback. Derived from route `deferSuspenseFor` exports.
|
|
58
|
+
* See design/05-streaming.md §"deferSuspenseFor". */
|
|
59
|
+
deferSuspenseFor?: number;
|
|
60
|
+
/** Request abort signal. When the client disconnects (page refresh,
|
|
61
|
+
* navigation away), this signal fires. Passed to renderToReadableStream
|
|
62
|
+
* so React stops rendering and doesn't fire error boundaries for aborts. */
|
|
63
|
+
signal?: AbortSignal;
|
|
64
|
+
/** Request cookies as name→value pairs. Used by useCookie() during SSR
|
|
65
|
+
* to return correct cookie values before hydration. */
|
|
66
|
+
cookies?: Map<string, string>;
|
|
67
|
+
/**
|
|
68
|
+
* Mutable flag: set by TimberErrorBoundary during SSR when it catches
|
|
69
|
+
* a DenySignal (via digest). This tells the RSC entry that the denial
|
|
70
|
+
* was contained by a slot error boundary and should NOT be promoted
|
|
71
|
+
* to a page-level deny. See LOCAL-298.
|
|
72
|
+
*/
|
|
73
|
+
_denyHandledByBoundary?: boolean;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Mutable: SSR timing data populated by handleSsr().
|
|
77
|
+
* Read by the RSC entry to record sub-phase Server-Timing entries
|
|
78
|
+
* when `serverTiming: 'detailed'` is configured.
|
|
79
|
+
*
|
|
80
|
+
* This bridges the RSC→SSR environment boundary: the SSR entry populates
|
|
81
|
+
* these fields, the RSC entry reads them after callSsr() returns.
|
|
82
|
+
*/
|
|
83
|
+
_ssrTimings?: {
|
|
84
|
+
/** Time to decode RSC stream (createFromReadableStream/createFromNodeStream) */
|
|
85
|
+
decodeMs: number;
|
|
86
|
+
/** Time for Fizz to render the shell (onShellReady) */
|
|
87
|
+
shellMs: number;
|
|
88
|
+
/** Time for pipe() to flush shell bytes to the output stream */
|
|
89
|
+
pipeMs: number;
|
|
90
|
+
/** Time to set up Node.js Transform pipeline / Web Stream transforms */
|
|
91
|
+
pipelineMs: number;
|
|
92
|
+
/** Total SSR time (decode → response ready) */
|
|
93
|
+
totalMs: number;
|
|
94
|
+
/** Whether the Node.js native stream path was used */
|
|
95
|
+
nodeStreams: boolean;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Signal that fires when an unhandled RSC error is detected.
|
|
100
|
+
* The Flight injector checks this before writing each script tag —
|
|
101
|
+
* when aborted, it stops injecting Flight data to prevent corrupted
|
|
102
|
+
* chunks from crashing the client's Flight decoder.
|
|
103
|
+
*/
|
|
104
|
+
_errorSignal?: AbortSignal;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Mutable flag: set by the SSR entry when Fizz's `stream.allReady`
|
|
108
|
+
* settled before the `deferSuspenseFor` hold ended.
|
|
109
|
+
*
|
|
110
|
+
* When true, the RSC entry can skip its signal-promotion
|
|
111
|
+
* microtask-retry wait: Fizz allReady implies Flight closed its
|
|
112
|
+
* stream, which implies `onError` already fired for every rejected
|
|
113
|
+
* Flight task. Any DenySignal/RedirectSignal/RenderError in-tree has
|
|
114
|
+
* already been captured in `RenderSignals`.
|
|
115
|
+
*
|
|
116
|
+
* When false or undefined (hold timed out, no hold window, shell-only
|
|
117
|
+
* return, Node error path), no such guarantee — the RSC entry falls
|
|
118
|
+
* back to the bounded microtask retry loop.
|
|
119
|
+
*
|
|
120
|
+
* See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
|
|
121
|
+
* and design/spike-signal-promotion-protocol.md (Option 1).
|
|
122
|
+
*/
|
|
123
|
+
_fizzAllReadyResolved?: boolean;
|
|
124
|
+
}
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { wrapSsrElement } from './ssr-wrappers.js';
|
|
|
45
45
|
import { withSpan } from './tracing.js';
|
|
46
46
|
import { setCurrentParams } from '../client/use-segment-params.js';
|
|
47
47
|
import { registerSsrDataProvider, type SsrData } from '../client/ssr-data.js';
|
|
48
|
+
import type { NavContext } from './ssr-bridge-types.js';
|
|
48
49
|
|
|
49
50
|
// Pre-import Node.js stream modules at module load time — not per-request.
|
|
50
51
|
// Dynamic imports of node-stream-transforms and node:stream/promises were
|
|
@@ -98,81 +99,6 @@ const ssrDataAls = new AsyncLocalStorage<SsrData>();
|
|
|
98
99
|
// Register the ALS-backed provider so getSsrData() reads from ALS.
|
|
99
100
|
registerSsrDataProvider(() => ssrDataAls.getStore());
|
|
100
101
|
|
|
101
|
-
/**
|
|
102
|
-
* Navigation context passed from the RSC environment to SSR.
|
|
103
|
-
*
|
|
104
|
-
* Per-request state must be explicitly passed across the RSC→SSR
|
|
105
|
-
* environment boundary since they are separate Vite module graphs.
|
|
106
|
-
*/
|
|
107
|
-
export interface NavContext {
|
|
108
|
-
/** The requested pathname */
|
|
109
|
-
pathname: string;
|
|
110
|
-
/** Extracted route params (catch-all segments produce string[]) */
|
|
111
|
-
params: Record<string, string | string[]>;
|
|
112
|
-
/** Search params from the URL */
|
|
113
|
-
searchParams: Record<string, string>;
|
|
114
|
-
/** The committed HTTP status code */
|
|
115
|
-
statusCode: number;
|
|
116
|
-
/** Response headers from middleware/proxy */
|
|
117
|
-
responseHeaders: Headers;
|
|
118
|
-
/** Pre-rendered metadata HTML to inject before </head> */
|
|
119
|
-
headHtml: string;
|
|
120
|
-
/** Inline JS for React's bootstrapScriptContent — kicks off module loading */
|
|
121
|
-
bootstrapScriptContent: string;
|
|
122
|
-
/** Tee'd RSC stream for client-side hydration (inlined into HTML) */
|
|
123
|
-
rscStream?: ReadableStream<Uint8Array>;
|
|
124
|
-
/** Max Suspense hold window (ms). SSR delays the first flush by this
|
|
125
|
-
* duration, racing allReady so that fast-resolving boundaries render inline
|
|
126
|
-
* without ever showing a fallback. Derived from route `deferSuspenseFor` exports.
|
|
127
|
-
* See design/05-streaming.md §"deferSuspenseFor". */
|
|
128
|
-
deferSuspenseFor?: number;
|
|
129
|
-
/** Request abort signal. When the client disconnects (page refresh,
|
|
130
|
-
* navigation away), this signal fires. Passed to renderToReadableStream
|
|
131
|
-
* so React stops rendering and doesn't fire error boundaries for aborts. */
|
|
132
|
-
signal?: AbortSignal;
|
|
133
|
-
/** Request cookies as name→value pairs. Used by useCookie() during SSR
|
|
134
|
-
* to return correct cookie values before hydration. */
|
|
135
|
-
cookies?: Map<string, string>;
|
|
136
|
-
/**
|
|
137
|
-
* Mutable flag: set by TimberErrorBoundary during SSR when it catches
|
|
138
|
-
* a DenySignal (via digest). This tells the RSC entry that the denial
|
|
139
|
-
* was contained by a slot error boundary and should NOT be promoted
|
|
140
|
-
* to a page-level deny. See LOCAL-298.
|
|
141
|
-
*/
|
|
142
|
-
_denyHandledByBoundary?: boolean;
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Mutable: SSR timing data populated by handleSsr().
|
|
146
|
-
* Read by the RSC entry to record sub-phase Server-Timing entries
|
|
147
|
-
* when `serverTiming: 'detailed'` is configured.
|
|
148
|
-
*
|
|
149
|
-
* This bridges the RSC→SSR environment boundary: the SSR entry populates
|
|
150
|
-
* these fields, the RSC entry reads them after callSsr() returns.
|
|
151
|
-
*/
|
|
152
|
-
_ssrTimings?: {
|
|
153
|
-
/** Time to decode RSC stream (createFromReadableStream/createFromNodeStream) */
|
|
154
|
-
decodeMs: number;
|
|
155
|
-
/** Time for Fizz to render the shell (onShellReady) */
|
|
156
|
-
shellMs: number;
|
|
157
|
-
/** Time for pipe() to flush shell bytes to the output stream */
|
|
158
|
-
pipeMs: number;
|
|
159
|
-
/** Time to set up Node.js Transform pipeline / Web Stream transforms */
|
|
160
|
-
pipelineMs: number;
|
|
161
|
-
/** Total SSR time (decode → response ready) */
|
|
162
|
-
totalMs: number;
|
|
163
|
-
/** Whether the Node.js native stream path was used */
|
|
164
|
-
nodeStreams: boolean;
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Signal that fires when an unhandled RSC error is detected.
|
|
169
|
-
* The Flight injector checks this before writing each script tag —
|
|
170
|
-
* when aborted, it stops injecting Flight data to prevent corrupted
|
|
171
|
-
* chunks from crashing the client's Flight decoder.
|
|
172
|
-
*/
|
|
173
|
-
_errorSignal?: AbortSignal;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
102
|
/**
|
|
177
103
|
* Handle SSR: decode an RSC stream and render it to hydration-ready HTML.
|
|
178
104
|
*
|
|
@@ -207,7 +133,7 @@ export async function handleSsr(
|
|
|
207
133
|
pathname: navContext.pathname,
|
|
208
134
|
searchParams: navContext.searchParams,
|
|
209
135
|
cookies: navContext.cookies ?? new Map(),
|
|
210
|
-
params: navContext.
|
|
136
|
+
params: navContext.segmentParams,
|
|
211
137
|
_navContext: navContext,
|
|
212
138
|
};
|
|
213
139
|
|
|
@@ -221,7 +147,7 @@ export async function handleSsr(
|
|
|
221
147
|
// useParams reads from getSsrData() during SSR (ALS-backed),
|
|
222
148
|
// but setCurrentParams is kept for the client-side path where
|
|
223
149
|
// the segment router updates params on navigation.
|
|
224
|
-
setCurrentParams(navContext.
|
|
150
|
+
setCurrentParams(navContext.segmentParams);
|
|
225
151
|
|
|
226
152
|
// Decode the RSC stream into a React element tree.
|
|
227
153
|
// createFromReadableStream resolves client component references
|
|
@@ -280,6 +206,9 @@ export async function handleSsr(
|
|
|
280
206
|
deferSuspenseFor: navContext.deferSuspenseFor,
|
|
281
207
|
signal: navContext.signal,
|
|
282
208
|
renderTimeoutMs,
|
|
209
|
+
onFizzAllReadyResolved: (resolved) => {
|
|
210
|
+
navContext._fizzAllReadyResolved = resolved;
|
|
211
|
+
},
|
|
283
212
|
});
|
|
284
213
|
} catch (renderError) {
|
|
285
214
|
logRenderError({ method: '', path: '', error: renderError });
|
|
@@ -346,6 +275,9 @@ export async function handleSsr(
|
|
|
346
275
|
deferSuspenseFor: navContext.deferSuspenseFor,
|
|
347
276
|
signal: navContext.signal,
|
|
348
277
|
renderTimeoutMs,
|
|
278
|
+
onFizzAllReadyResolved: (resolved) => {
|
|
279
|
+
navContext._fizzAllReadyResolved = resolved;
|
|
280
|
+
},
|
|
349
281
|
});
|
|
350
282
|
} catch (renderError) {
|
|
351
283
|
logRenderError({ method: '', path: '', error: renderError });
|
package/src/server/ssr-render.ts
CHANGED
|
@@ -128,6 +128,14 @@ export async function renderSsrStream(
|
|
|
128
128
|
deferSuspenseFor?: number;
|
|
129
129
|
signal?: AbortSignal;
|
|
130
130
|
renderTimeoutMs?: number;
|
|
131
|
+
/**
|
|
132
|
+
* Fired synchronously when the render function is about to resolve
|
|
133
|
+
* (i.e. at hold-end / shell-ready). `resolved` reports whether Fizz's
|
|
134
|
+
* `stream.allReady` had already settled by that moment. Callers use
|
|
135
|
+
* this to decide whether signal promotion can be short-circuited.
|
|
136
|
+
* See NavContext._fizzAllReadyResolved.
|
|
137
|
+
*/
|
|
138
|
+
onFizzAllReadyResolved?: (resolved: boolean) => void;
|
|
131
139
|
}
|
|
132
140
|
): Promise<ReadableStream<Uint8Array>> {
|
|
133
141
|
return renderViaReadableStream(element, options);
|
|
@@ -158,17 +166,26 @@ export async function renderSsrNodeStream(
|
|
|
158
166
|
deferSuspenseFor?: number;
|
|
159
167
|
signal?: AbortSignal;
|
|
160
168
|
renderTimeoutMs?: number;
|
|
169
|
+
/**
|
|
170
|
+
* Fired synchronously at the moment `renderSsrNodeStream`'s returned
|
|
171
|
+
* Promise is about to resolve. `resolved` reports whether Fizz's
|
|
172
|
+
* `onAllReady` had already fired by then. See the twin callback on
|
|
173
|
+
* `renderSsrStream`.
|
|
174
|
+
*/
|
|
175
|
+
onFizzAllReadyResolved?: (resolved: boolean) => void;
|
|
161
176
|
}
|
|
162
177
|
): Promise<import('node:stream').Readable> {
|
|
163
178
|
const signal = options?.signal;
|
|
164
179
|
const deferMs = options?.deferSuspenseFor;
|
|
165
180
|
const timeoutMs = options?.renderTimeoutMs;
|
|
181
|
+
const onFizzAllReadyResolved = options?.onFizzAllReadyResolved;
|
|
166
182
|
|
|
167
183
|
return new Promise<import('node:stream').Readable>((resolve, reject) => {
|
|
168
184
|
const passthrough = new _PassThrough!();
|
|
169
185
|
|
|
170
186
|
let allReadyResolve: (() => void) | null = null;
|
|
171
187
|
let allReadyReject: ((reason?: unknown) => void) | null = null;
|
|
188
|
+
let allReadyFired = false;
|
|
172
189
|
const allReady = new Promise<void>((resolve, reject) => {
|
|
173
190
|
allReadyResolve = resolve;
|
|
174
191
|
allReadyReject = reject;
|
|
@@ -181,16 +198,21 @@ export async function renderSsrNodeStream(
|
|
|
181
198
|
onShellReady() {
|
|
182
199
|
if (deferMs && deferMs > 0) {
|
|
183
200
|
Promise.race([allReady, new Promise<void>((r) => setTimeout(r, deferMs))]).then(() => {
|
|
201
|
+
onFizzAllReadyResolved?.(allReadyFired);
|
|
184
202
|
pipe(passthrough);
|
|
185
203
|
resolve(passthrough);
|
|
186
204
|
});
|
|
187
205
|
} else {
|
|
206
|
+
// No hold — allReady has almost certainly not fired yet.
|
|
207
|
+
// Report false so the RSC entry runs the microtask retry loop.
|
|
208
|
+
onFizzAllReadyResolved?.(allReadyFired);
|
|
188
209
|
pipe(passthrough);
|
|
189
210
|
resolve(passthrough);
|
|
190
211
|
}
|
|
191
212
|
},
|
|
192
213
|
|
|
193
214
|
onAllReady() {
|
|
215
|
+
allReadyFired = true;
|
|
194
216
|
allReadyResolve?.();
|
|
195
217
|
},
|
|
196
218
|
|
|
@@ -271,10 +293,12 @@ async function renderViaReadableStream(
|
|
|
271
293
|
deferSuspenseFor?: number;
|
|
272
294
|
signal?: AbortSignal;
|
|
273
295
|
renderTimeoutMs?: number;
|
|
296
|
+
onFizzAllReadyResolved?: (resolved: boolean) => void;
|
|
274
297
|
}
|
|
275
298
|
): Promise<ReadableStream<Uint8Array>> {
|
|
276
299
|
const signal = options?.signal;
|
|
277
300
|
const timeoutMs = options?.renderTimeoutMs;
|
|
301
|
+
const onFizzAllReadyResolved = options?.onFizzAllReadyResolved;
|
|
278
302
|
|
|
279
303
|
// If a render timeout is configured, create a combined signal that
|
|
280
304
|
// fires on either request abort OR timeout — whichever comes first.
|
|
@@ -329,13 +353,25 @@ async function renderViaReadableStream(
|
|
|
329
353
|
// deferSuspenseFor hold: delay the first read so React can resolve
|
|
330
354
|
// fast-completing Suspense boundaries before we read the shell HTML.
|
|
331
355
|
// See design/05-streaming.md §"deferSuspenseFor"
|
|
356
|
+
//
|
|
357
|
+
// While we're holding, also track whether `stream.allReady` settled
|
|
358
|
+
// before the timer. That boolean is the Option-1 signal-promotion
|
|
359
|
+
// short-circuit key — see NavContext._fizzAllReadyResolved and
|
|
360
|
+
// design/spike-signal-promotion-protocol.md.
|
|
361
|
+
let fizzAllReadyResolved = false;
|
|
362
|
+
const allReadyTap = stream.allReady.then(
|
|
363
|
+
() => {
|
|
364
|
+
fizzAllReadyResolved = true;
|
|
365
|
+
},
|
|
366
|
+
() => {
|
|
367
|
+
// allReady rejections are already swallowed above; keep false.
|
|
368
|
+
}
|
|
369
|
+
);
|
|
332
370
|
const deferMs = options?.deferSuspenseFor;
|
|
333
371
|
if (deferMs && deferMs > 0) {
|
|
334
|
-
await Promise.race([
|
|
335
|
-
stream.allReady,
|
|
336
|
-
new Promise<void>((resolve) => setTimeout(resolve, deferMs)),
|
|
337
|
-
]);
|
|
372
|
+
await Promise.race([allReadyTap, new Promise<void>((resolve) => setTimeout(resolve, deferMs))]);
|
|
338
373
|
}
|
|
374
|
+
onFizzAllReadyResolved?.(fizzAllReadyResolved);
|
|
339
375
|
|
|
340
376
|
return wrapStreamWithErrorHandling(stream, signal);
|
|
341
377
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"tracing-DvKLzCFa.js","names":[],"sources":["../../src/server/debug.ts","../../src/server/als-registry.ts","../../src/server/tracing.ts"],"sourcesContent":["/**\n * Runtime debug flag for timber.js.\n *\n * Two distinct functions for two distinct security levels:\n *\n * ## `isDebug()` — server-side logging only\n *\n * Returns true when timber's debug/warning messages should be written to\n * stderr / the server console. This NEVER affects what is sent to the\n * client (no error details, no timing headers, no stack traces).\n *\n * Active when any of:\n * - `NODE_ENV !== 'production'` (standard dev mode)\n * - `TIMBER_DEBUG` env var is set to a truthy value at runtime\n * - `timber.config.ts` has `debug: true`\n *\n * ## `isDevMode()` — client-visible dev behavior\n *\n * Returns true ONLY when `NODE_ENV !== 'production'`. This gates anything\n * that changes what clients can observe:\n * - Dev error pages with stack traces (fallback-error.ts)\n * - Detailed Server-Timing headers (pipeline.ts)\n * - Error messages in action INTERNAL_ERROR payloads (action-client.ts)\n * - Pipeline error handler wiring (Vite overlay)\n *\n * `isDevMode()` is statically replaced in production builds → the guarded\n * code is tree-shaken to zero bytes. TIMBER_DEBUG cannot enable it.\n *\n * Usage:\n * In Cloudflare Workers wrangler.toml:\n * [vars]\n * TIMBER_DEBUG = \"1\"\n *\n * In Node.js:\n * TIMBER_DEBUG=1 node server.js\n *\n * In timber.config.ts:\n * export default { debug: true }\n *\n * See design/13-security.md for the security taxonomy.\n * See design/18-build-system.md for build pipeline details.\n */\n\n// ─── Dev Mode (client-visible) ──────────────────────────────────────────────\n\n/**\n * Check if the application is running in development mode.\n *\n * This is the ONLY function that should gate client-visible dev behavior:\n * - Dev error pages with stack traces\n * - Server-Timing mode default (`'detailed'` in dev, `'total'` in prod)\n * - Error messages in action `INTERNAL_ERROR` payloads\n * - Pipeline error handler wiring (Vite overlay)\n *\n * Returns `process.env.NODE_ENV !== 'production'`, which is statically\n * replaced by the bundler in production builds. Code guarded by this\n * function is tree-shaken to zero bytes in production.\n *\n * TIMBER_DEBUG does NOT enable this — that would leak server internals\n * to clients. Use `isDebug()` for server-side-only logging.\n */\nexport function isDevMode(): boolean {\n return process.env.NODE_ENV !== 'production';\n}\n\n// ─── Debug Flag (server-side logging only) ──────────────────────────────────\n\n/**\n * Config-level debug override. Set via `setDebugFromConfig()` during\n * initialization when timber.config.ts has `debug: true`.\n */\nlet _configDebug = false;\n\n/**\n * Set the debug flag from timber.config.ts.\n * Called during handler initialization.\n */\nexport function setDebugFromConfig(debug: boolean): void {\n _configDebug = debug;\n}\n\n/**\n * Check if timber debug logging is active (server-side only).\n *\n * Returns true if ANY of these conditions hold:\n * - NODE_ENV is not 'production' (standard dev mode)\n * - TIMBER_DEBUG environment variable is set to a truthy value at runtime\n * - timber.config.ts has `debug: true`\n *\n * This function controls ONLY server-side logging — messages written to\n * stderr or the server console. It NEVER affects client-visible behavior\n * (error pages, response headers, action payloads). For client-visible\n * behavior, use `isDevMode()`.\n *\n * The TIMBER_DEBUG check is deliberately written as a dynamic property\n * access so bundlers cannot statically replace it.\n */\nexport function isDebug(): boolean {\n // Fast path: dev mode (statically replaced to `true` in dev, `false` in prod)\n if (process.env.NODE_ENV !== 'production') return true;\n\n // Config override\n if (_configDebug) return true;\n\n // Runtime env var check — uses dynamic access to prevent static replacement.\n // In production builds, process.env.NODE_ENV is statically replaced, but\n // TIMBER_DEBUG must survive as a runtime check. The dynamic key access\n // pattern ensures the bundler treats this as opaque.\n return _readTimberDebugEnv();\n}\n\n/**\n * Read TIMBER_DEBUG from the environment at runtime.\n *\n * Extracted to a separate function to:\n * 1. Prevent bundler inlining (cross-module function calls are not inlined)\n * 2. Handle platforms where `process` may not exist (Cloudflare Workers)\n * 3. Support globalThis.__TIMBER_DEBUG for programmatic control\n */\nfunction _readTimberDebugEnv(): boolean {\n // globalThis override — useful for programmatic control and testing\n if ((globalThis as Record<string, unknown>).__TIMBER_DEBUG) return true;\n\n // process.env — works in Node.js and platforms that polyfill process\n try {\n const key = 'TIMBER_DEBUG';\n const val =\n typeof process !== 'undefined' && process.env\n ? (process.env as Record<string, string | undefined>)[key]\n : undefined;\n if (val && val !== '0' && val !== 'false') return true;\n } catch {\n // process may not exist or env may throw — safe to ignore\n }\n\n return false;\n}\n","/**\n * Centralized AsyncLocalStorage registry for server-side per-request state.\n *\n * ALL ALS instances used by the server framework live here. Individual\n * modules (request-context.ts, tracing.ts, actions.ts, etc.) import from\n * this registry and re-export public accessor functions.\n *\n * Why: ALS instances require singleton semantics — if two copies of the\n * same ALS exist (one from a relative import, one from a barrel import),\n * one module writes to its copy and another reads from an empty copy.\n * Centralizing ALS creation in a single module eliminates this class of bug.\n *\n * The `timber-shims` plugin ensures `@timber-js/app/server` resolves to\n * src/ in RSC and SSR environments, so all import paths converge here.\n *\n * DO NOT create ALS instances outside this file. If you need a new ALS,\n * add it here and import from `./als-registry.js` in the consuming module.\n *\n * See design/18-build-system.md §\"Module Singleton Strategy\" and\n * §\"Singleton State Registry\".\n */\n\nimport { AsyncLocalStorage } from 'node:async_hooks';\nimport type { DebugComponentEntry } from './rsc-entry/helpers.js';\n\n// ─── Request Context ──────────────────────────────────────────────────────\n// Used by: request-context.ts (getHeaders(), getCookies(), getSearchParams())\n// Design doc: design/04-authorization.md\n\n/** @internal — import via request-context.ts public API */\nexport const requestContextAls = new AsyncLocalStorage<RequestContextStore>();\n\nexport interface RequestContextStore {\n /** Incoming request headers (read-only view). */\n headers: Headers;\n /** Raw cookie header string, parsed lazily into a Map on first access. */\n cookieHeader: string;\n /** Lazily-parsed cookie map (mutable — reflects write-overlay from set()). */\n parsedCookies?: Map<string, string>;\n /** Original (pre-overlay) frozen headers, kept for overlay merging. */\n originalHeaders: Headers;\n /**\n * Raw URLSearchParams for the current request.\n * To get typed parsed params, import a search params definition and\n * call `.parse(searchParams())`.\n */\n searchParams: URLSearchParams;\n /**\n * Raw search string from the request URL (e.g. \"?foo=bar&baz=1\").\n * Available synchronously for use in `redirect()` with `preserveSearchParams`.\n */\n searchString: string;\n /**\n * Coerced segment params for the current request.\n * Set by the pipeline after route matching and param coercion, before\n * middleware and rendering. Pages and layouts read params via\n * `getSegmentParams()` instead of receiving them as a prop.\n *\n * See design/07-routing.md §\"params.ts — Convention File for Typed Params\"\n */\n segmentParams?: Record<string, string | string[]>;\n /**\n * The matched segment path (e.g. '/(browse)/[artistSlug]/[year]').\n * Includes route groups and parallel slots — uniquely identifies the\n * segment in the route tree. Set by the pipeline alongside segmentParams.\n * Used by getSegmentParams() for dev-mode validation.\n * See design/41-global-params.md §Runtime Validation\n */\n matchedSegmentPath?: string;\n /**\n * Per-slot coerced segment params, keyed by the slot's full tree path\n * (e.g. '/(browse)/@shows/[artistSlug]/[...year]').\n *\n * Each slot walks its own segment tree and may interpret URL parts\n * differently from the main route (e.g. catch-all vs dynamic). This\n * map stores slot-specific params so getSegmentParams(segmentPath)\n * returns the correct types for slot pages.\n *\n * See design/07-routing.md §\"Parallel Slot Params\"\n */\n slotParamsMap?: Map<string, Record<string, string | string[]>>;\n /** Outgoing Set-Cookie entries (name → serialized value + options). Last write wins. */\n cookieJar: Map<string, CookieEntry>;\n /** Whether the response has flushed (headers committed). */\n flushed: boolean;\n /** Whether the current context allows cookie mutation. */\n mutableContext: boolean;\n /**\n * Set by AccessGate or PageDenyBoundary when a DenySignal is caught\n * server-side (inside the React tree, before React Flight sees it).\n * The pipeline reads this after render to set the HTTP status code.\n * See TIM-666.\n */\n denyStatus?: number;\n /**\n * Dev-only: getter for the current request's RSC debug components.\n * Set by renderRoute() so onPipelineError can include component tree\n * context for render-phase errors without module-level shared state.\n */\n debugComponentsGetter?: () => DebugComponentEntry[];\n}\n\n/** A single outgoing cookie entry in the cookie jar. */\nexport interface CookieEntry {\n name: string;\n value: string;\n options: import('./cookie-context.js').CookieOptions;\n}\n\n// ─── Tracing ──────────────────────────────────────────────────────────────\n// Used by: tracing.ts (getTraceId(), getSpanId())\n// Design doc: design/17-logging.md\n\nexport interface TraceStore {\n /** 32-char lowercase hex trace ID (OTEL or UUID fallback). */\n traceId: string;\n /** OTEL span ID if available, undefined otherwise. */\n spanId?: string;\n}\n\n/** @internal — import via tracing.ts public API */\nexport const traceAls = new AsyncLocalStorage<TraceStore>();\n\n// ─── Server-Timing ────────────────────────────────────────────────────────\n// Used by: server-timing.ts (recordTiming(), withTiming())\n// Design doc: (dev-only performance instrumentation)\n\nexport interface TimingStore {\n entries: import('./server-timing.js').TimingEntry[];\n}\n\n/** @internal — import via server-timing.ts public API */\nexport const timingAls = new AsyncLocalStorage<TimingStore>();\n\n// ─── Revalidation ─────────────────────────────────────────────────────────\n// Used by: actions.ts (revalidatePath(), revalidateTag())\n// Design doc: design/08-forms-and-actions.md\n\nexport interface RevalidationState {\n /** Paths to re-render (populated by revalidatePath calls). */\n paths: string[];\n /** Tags to invalidate (populated by revalidateTag calls). */\n tags: string[];\n}\n\n/** @internal — import via actions.ts public API */\nexport const revalidationAls = new AsyncLocalStorage<RevalidationState>();\n\n// ─── Form Flash ───────────────────────────────────────────────────────────\n// Used by: form-flash.ts (getFormFlash())\n// Design doc: design/08-forms-and-actions.md §\"No-JS Error Round-Trip\"\n\n/** @internal — import via form-flash.ts public API */\nexport const formFlashAls = new AsyncLocalStorage<import('./form-flash.js').FormFlashData>();\n\n// ─── Early Hints Sender ──────────────────────────────────────────────────\n// Used by: early-hints-sender.ts (sendEarlyHints103())\n// Design doc: design/02-rendering-pipeline.md §\"Early Hints (103)\"\n\n/** Function that sends Link header values as a 103 Early Hints response. */\nexport type EarlyHintsSenderFn = (links: string[]) => void;\n\n/** @internal — import via early-hints-sender.ts public API */\nexport const earlyHintsSenderAls = new AsyncLocalStorage<EarlyHintsSenderFn>();\n\n// ─── waitUntil Bridge ────────────────────────────────────────────────────\n// Used by: waituntil-bridge.ts (waitUntil())\n// Design doc: design/11-platform.md §\"waitUntil()\"\n\n/** Function that extends the request lifecycle with a background promise. */\nexport type WaitUntilFn = (promise: Promise<unknown>) => void;\n\n/** @internal — import via waituntil-bridge.ts public API */\nexport const waitUntilAls = new AsyncLocalStorage<WaitUntilFn>();\n","/**\n * Tracing — per-request trace ID via AsyncLocalStorage, OTEL span helpers.\n *\n * getTraceId() is always available in server code (middleware, access, components, actions).\n * Returns a 32-char lowercase hex string — the OTEL trace ID when an SDK is active,\n * or a crypto.randomUUID()-derived fallback otherwise.\n *\n * See design/17-logging.md §\"trace_id is Always Set\"\n */\n\nimport { randomUUID } from 'node:crypto';\nimport { traceAls, type TraceStore } from './als-registry.js';\n\n// Re-export the TraceStore type for public API consumers.\nexport type { TraceStore } from './als-registry.js';\n\n// ─── Public API ───────────────────────────────────────────────────────────\n\n/**\n * Returns the current request's trace ID — always a 32-char lowercase hex string.\n *\n * With OTEL: the real OTEL trace ID (matches Jaeger/Honeycomb/Datadog).\n * Without OTEL: crypto.randomUUID() with hyphens stripped.\n *\n * Throws if called outside a request context (no ALS store).\n */\nexport function getTraceId(): string {\n const store = traceAls.getStore();\n if (!store) {\n throw new Error(\n '[timber] getTraceId() called outside of a request context. ' +\n 'It can only be used in middleware, access checks, server components, and server actions.'\n );\n }\n return store.traceId;\n}\n\n/**\n * Returns the current OTEL span ID if available, undefined otherwise.\n */\nexport function getSpanId(): string | undefined {\n return traceAls.getStore()?.spanId;\n}\n\n// ─── Framework-Internal Helpers ───────────────────────────────────────────\n\n/**\n * Generate a 32-char lowercase hex ID from crypto.randomUUID().\n * Same format as OTEL trace IDs — zero-friction upgrade path.\n */\nexport function generateTraceId(): string {\n return randomUUID().replace(/-/g, '');\n}\n\n/**\n * Run a callback within a trace context. Used by the pipeline to establish\n * per-request ALS scope.\n */\nexport function runWithTraceId<T>(id: string, fn: () => T): T {\n return traceAls.run({ traceId: id }, fn);\n}\n\n/**\n * Replace the trace ID in the current ALS store. Used when OTEL creates\n * a root span and we want to switch from the UUID fallback to the real\n * OTEL trace ID.\n */\nexport function replaceTraceId(newTraceId: string, newSpanId?: string): void {\n const store = traceAls.getStore();\n if (store) {\n store.traceId = newTraceId;\n store.spanId = newSpanId;\n }\n}\n\n/**\n * Update the span ID in the current ALS store. Used when entering a new\n * OTEL span to keep log–trace correlation accurate.\n */\nexport function updateSpanId(newSpanId: string | undefined): void {\n const store = traceAls.getStore();\n if (store) {\n store.spanId = newSpanId;\n }\n}\n\n/**\n * Get the current trace store, or undefined if outside a request context.\n * Framework-internal — use getTraceId()/getSpanId() in user code.\n */\nexport function getTraceStore(): TraceStore | undefined {\n return traceAls.getStore();\n}\n\n// ─── Dev-Mode OTEL Auto-Init ─────────────────────────────────────────────\n\n/**\n * Initialize a minimal OTEL SDK in dev mode so spans are recorded and\n * fed to the DevSpanProcessor for dev log output.\n *\n * If the user already configured an OTEL SDK in register(), we add\n * our DevSpanProcessor alongside theirs. If no SDK is configured,\n * we create a BasicTracerProvider with our processor.\n *\n * Only called in dev mode — zero overhead in production.\n */\nexport async function initDevTracing(\n config: import('../dev-tools/logger.js').DevLoggerConfig\n): Promise<void> {\n const api = await getOtelApi();\n if (!api) return;\n\n let DevSpanProcessor: typeof import('../dev-tools/instrumentation.js').DevSpanProcessor;\n let BasicTracerProvider: typeof import('@opentelemetry/sdk-trace-base').BasicTracerProvider;\n let AsyncLocalStorageContextManager: typeof import('@opentelemetry/context-async-hooks').AsyncLocalStorageContextManager;\n\n try {\n ({ DevSpanProcessor } = await import('../dev-tools/instrumentation.js'));\n ({ BasicTracerProvider } = await import('@opentelemetry/sdk-trace-base'));\n ({ AsyncLocalStorageContextManager } = await import('@opentelemetry/context-async-hooks'));\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n console.warn(`[timber] Dev tracing disabled — failed to load OTEL packages:\\n ${msg}`);\n return;\n }\n\n const processor = new DevSpanProcessor(config);\n\n // Register a context manager so OTEL can propagate the active span\n // across async boundaries. Without this, startActiveSpan can't make\n // spans \"active\" — child spans get random trace IDs and getActiveSpan()\n // returns undefined.\n const contextManager = new AsyncLocalStorageContextManager();\n contextManager.enable();\n api.context.setGlobalContextManager(contextManager);\n\n // Create a minimal TracerProvider with our DevSpanProcessor.\n // If the user also configures an SDK in register(), their provider\n // will coexist — the global provider set last wins for new tracers,\n // but our processor captures all spans from the timber.js tracer.\n const provider = new BasicTracerProvider({\n spanProcessors: [processor],\n });\n api.trace.setGlobalTracerProvider(provider);\n\n // Reset cached tracer so next getTracer() picks up the new provider\n _tracer = undefined;\n}\n\n// ─── OTEL Span Helpers ───────────────────────────────────────────────────\n\n/**\n * Attempt to get the @opentelemetry/api tracer. Returns undefined if the\n * package is not installed or no SDK is registered.\n *\n * timber.js depends on @opentelemetry/api as the vendor-neutral interface.\n * The API is a no-op by default — spans are only emitted when the developer\n * initializes an SDK in register().\n */\nlet _otelApi: typeof import('@opentelemetry/api') | null | undefined;\n\nasync function getOtelApi(): Promise<typeof import('@opentelemetry/api') | null> {\n if (_otelApi === undefined) {\n try {\n _otelApi = await import('@opentelemetry/api');\n } catch {\n _otelApi = null;\n }\n }\n return _otelApi;\n}\n\n/** OTEL tracer instance, lazily created. */\nlet _tracer: import('@opentelemetry/api').Tracer | null | undefined;\n\n/**\n * Get the timber.js OTEL tracer. Returns null if @opentelemetry/api is not available.\n */\nexport async function getTracer(): Promise<import('@opentelemetry/api').Tracer | null> {\n if (_tracer === undefined) {\n const api = await getOtelApi();\n if (api) {\n _tracer = api.trace.getTracer('timber.js');\n } else {\n _tracer = null;\n }\n }\n return _tracer;\n}\n\n/**\n * Run a function within an OTEL span. If OTEL is not available, runs the function\n * directly without any span overhead.\n *\n * Automatically:\n * - Creates the span as a child of the current context\n * - Updates the ALS span ID for log–trace correlation\n * - Ends the span when the function completes\n * - Records exceptions on error\n */\nexport async function withSpan<T>(\n name: string,\n attributes: Record<string, string | number | boolean>,\n fn: () => T | Promise<T>\n): Promise<T> {\n const tracer = await getTracer();\n if (!tracer) {\n return fn();\n }\n\n const api = (await getOtelApi())!;\n return tracer.startActiveSpan(name, { attributes }, async (span) => {\n const prevSpanId = getSpanId();\n updateSpanId(span.spanContext().spanId);\n try {\n const result = await fn();\n span.setStatus({ code: api.SpanStatusCode.OK });\n return result;\n } catch (error) {\n span.setStatus({ code: api.SpanStatusCode.ERROR });\n if (error instanceof Error) {\n span.recordException(error);\n }\n throw error;\n } finally {\n span.end();\n updateSpanId(prevSpanId);\n }\n });\n}\n\n/**\n * Set an attribute on the current active span (if any).\n * Used for setting span attributes after span creation (e.g. timber.result on access spans).\n */\nexport async function setSpanAttribute(\n key: string,\n value: string | number | boolean\n): Promise<void> {\n const api = await getOtelApi();\n if (!api) return;\n\n const activeSpan = api.trace.getActiveSpan();\n if (activeSpan) {\n activeSpan.setAttribute(key, value);\n }\n}\n\n/**\n * Add a span event to the current active span (if any).\n * Used for timber.cache HIT/MISS events — recorded as span events, not child spans.\n */\nexport async function addSpanEvent(\n name: string,\n attributes?: Record<string, string | number | boolean>\n): Promise<void> {\n const api = await getOtelApi();\n if (!api) return;\n\n const activeSpan = api.trace.getActiveSpan();\n if (activeSpan) {\n activeSpan.addEvent(name, attributes);\n }\n}\n\n/**\n * Fire-and-forget span event — no await, no microtask overhead.\n *\n * Used on the cache hot path where awaiting addSpanEvent creates an\n * unnecessary microtask per cache operation. If OTEL is not loaded yet,\n * the event is silently dropped (acceptable for diagnostics).\n *\n * See TIM-370 for perf motivation.\n */\nexport function addSpanEventSync(\n name: string,\n attributes?: Record<string, string | number | boolean>\n): void {\n // Fast path: if OTEL API hasn't been loaded yet, skip entirely.\n // _otelApi is undefined (not yet loaded), null (failed to load), or the module.\n if (!_otelApi) return;\n\n const activeSpan = _otelApi.trace.getActiveSpan();\n if (activeSpan) {\n activeSpan.addEvent(name, attributes);\n }\n}\n\n/**\n * Try to extract the OTEL trace ID from the current active span context.\n * Returns undefined if OTEL is not active or no span exists.\n */\nexport async function getOtelTraceId(): Promise<{ traceId: string; spanId: string } | undefined> {\n const api = await getOtelApi();\n if (!api) return undefined;\n\n const activeSpan = api.trace.getActiveSpan();\n if (!activeSpan) return undefined;\n\n const ctx = activeSpan.spanContext();\n // OTEL uses \"0000000000000000\" as invalid trace IDs\n if (!ctx.traceId || ctx.traceId === '00000000000000000000000000000000') {\n return undefined;\n }\n\n return { traceId: ctx.traceId, spanId: ctx.spanId };\n}\n"],"mappings":"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"}
|