@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.
Files changed (142) hide show
  1. package/dist/_chunks/{als-registry-k-AtAQ9R.js → als-registry-B7DbZ2hS.js} +1 -1
  2. package/dist/_chunks/{als-registry-k-AtAQ9R.js.map → als-registry-B7DbZ2hS.js.map} +1 -1
  3. package/dist/_chunks/debug-B3Gypr3D.js +108 -0
  4. package/dist/_chunks/debug-B3Gypr3D.js.map +1 -0
  5. package/dist/_chunks/{format-DNt20Kt8.js → format-RyoGQL74.js} +3 -2
  6. package/dist/_chunks/format-RyoGQL74.js.map +1 -0
  7. package/dist/_chunks/{interception-DGDIjDbR.js → interception-BOoWmLUA.js} +2 -2
  8. package/dist/_chunks/{interception-DGDIjDbR.js.map → interception-BOoWmLUA.js.map} +1 -1
  9. package/dist/_chunks/{metadata-routes-CQCnF4VK.js → metadata-routes-Cjmvi3rQ.js} +1 -1
  10. package/dist/_chunks/{metadata-routes-CQCnF4VK.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
  11. package/dist/_chunks/{request-context-CRj2Zh1E.js → request-context-BQUC8PHn.js} +5 -4
  12. package/dist/_chunks/request-context-BQUC8PHn.js.map +1 -0
  13. package/dist/_chunks/{ssr-data-DLnbYpj1.js → ssr-data-MjmprTmO.js} +1 -1
  14. package/dist/_chunks/{ssr-data-DLnbYpj1.js.map → ssr-data-MjmprTmO.js.map} +1 -1
  15. package/dist/_chunks/{tracing-DF0G3FB7.js → tracing-CemImE6h.js} +17 -3
  16. package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-CemImE6h.js.map} +1 -1
  17. package/dist/_chunks/{use-cookie-dDbpCTx-.js → use-cookie-DX-l1_5E.js} +2 -2
  18. package/dist/_chunks/{use-cookie-dDbpCTx-.js.map → use-cookie-DX-l1_5E.js.map} +1 -1
  19. package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-D5KaffOK.js} +1 -1
  20. package/dist/_chunks/{use-query-states-DAhgj8Gx.js.map → use-query-states-D5KaffOK.js.map} +1 -1
  21. package/dist/adapters/compress-module.d.ts.map +1 -1
  22. package/dist/adapters/nitro.d.ts +17 -1
  23. package/dist/adapters/nitro.d.ts.map +1 -1
  24. package/dist/adapters/nitro.js +26 -9
  25. package/dist/adapters/nitro.js.map +1 -1
  26. package/dist/cache/fast-hash.d.ts +22 -0
  27. package/dist/cache/fast-hash.d.ts.map +1 -0
  28. package/dist/cache/index.js +52 -10
  29. package/dist/cache/index.js.map +1 -1
  30. package/dist/cache/register-cached-function.d.ts.map +1 -1
  31. package/dist/cache/timber-cache.d.ts.map +1 -1
  32. package/dist/client/error-boundary.js +1 -1
  33. package/dist/client/index.js +3 -3
  34. package/dist/client/index.js.map +1 -1
  35. package/dist/client/link.d.ts.map +1 -1
  36. package/dist/client/router.d.ts.map +1 -1
  37. package/dist/client/segment-context.d.ts +1 -1
  38. package/dist/client/segment-context.d.ts.map +1 -1
  39. package/dist/client/segment-merger.d.ts.map +1 -1
  40. package/dist/client/stale-reload.d.ts.map +1 -1
  41. package/dist/client/top-loader.d.ts.map +1 -1
  42. package/dist/client/transition-root.d.ts +1 -1
  43. package/dist/client/transition-root.d.ts.map +1 -1
  44. package/dist/cookies/index.js +4 -4
  45. package/dist/fonts/css.d.ts +1 -0
  46. package/dist/fonts/css.d.ts.map +1 -1
  47. package/dist/fonts/local.d.ts +4 -2
  48. package/dist/fonts/local.d.ts.map +1 -1
  49. package/dist/index.d.ts +28 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +249 -21
  52. package/dist/index.js.map +1 -1
  53. package/dist/plugins/build-report.d.ts +11 -1
  54. package/dist/plugins/build-report.d.ts.map +1 -1
  55. package/dist/plugins/entries.d.ts +7 -0
  56. package/dist/plugins/entries.d.ts.map +1 -1
  57. package/dist/plugins/fonts.d.ts +9 -1
  58. package/dist/plugins/fonts.d.ts.map +1 -1
  59. package/dist/plugins/mdx.d.ts +6 -0
  60. package/dist/plugins/mdx.d.ts.map +1 -1
  61. package/dist/plugins/server-bundle.d.ts.map +1 -1
  62. package/dist/routing/index.js +1 -1
  63. package/dist/rsc-runtime/ssr.d.ts +12 -0
  64. package/dist/rsc-runtime/ssr.d.ts.map +1 -1
  65. package/dist/search-params/index.js +1 -1
  66. package/dist/server/access-gate.d.ts.map +1 -1
  67. package/dist/server/action-client.d.ts.map +1 -1
  68. package/dist/server/debug.d.ts +82 -0
  69. package/dist/server/debug.d.ts.map +1 -0
  70. package/dist/server/deny-renderer.d.ts.map +1 -1
  71. package/dist/server/dev-warnings.d.ts.map +1 -1
  72. package/dist/server/html-injectors.d.ts.map +1 -1
  73. package/dist/server/index.js +32 -23
  74. package/dist/server/index.js.map +1 -1
  75. package/dist/server/logger.d.ts.map +1 -1
  76. package/dist/server/node-stream-transforms.d.ts +65 -0
  77. package/dist/server/node-stream-transforms.d.ts.map +1 -0
  78. package/dist/server/pipeline.d.ts +7 -4
  79. package/dist/server/pipeline.d.ts.map +1 -1
  80. package/dist/server/primitives.d.ts.map +1 -1
  81. package/dist/server/request-context.d.ts.map +1 -1
  82. package/dist/server/route-element-builder.d.ts.map +1 -1
  83. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  84. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  85. package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
  86. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  87. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  88. package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
  89. package/dist/server/ssr-entry.d.ts.map +1 -1
  90. package/dist/server/ssr-render.d.ts +34 -21
  91. package/dist/server/ssr-render.d.ts.map +1 -1
  92. package/dist/server/tracing.d.ts +10 -0
  93. package/dist/server/tracing.d.ts.map +1 -1
  94. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  95. package/dist/shims/image.d.ts +15 -15
  96. package/package.json +1 -1
  97. package/src/adapters/compress-module.ts +21 -4
  98. package/src/adapters/nitro.ts +31 -5
  99. package/src/cache/fast-hash.ts +34 -0
  100. package/src/cache/register-cached-function.ts +7 -3
  101. package/src/cache/timber-cache.ts +17 -10
  102. package/src/client/browser-entry.ts +10 -6
  103. package/src/client/link.tsx +14 -9
  104. package/src/client/router.ts +4 -6
  105. package/src/client/segment-context.ts +6 -1
  106. package/src/client/segment-merger.ts +2 -8
  107. package/src/client/stale-reload.ts +5 -7
  108. package/src/client/top-loader.tsx +8 -7
  109. package/src/client/transition-root.tsx +7 -1
  110. package/src/fonts/css.ts +2 -1
  111. package/src/fonts/local.ts +7 -3
  112. package/src/index.ts +35 -2
  113. package/src/plugins/build-report.ts +23 -3
  114. package/src/plugins/entries.ts +9 -4
  115. package/src/plugins/fonts.ts +171 -19
  116. package/src/plugins/mdx.ts +9 -5
  117. package/src/plugins/server-bundle.ts +4 -0
  118. package/src/rsc-runtime/ssr.ts +50 -0
  119. package/src/rsc-runtime/vendor-types.d.ts +7 -0
  120. package/src/server/access-gate.tsx +3 -2
  121. package/src/server/action-client.ts +15 -5
  122. package/src/server/debug.ts +137 -0
  123. package/src/server/deny-renderer.ts +3 -2
  124. package/src/server/dev-warnings.ts +2 -1
  125. package/src/server/html-injectors.ts +30 -10
  126. package/src/server/logger.ts +4 -3
  127. package/src/server/node-stream-transforms.ts +315 -0
  128. package/src/server/pipeline.ts +34 -20
  129. package/src/server/primitives.ts +2 -1
  130. package/src/server/request-context.ts +3 -2
  131. package/src/server/route-element-builder.ts +1 -6
  132. package/src/server/rsc-entry/index.ts +50 -7
  133. package/src/server/rsc-entry/rsc-payload.ts +42 -7
  134. package/src/server/rsc-entry/rsc-stream.ts +10 -5
  135. package/src/server/rsc-entry/ssr-renderer.ts +12 -5
  136. package/src/server/rsc-prop-warnings.ts +3 -1
  137. package/src/server/ssr-entry.ts +130 -8
  138. package/src/server/ssr-render.ts +168 -57
  139. package/src/server/tracing.ts +23 -0
  140. package/src/server/waituntil-bridge.ts +4 -1
  141. package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
  142. package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
@@ -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
- // Wait one macrotask so the HTML shell chunk flows through
205
- // transform() first. The browser needs the shell HTML before
206
- // RSC data script tags arrive.
207
- await new Promise<void>((r) => setTimeout(r, 0));
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. RSC and HTML are driven by the
219
- // same source each RSC chunk typically produces a
220
- // corresponding HTML chunk from SSR.
221
- await new Promise<void>((r) => setTimeout(r, 0));
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
- // Start pulling RSC scripts into the buffer (if not started)
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 stream is done drain remaining RSC chunks at body level
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
@@ -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 (process.env.NODE_ENV !== 'production') {
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 (process.env.NODE_ENV !== 'production') {
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 (process.env.NODE_ENV !== 'production') {
127
+ } else if (isDebug()) {
127
128
  console.error('[timber] proxy error', data.error);
128
129
  }
129
130
  }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Node.js native stream transforms for SSR HTML post-processing.
3
+ *
4
+ * These are Node.js Transform stream equivalents of the Web Stream
5
+ * transforms in html-injectors.ts. Used on Node.js/Bun where native
6
+ * streams (C++ backed) are faster than Web Streams (JS reimplementation).
7
+ *
8
+ * The transforms are pure string operations on HTML chunks — the same
9
+ * logic as the Web Stream versions, just wrapped in Node.js Transform
10
+ * instead of Web TransformStream.
11
+ *
12
+ * Architecture:
13
+ * renderToPipeableStream → pipe(errorHandler) → pipe(headInjector)
14
+ * → pipe(flightInjector) → Readable.toWeb() → Response
15
+ *
16
+ * All chunks stay in C++ Node.js stream buffers until the final
17
+ * Readable.toWeb() conversion for the Response body.
18
+ */
19
+
20
+ import { Transform } from 'node:stream';
21
+ import { createGzip } from 'node:zlib';
22
+
23
+ // ─── Head Injection ──────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Node.js Transform that injects HTML content before </head>.
27
+ *
28
+ * Equivalent to injectHead() in html-injectors.ts. Streams chunks
29
+ * through immediately, keeping only a small trailing buffer to handle
30
+ * </head> split across chunk boundaries.
31
+ */
32
+ export function createNodeHeadInjector(headHtml: string): Transform {
33
+ if (!headHtml) {
34
+ return new Transform({
35
+ transform(chunk, _enc, cb) {
36
+ cb(null, chunk);
37
+ },
38
+ });
39
+ }
40
+
41
+ const target = '</head>';
42
+ const tailLen = target.length - 1;
43
+ let injected = false;
44
+ let tail = '';
45
+
46
+ return new Transform({
47
+ transform(chunk: Buffer, _encoding, callback) {
48
+ if (injected) {
49
+ callback(null, chunk);
50
+ return;
51
+ }
52
+
53
+ const text = tail + chunk.toString('utf-8');
54
+ const tagIndex = text.indexOf(target);
55
+
56
+ if (tagIndex !== -1) {
57
+ const before = text.slice(0, tagIndex);
58
+ const after = text.slice(tagIndex);
59
+ this.push(Buffer.from(before + headHtml + after, 'utf-8'));
60
+ injected = true;
61
+ tail = '';
62
+ callback();
63
+ } else {
64
+ const safeEnd = Math.max(0, text.length - tailLen);
65
+ if (safeEnd > 0) {
66
+ this.push(Buffer.from(text.slice(0, safeEnd), 'utf-8'));
67
+ }
68
+ tail = text.slice(safeEnd);
69
+ callback();
70
+ }
71
+ },
72
+ flush(callback) {
73
+ if (!injected && tail) {
74
+ this.push(Buffer.from(tail, 'utf-8'));
75
+ }
76
+ callback();
77
+ },
78
+ });
79
+ }
80
+
81
+ // ─── RSC Flight Injection ────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Escape a string for safe embedding inside a `<script>` tag within
85
+ * a JSON-encoded value. Same as htmlEscapeJsonString in html-injectors.ts.
86
+ */
87
+ function htmlEscapeJsonString(str: string): string {
88
+ return str
89
+ .replace(/</g, '\\u003c')
90
+ .replace(/\u2028/g, '\\u2028')
91
+ .replace(/\u2029/g, '\\u2029');
92
+ }
93
+
94
+ /**
95
+ * Node.js Transform that merges RSC script tags into the HTML stream.
96
+ *
97
+ * Equivalent to injectRscPayload() in html-injectors.ts. Combines
98
+ * createInlinedRscStream + createFlightInjectionTransform into a single
99
+ * Node.js Transform.
100
+ *
101
+ * 1. Strips `</body></html>` from the shell so all subsequent content
102
+ * is at `<body>` level.
103
+ * 2. Reads RSC chunks from the provided ReadableStream and injects them
104
+ * as `<script>` tags after HTML chunks.
105
+ * 3. Re-emits `</body></html>` at the very end.
106
+ *
107
+ * The RSC stream is a Web ReadableStream (from the tee'd RSC Flight
108
+ * stream). We read from it using the Web API — this is the one bridge
109
+ * point between Web Streams and Node.js streams in the pipeline.
110
+ */
111
+ export function createNodeFlightInjector(
112
+ rscStream: ReadableStream<Uint8Array> | undefined
113
+ ): Transform {
114
+ if (!rscStream) {
115
+ return new Transform({
116
+ transform(chunk, _enc, cb) {
117
+ cb(null, chunk);
118
+ },
119
+ });
120
+ }
121
+
122
+ const suffix = '</body></html>';
123
+ const suffixBuf = Buffer.from(suffix, 'utf-8');
124
+ const rscReader = rscStream.getReader();
125
+ const decoder = new TextDecoder('utf-8', { fatal: true });
126
+
127
+ let pullPromise: Promise<void> | null = null;
128
+ let donePulling = false;
129
+ let pullError: unknown = null;
130
+ let foundSuffix = false;
131
+ let htmlStreamFinished = false;
132
+ const pending: Buffer[] = [];
133
+
134
+ // Emit bootstrap signal
135
+ const bootstrap = `<script>(self.__timber_f=self.__timber_f||[]).push(${htmlEscapeJsonString(JSON.stringify([0]))})</script>`;
136
+ pending.push(Buffer.from(bootstrap, 'utf-8'));
137
+
138
+ async function pullLoop(): Promise<void> {
139
+ await new Promise<void>((r) => setImmediate(r));
140
+ try {
141
+ for (;;) {
142
+ const { done, value } = await rscReader.read();
143
+ if (done) {
144
+ donePulling = true;
145
+ return;
146
+ }
147
+ const decoded = decoder.decode(value, { stream: true });
148
+ const escaped = htmlEscapeJsonString(JSON.stringify([1, decoded]));
149
+ pending.push(Buffer.from(`<script>self.__timber_f.push(${escaped})</script>`, 'utf-8'));
150
+ if (!htmlStreamFinished) {
151
+ await new Promise<void>((r) => setImmediate(r));
152
+ }
153
+ }
154
+ } catch (err) {
155
+ pullError = err;
156
+ donePulling = true;
157
+ }
158
+ }
159
+
160
+ function drainPending(transform: Transform): void {
161
+ while (pending.length > 0) {
162
+ transform.push(pending.shift()!);
163
+ }
164
+ if (pullError) {
165
+ transform.destroy(pullError instanceof Error ? pullError : new Error(String(pullError)));
166
+ pullError = null;
167
+ }
168
+ }
169
+
170
+ return new Transform({
171
+ transform(chunk: Buffer, _encoding, callback) {
172
+ if (!pullPromise) {
173
+ pullPromise = pullLoop();
174
+ }
175
+
176
+ if (foundSuffix) {
177
+ this.push(chunk);
178
+ if (pending.length > 0) drainPending(this);
179
+ callback();
180
+ return;
181
+ }
182
+
183
+ const text = chunk.toString('utf-8');
184
+ const idx = text.indexOf(suffix);
185
+ if (idx !== -1) {
186
+ foundSuffix = true;
187
+ const before = text.slice(0, idx);
188
+ const after = text.slice(idx + suffix.length);
189
+ if (before) this.push(Buffer.from(before, 'utf-8'));
190
+ if (pending.length > 0) drainPending(this);
191
+ if (after) this.push(Buffer.from(after, 'utf-8'));
192
+ } else {
193
+ this.push(chunk);
194
+ }
195
+ callback();
196
+ },
197
+ flush(callback) {
198
+ htmlStreamFinished = true;
199
+
200
+ const finish = () => {
201
+ drainPending(this);
202
+ if (foundSuffix) {
203
+ this.push(suffixBuf);
204
+ }
205
+ callback();
206
+ };
207
+
208
+ if (donePulling) {
209
+ finish();
210
+ return;
211
+ }
212
+ if (!pullPromise) {
213
+ pullPromise = pullLoop();
214
+ }
215
+ pullPromise.then(finish);
216
+ },
217
+ });
218
+ }
219
+
220
+ // ─── Error Handling ──────────────────────────────────────────────────────────
221
+
222
+ const NOINDEX_SCRIPT =
223
+ '<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
224
+
225
+ /**
226
+ * Node.js Transform that catches post-shell streaming errors.
227
+ *
228
+ * Equivalent to wrapStreamWithErrorHandling() in ssr-render.ts.
229
+ * Catches errors from React's streaming phase (deny/throw inside Suspense
230
+ * after the shell has flushed) and closes the stream cleanly.
231
+ */
232
+ export function createNodeErrorHandler(signal?: AbortSignal): Transform {
233
+ const transform = new Transform({
234
+ transform(chunk, _encoding, callback) {
235
+ callback(null, chunk);
236
+ },
237
+ });
238
+
239
+ transform.on('error', (error) => {
240
+ const isAbort =
241
+ (error instanceof DOMException && error.name === 'AbortError') ||
242
+ (error instanceof Error && error.name === 'AbortError') ||
243
+ signal?.aborted;
244
+
245
+ if (isAbort) {
246
+ transform.end();
247
+ return;
248
+ }
249
+
250
+ console.error('[timber] SSR streaming error (post-shell):', error.message || error);
251
+ transform.push(Buffer.from(NOINDEX_SCRIPT, 'utf-8'));
252
+ transform.end();
253
+ });
254
+
255
+ return transform;
256
+ }
257
+
258
+ // ─── Compression ─────────────────────────────────────────────────────────────
259
+
260
+ const COMPRESSIBLE_TYPES = new Set([
261
+ 'text/html',
262
+ 'text/css',
263
+ 'text/plain',
264
+ 'text/xml',
265
+ 'text/javascript',
266
+ 'text/x-component',
267
+ 'application/json',
268
+ 'application/javascript',
269
+ 'application/xml',
270
+ 'application/xhtml+xml',
271
+ 'application/rss+xml',
272
+ 'application/atom+xml',
273
+ 'image/svg+xml',
274
+ ]);
275
+
276
+ /**
277
+ * Create a Node.js gzip Transform using native node:zlib.
278
+ *
279
+ * Uses `createGzip()` which is backed by C++ zlib — significantly faster
280
+ * than the Web Streams `CompressionStream` API (which is a JS wrapper
281
+ * around the same zlib but with per-chunk Promise overhead).
282
+ *
283
+ * Returns null if the response shouldn't be compressed (wrong content type,
284
+ * client doesn't accept gzip, already encoded, etc.).
285
+ */
286
+ export function createNodeGzipCompressor(
287
+ requestHeaders: Headers,
288
+ responseHeaders: Headers
289
+ ): Transform | null {
290
+ // Check Accept-Encoding
291
+ const acceptEncoding = requestHeaders.get('accept-encoding') || '';
292
+ if (!acceptEncoding.includes('gzip')) return null;
293
+
294
+ // Check content type is compressible
295
+ const contentType = responseHeaders.get('content-type') || '';
296
+ const mimeType = contentType.split(';')[0].trim().toLowerCase();
297
+ if (!COMPRESSIBLE_TYPES.has(mimeType)) return null;
298
+
299
+ // Don't double-compress
300
+ if (responseHeaders.has('content-encoding')) return null;
301
+
302
+ // Set response headers for gzip
303
+ responseHeaders.set('content-encoding', 'gzip');
304
+ responseHeaders.delete('content-length');
305
+ const existingVary = responseHeaders.get('vary');
306
+ if (existingVary) {
307
+ if (!existingVary.toLowerCase().includes('accept-encoding')) {
308
+ responseHeaders.set('vary', existingVary + ', Accept-Encoding');
309
+ }
310
+ } else {
311
+ responseHeaders.set('vary', 'Accept-Encoding');
312
+ }
313
+
314
+ return createGzip();
315
+ }
@@ -117,12 +117,15 @@ export interface PipelineConfig {
117
117
  */
118
118
  interceptionRewrites?: import('#/routing/interception.js').InterceptionRewrite[];
119
119
  /**
120
- * Emit Server-Timing header on responses for Chrome DevTools visibility.
121
- * Only enable in dev mode — exposes internal timing data.
120
+ * Control Server-Timing header output.
122
121
  *
123
- * Default: false (production-safe).
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
- enableServerTiming?: boolean;
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
- enableServerTiming = false,
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 (enableServerTiming) {
225
- const serverTiming = getServerTimingHeader();
226
- if (serverTiming) {
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', serverTiming);
230
+ result.headers.set('Server-Timing', timingHeader);
229
231
  }
230
- } else {
231
- // Production: emit total request duration only.
232
- // No phase breakdown prevents information disclosure
233
- // while giving browser DevTools useful timing data.
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 enableServerTiming ? runWithTimingCollector(runRequest) : runRequest();
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
- enableServerTiming ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
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
- enableServerTiming ? withTiming('mw', 'middleware.ts', middlewareFn) : middlewareFn()
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
- enableServerTiming ? withTiming('render', 'RSC + SSR render', renderFn) : renderFn()
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
  }
@@ -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 (process.env.NODE_ENV === 'production') return;
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 (process.env.NODE_ENV !== 'production') {
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 (process.env.NODE_ENV !== 'production') {
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