@timber-js/app 0.2.0-alpha.127 → 0.2.0-alpha.128

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.
@@ -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.params,
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.params);
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 });
@@ -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
  }