@timber-js/app 0.1.20 → 0.1.22

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 (137) hide show
  1. package/dist/_chunks/als-registry-c0AGnbqS.js +39 -0
  2. package/dist/_chunks/als-registry-c0AGnbqS.js.map +1 -0
  3. package/dist/_chunks/{interception-c-a3uODY.js → interception-DGDIjDbR.js} +10 -3
  4. package/dist/_chunks/interception-DGDIjDbR.js.map +1 -0
  5. package/dist/_chunks/{metadata-routes-BDnswgRO.js → metadata-routes-CQCnF4VK.js} +14 -2
  6. package/dist/_chunks/metadata-routes-CQCnF4VK.js.map +1 -0
  7. package/dist/_chunks/{request-context-BzES06i1.js → request-context-C69VW4xS.js} +2 -4
  8. package/dist/_chunks/request-context-C69VW4xS.js.map +1 -0
  9. package/dist/_chunks/ssr-data-B2yikEEB.js +90 -0
  10. package/dist/_chunks/ssr-data-B2yikEEB.js.map +1 -0
  11. package/dist/_chunks/{tracing-BtOwb8O6.js → tracing-tIvqStk8.js} +2 -3
  12. package/dist/_chunks/tracing-tIvqStk8.js.map +1 -0
  13. package/dist/_chunks/{use-cookie-D2cZu0jK.js → use-cookie-D5aS4slY.js} +2 -2
  14. package/dist/_chunks/{use-cookie-D2cZu0jK.js.map → use-cookie-D5aS4slY.js.map} +1 -1
  15. package/dist/_chunks/{use-query-states-wEXY2JQB.js → use-query-states-DAhgj8Gx.js} +1 -1
  16. package/dist/_chunks/{use-query-states-wEXY2JQB.js.map → use-query-states-DAhgj8Gx.js.map} +1 -1
  17. package/dist/cache/index.js +2 -1
  18. package/dist/cache/index.js.map +1 -1
  19. package/dist/client/error-boundary.js +1 -1
  20. package/dist/client/index.d.ts +1 -1
  21. package/dist/client/index.d.ts.map +1 -1
  22. package/dist/client/index.js +40 -26
  23. package/dist/client/index.js.map +1 -1
  24. package/dist/client/router-ref.d.ts.map +1 -1
  25. package/dist/client/router.d.ts.map +1 -1
  26. package/dist/client/ssr-data.d.ts +3 -0
  27. package/dist/client/ssr-data.d.ts.map +1 -1
  28. package/dist/client/state.d.ts +47 -0
  29. package/dist/client/state.d.ts.map +1 -0
  30. package/dist/client/types.d.ts +10 -1
  31. package/dist/client/types.d.ts.map +1 -1
  32. package/dist/client/unload-guard.d.ts +3 -0
  33. package/dist/client/unload-guard.d.ts.map +1 -1
  34. package/dist/client/use-params.d.ts +19 -6
  35. package/dist/client/use-params.d.ts.map +1 -1
  36. package/dist/client/use-search-params.d.ts +3 -0
  37. package/dist/client/use-search-params.d.ts.map +1 -1
  38. package/dist/cookies/index.js +4 -2
  39. package/dist/cookies/index.js.map +1 -1
  40. package/dist/index.js +4 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/plugins/shims.d.ts.map +1 -1
  43. package/dist/routing/index.js +1 -1
  44. package/dist/routing/scanner.d.ts.map +1 -1
  45. package/dist/rsc-runtime/browser.d.ts +13 -0
  46. package/dist/rsc-runtime/browser.d.ts.map +1 -0
  47. package/dist/rsc-runtime/rsc.d.ts +14 -0
  48. package/dist/rsc-runtime/rsc.d.ts.map +1 -0
  49. package/dist/rsc-runtime/ssr.d.ts +13 -0
  50. package/dist/rsc-runtime/ssr.d.ts.map +1 -0
  51. package/dist/search-params/builtin-codecs.d.ts +105 -0
  52. package/dist/search-params/builtin-codecs.d.ts.map +1 -0
  53. package/dist/search-params/index.d.ts +1 -0
  54. package/dist/search-params/index.d.ts.map +1 -1
  55. package/dist/search-params/index.js +167 -2
  56. package/dist/search-params/index.js.map +1 -1
  57. package/dist/server/actions.d.ts +2 -7
  58. package/dist/server/actions.d.ts.map +1 -1
  59. package/dist/server/als-registry.d.ts +80 -0
  60. package/dist/server/als-registry.d.ts.map +1 -0
  61. package/dist/server/early-hints-sender.d.ts.map +1 -1
  62. package/dist/server/form-flash.d.ts.map +1 -1
  63. package/dist/server/index.d.ts +1 -0
  64. package/dist/server/index.d.ts.map +1 -1
  65. package/dist/server/index.js +242 -76
  66. package/dist/server/index.js.map +1 -1
  67. package/dist/server/metadata-routes.d.ts +27 -0
  68. package/dist/server/metadata-routes.d.ts.map +1 -1
  69. package/dist/server/pipeline.d.ts +7 -0
  70. package/dist/server/pipeline.d.ts.map +1 -1
  71. package/dist/server/primitives.d.ts +14 -6
  72. package/dist/server/primitives.d.ts.map +1 -1
  73. package/dist/server/request-context.d.ts +2 -32
  74. package/dist/server/request-context.d.ts.map +1 -1
  75. package/dist/server/route-matcher.d.ts +5 -0
  76. package/dist/server/route-matcher.d.ts.map +1 -1
  77. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  78. package/dist/server/rsc-entry/rsc-payload.d.ts +25 -0
  79. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -0
  80. package/dist/server/rsc-entry/rsc-stream.d.ts +43 -0
  81. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -0
  82. package/dist/server/rsc-entry/ssr-renderer.d.ts +52 -0
  83. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -0
  84. package/dist/server/rsc-prop-warnings.d.ts +53 -0
  85. package/dist/server/rsc-prop-warnings.d.ts.map +1 -0
  86. package/dist/server/server-timing.d.ts +49 -0
  87. package/dist/server/server-timing.d.ts.map +1 -0
  88. package/dist/server/tracing.d.ts +2 -6
  89. package/dist/server/tracing.d.ts.map +1 -1
  90. package/dist/server/types.d.ts +11 -0
  91. package/dist/server/types.d.ts.map +1 -1
  92. package/package.json +1 -1
  93. package/src/client/browser-entry.ts +1 -1
  94. package/src/client/index.ts +1 -1
  95. package/src/client/router-ref.ts +6 -12
  96. package/src/client/router.ts +14 -4
  97. package/src/client/ssr-data.ts +25 -9
  98. package/src/client/state.ts +83 -0
  99. package/src/client/types.ts +18 -1
  100. package/src/client/unload-guard.ts +6 -3
  101. package/src/client/use-params.ts +42 -32
  102. package/src/client/use-search-params.ts +9 -5
  103. package/src/plugins/shims.ts +26 -2
  104. package/src/routing/scanner.ts +18 -2
  105. package/src/rsc-runtime/browser.ts +18 -0
  106. package/src/rsc-runtime/rsc.ts +19 -0
  107. package/src/rsc-runtime/ssr.ts +13 -0
  108. package/src/search-params/builtin-codecs.ts +228 -0
  109. package/src/search-params/index.ts +11 -0
  110. package/src/server/action-handler.ts +1 -1
  111. package/src/server/actions.ts +4 -10
  112. package/src/server/als-registry.ts +116 -0
  113. package/src/server/deny-renderer.ts +1 -1
  114. package/src/server/early-hints-sender.ts +1 -3
  115. package/src/server/form-flash.ts +1 -5
  116. package/src/server/index.ts +1 -0
  117. package/src/server/metadata-routes.ts +61 -0
  118. package/src/server/pipeline.ts +164 -38
  119. package/src/server/primitives.ts +110 -6
  120. package/src/server/request-context.ts +8 -36
  121. package/src/server/route-matcher.ts +25 -2
  122. package/src/server/rsc-entry/error-renderer.ts +1 -1
  123. package/src/server/rsc-entry/index.ts +42 -380
  124. package/src/server/rsc-entry/rsc-payload.ts +126 -0
  125. package/src/server/rsc-entry/rsc-stream.ts +162 -0
  126. package/src/server/rsc-entry/ssr-renderer.ts +228 -0
  127. package/src/server/rsc-prop-warnings.ts +187 -0
  128. package/src/server/server-timing.ts +132 -0
  129. package/src/server/ssr-entry.ts +1 -1
  130. package/src/server/tracing.ts +3 -11
  131. package/src/server/types.ts +16 -0
  132. package/dist/_chunks/interception-c-a3uODY.js.map +0 -1
  133. package/dist/_chunks/metadata-routes-BDnswgRO.js.map +0 -1
  134. package/dist/_chunks/request-context-BzES06i1.js.map +0 -1
  135. package/dist/_chunks/ssr-data-BgSwMbN9.js +0 -38
  136. package/dist/_chunks/ssr-data-BgSwMbN9.js.map +0 -1
  137. package/dist/_chunks/tracing-BtOwb8O6.js.map +0 -1
@@ -0,0 +1,162 @@
1
+ /**
2
+ * RSC Stream Renderer — Creates the RSC Flight stream with signal tracking.
3
+ *
4
+ * Wraps `renderToReadableStream` from `@vitejs/plugin-rsc/rsc` and captures
5
+ * render-phase signals (DenySignal, RedirectSignal, RenderError) thrown by
6
+ * components during streaming. These signals are tracked in a shared
7
+ * `RenderSignals` object so the caller can decide the HTTP response.
8
+ *
9
+ * Design docs: 02-rendering-pipeline.md §"Single-Pass Rendering",
10
+ * 13-security.md §"Errors don't leak"
11
+ */
12
+
13
+ import { renderToReadableStream } from '#/rsc-runtime/rsc.js';
14
+
15
+ import { logRenderError } from '#/server/logger.js';
16
+ import { DenySignal, RedirectSignal, RenderError } from '#/server/primitives.js';
17
+ import { checkAndWarnRscPropError } from '#/server/rsc-prop-warnings.js';
18
+
19
+ import { createDebugChannelSink, isAbortError } from './helpers.js';
20
+
21
+ /**
22
+ * Mutable signal state captured during RSC rendering.
23
+ *
24
+ * Signals fire asynchronously via `onError` during stream consumption.
25
+ * The first signal of each type wins — subsequent signals are ignored.
26
+ */
27
+ export interface RenderSignals {
28
+ denySignal: DenySignal | null;
29
+ redirectSignal: RedirectSignal | null;
30
+ renderError: { error: unknown; status: number } | null;
31
+ }
32
+
33
+ export interface RscStreamResult {
34
+ rscStream: ReadableStream<Uint8Array> | undefined;
35
+ signals: RenderSignals;
36
+ }
37
+
38
+ /**
39
+ * Render a React element tree to an RSC Flight stream.
40
+ *
41
+ * The stream serializes server components as rendered output and client
42
+ * components ("use client") as serialized references with module ID + export name.
43
+ *
44
+ * DenySignal detection: deny() in sync components throws during
45
+ * renderToReadableStream (caught in try/catch). deny() in async components
46
+ * fires onError during stream consumption. Signals are captured in the
47
+ * returned `signals` object for the caller to handle.
48
+ */
49
+ export function renderRscStream(
50
+ element: React.ReactElement,
51
+ req: Request
52
+ ): RscStreamResult {
53
+ const signals: RenderSignals = {
54
+ denySignal: null,
55
+ redirectSignal: null,
56
+ renderError: null,
57
+ };
58
+
59
+ let rscStream: ReadableStream<Uint8Array> | undefined;
60
+
61
+ try {
62
+ rscStream = renderToReadableStream(
63
+ element,
64
+ {
65
+ signal: req.signal,
66
+ onError(error: unknown) {
67
+ // Connection abort (user refreshed or navigated away) — suppress.
68
+ // Not an application error; no need to track or log.
69
+ if (isAbortError(error) || req.signal?.aborted) return;
70
+ if (error instanceof DenySignal) {
71
+ signals.denySignal = error;
72
+ // Return structured digest for client-side error boundaries
73
+ return JSON.stringify({ type: 'deny', status: error.status, data: error.data });
74
+ }
75
+ if (error instanceof RedirectSignal) {
76
+ signals.redirectSignal = error;
77
+ return JSON.stringify({
78
+ type: 'redirect',
79
+ location: error.location,
80
+ status: error.status,
81
+ });
82
+ }
83
+ if (error instanceof RenderError) {
84
+ // Track the first render error for pre-flush handling
85
+ if (!signals.renderError) {
86
+ signals.renderError = { error, status: error.status };
87
+ }
88
+ logRenderError({ method: req.method, path: new URL(req.url).pathname, error });
89
+ return JSON.stringify({
90
+ type: 'render-error',
91
+ code: error.code,
92
+ data: error.digest.data,
93
+ status: error.status,
94
+ });
95
+ }
96
+ // Dev diagnostic: detect "Invalid hook call" errors which indicate
97
+ // a 'use client' component is being executed during RSC rendering
98
+ // instead of being serialized as a client reference. This happens when
99
+ // the RSC plugin's transform doesn't detect the directive — e.g., the
100
+ // directive isn't at the very top of the file, or the component is
101
+ // re-exported through a barrel file without 'use client'.
102
+ // See LOCAL-297.
103
+ if (
104
+ process.env.NODE_ENV !== 'production' &&
105
+ error instanceof Error &&
106
+ error.message.includes('Invalid hook call')
107
+ ) {
108
+ console.error(
109
+ '[timber] A React hook was called during RSC rendering. This usually means a ' +
110
+ "'use client' component is being executed as a server component instead of " +
111
+ 'being serialized as a client reference.\n\n' +
112
+ 'Common causes:\n' +
113
+ " 1. The 'use client' directive is not the FIRST statement in the file (before any imports)\n" +
114
+ " 2. The component is re-exported through a barrel file (index.ts) that lacks 'use client'\n" +
115
+ ' 3. @vitejs/plugin-rsc is not loaded or is misconfigured\n\n' +
116
+ `Request: ${req.method} ${new URL(req.url).pathname}`
117
+ );
118
+ }
119
+
120
+ // Dev-mode: detect non-serializable RSC props and provide
121
+ // actionable fix suggestions (TIM-358).
122
+ // checkAndWarnRscPropError no-ops in production internally.
123
+ if (error instanceof Error) {
124
+ checkAndWarnRscPropError(error, new URL(req.url).pathname);
125
+ }
126
+
127
+ // Track unhandled errors for pre-flush handling (500 status)
128
+ if (!signals.renderError) {
129
+ signals.renderError = { error, status: 500 };
130
+ }
131
+ logRenderError({ method: req.method, path: new URL(req.url).pathname, error });
132
+ },
133
+ debugChannel: createDebugChannelSink(),
134
+ },
135
+ {
136
+ onClientReference(info: { id: string; name: string; deps: unknown }) {
137
+ // Client reference callback — invoked when a "use client"
138
+ // component is serialized into the RSC stream. Can be extended
139
+ // for CSS dep collection and Early Hints.
140
+ void info;
141
+ },
142
+ }
143
+ );
144
+ } catch (error) {
145
+ if (error instanceof DenySignal) {
146
+ signals.denySignal = error;
147
+ } else if (error instanceof RedirectSignal) {
148
+ signals.redirectSignal = error;
149
+ } else {
150
+ // Synchronous render error — component threw during
151
+ // renderToReadableStream creation. Capture instead of crashing
152
+ // the server; the error page will be rendered below.
153
+ signals.renderError = {
154
+ error,
155
+ status: error instanceof RenderError ? error.status : 500,
156
+ };
157
+ logRenderError({ method: req.method, path: new URL(req.url).pathname, error });
158
+ }
159
+ }
160
+
161
+ return { rscStream, signals };
162
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * SSR Renderer — Pipes the RSC stream through SSR to produce HTML.
3
+ *
4
+ * Tees the RSC stream into two copies:
5
+ * 1. SSR stream — decoded and rendered to HTML
6
+ * 2. Inline stream — embedded as progressive <script> tags for hydration
7
+ *
8
+ * Handles signal promotion (redirect/deny discovered during SSR) and
9
+ * SSR shell failures (errors outside Suspense boundaries).
10
+ *
11
+ * Design docs: 02-rendering-pipeline.md §"RSC → SSR → Client Hydration",
12
+ * 05-streaming.md §"deferSuspenseFor and the Hold Window"
13
+ */
14
+
15
+ import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
16
+ import type { LayoutEntry } from '#/server/deny-renderer.js';
17
+ import { renderDenyPage } from '#/server/deny-renderer.js';
18
+ import type { RouteMatch } from '#/server/pipeline.js';
19
+ import { SsrStreamError } from '#/server/primitives.js';
20
+ import type { LayoutComponentEntry } from '#/server/route-element-builder.js';
21
+ import type { ManifestSegmentNode } from '#/server/route-matcher.js';
22
+ import type { NavContext } from '#/server/ssr-entry.js';
23
+
24
+ import {
25
+ buildRedirectResponse,
26
+ buildSegmentInfo,
27
+ createDebugChannelSink,
28
+ isAbortError,
29
+ parseCookiesFromHeader,
30
+ } from './helpers.js';
31
+ import { renderErrorPage } from './error-renderer.js';
32
+ import { callSsr } from './ssr-bridge.js';
33
+ import type { RenderSignals } from './rsc-stream.js';
34
+
35
+ interface SsrRenderOptions {
36
+ req: Request;
37
+ rscStream: ReadableStream<Uint8Array>;
38
+ signals: RenderSignals;
39
+ segments: ManifestSegmentNode[];
40
+ layoutComponents: LayoutComponentEntry[];
41
+ match: RouteMatch;
42
+ responseHeaders: Headers;
43
+ clientBootstrap: ClientBootstrapConfig;
44
+ clientJsDisabled: boolean;
45
+ headHtml: string;
46
+ deferSuspenseFor: number;
47
+ }
48
+
49
+ /**
50
+ * Render the RSC stream to HTML via SSR.
51
+ *
52
+ * Progressive streaming: pipes the RSC stream directly to SSR without
53
+ * buffering. This enables proper Suspense streaming behavior.
54
+ *
55
+ * For async deny() (inside components that await before calling deny()),
56
+ * SSR will attempt to render the element tree progressively. Two outcomes:
57
+ *
58
+ * 1. deny() outside Suspense: the error appears in the RSC shell. SSR's
59
+ * renderToReadableStream fails (rejects). We catch the failure, check
60
+ * denySignal, and render the deny page with the correct status code.
61
+ *
62
+ * 2. deny() inside Suspense: the SSR shell succeeds (200 committed). The
63
+ * error streams into the connection as a React error boundary. The
64
+ * status is already committed — per design/05-streaming.md this is the
65
+ * expected degraded behavior for deny inside Suspense.
66
+ */
67
+ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Response> {
68
+ const {
69
+ req,
70
+ rscStream,
71
+ signals,
72
+ segments,
73
+ layoutComponents,
74
+ match,
75
+ responseHeaders,
76
+ clientBootstrap,
77
+ clientJsDisabled,
78
+ headHtml,
79
+ deferSuspenseFor,
80
+ } = opts;
81
+
82
+ // Tee the RSC stream — one copy goes to SSR for HTML rendering,
83
+ // the other is inlined in the HTML for client-side hydration.
84
+ const [ssrStream, inlineStream] = rscStream.tee();
85
+
86
+ // Embed segment metadata in HTML for initial hydration.
87
+ // The client reads this to populate its segment cache before the
88
+ // first navigation, enabling state tree diffing from the start.
89
+ // Skipped when client JS is disabled — no client JS to consume it.
90
+ const segmentScript = clientJsDisabled
91
+ ? ''
92
+ : `<script>self.__timber_segments=${JSON.stringify(buildSegmentInfo(segments, layoutComponents))}</script>`;
93
+
94
+ // Embed route params in HTML so useParams() works on initial hydration.
95
+ // Without this, useParams() returns {} until the first client navigation.
96
+ const paramsScript =
97
+ clientJsDisabled || Object.keys(match.params).length === 0
98
+ ? ''
99
+ : `<script>self.__timber_params=${JSON.stringify(match.params)}</script>`;
100
+
101
+ const navContext: NavContext = {
102
+ pathname: new URL(req.url).pathname,
103
+ params: match.params,
104
+ searchParams: Object.fromEntries(new URL(req.url).searchParams),
105
+ statusCode: 200,
106
+ responseHeaders,
107
+ headHtml: headHtml + clientBootstrap.preloadLinks + segmentScript + paramsScript,
108
+ bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
109
+ // Skip RSC inline stream when client JS is disabled — no client to hydrate.
110
+ rscStream: clientJsDisabled ? undefined : inlineStream,
111
+ deferSuspenseFor: deferSuspenseFor > 0 ? deferSuspenseFor : undefined,
112
+ signal: req.signal,
113
+ cookies: parseCookiesFromHeader(req.headers.get('cookie') ?? ''),
114
+ };
115
+
116
+ // Helper: check if render-phase signals were captured and return the
117
+ // appropriate HTTP response. Used after both successful SSR (signal
118
+ // promotion from Suspense) and failed SSR (signal outside Suspense).
119
+ //
120
+ // When `skipHandledDeny` is true (SSR success path), skip DenySignal
121
+ // promotion if the denial was already handled by a TimberErrorBoundary
122
+ // (e.g., slot error boundary). The boundary sets navContext._denyHandledByBoundary
123
+ // during SSR rendering. See LOCAL-298.
124
+ function checkCapturedSignals(
125
+ skipHandledDeny = false
126
+ ): Response | Promise<Response> | null {
127
+ if (signals.redirectSignal) {
128
+ return buildRedirectResponse(req, signals.redirectSignal, responseHeaders);
129
+ }
130
+ if (signals.denySignal && !(skipHandledDeny && navContext._denyHandledByBoundary)) {
131
+ return renderDenyPage(
132
+ signals.denySignal,
133
+ segments,
134
+ layoutComponents as LayoutEntry[],
135
+ req,
136
+ match,
137
+ responseHeaders,
138
+ clientBootstrap,
139
+ createDebugChannelSink,
140
+ callSsr
141
+ );
142
+ }
143
+ if (signals.renderError) {
144
+ return renderErrorPage(
145
+ signals.renderError.error,
146
+ signals.renderError.status,
147
+ segments,
148
+ layoutComponents as LayoutEntry[],
149
+ req,
150
+ match,
151
+ responseHeaders,
152
+ clientBootstrap
153
+ );
154
+ }
155
+ return null;
156
+ }
157
+
158
+ try {
159
+ const ssrResponse = await callSsr(ssrStream, navContext);
160
+
161
+ // Signal promotion: yield one tick so async component rejections
162
+ // propagate to the RSC onError callback, then check if any signals
163
+ // were captured during rendering inside Suspense boundaries.
164
+ // The Response hasn't been sent yet — it's an unconsumed stream.
165
+ // See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
166
+ await new Promise<void>((r) => setTimeout(r, 0));
167
+
168
+ const promoted = checkCapturedSignals(/* skipHandledDeny */ true);
169
+ if (promoted) {
170
+ ssrResponse.body?.cancel();
171
+ return promoted;
172
+ }
173
+ return ssrResponse;
174
+ } catch (ssrError) {
175
+ // Connection abort — the client disconnected (page refresh, navigation
176
+ // away). No response needed; return empty 499 (client closed request).
177
+ if (isAbortError(ssrError) || req.signal?.aborted) {
178
+ return new Response(null, { status: 499 });
179
+ }
180
+
181
+ // SsrStreamError: SSR's renderToReadableStream failed because the RSC
182
+ // stream contained an uncontained error (e.g., slot without error boundary).
183
+ // Render the deny/error page WITHOUT layout wrapping to avoid re-executing
184
+ // server components (which call headers()/cookies() and fail in SSR's
185
+ // separate ALS scope). See LOCAL-293.
186
+ if (ssrError instanceof SsrStreamError) {
187
+ if (signals.redirectSignal) {
188
+ return buildRedirectResponse(req, signals.redirectSignal, responseHeaders);
189
+ }
190
+ if (signals.denySignal) {
191
+ // Render deny page without layouts — pass empty layout list
192
+ return renderDenyPage(
193
+ signals.denySignal,
194
+ segments,
195
+ [] as LayoutEntry[],
196
+ req,
197
+ match,
198
+ responseHeaders,
199
+ clientBootstrap,
200
+ createDebugChannelSink,
201
+ callSsr
202
+ );
203
+ }
204
+ if (signals.renderError) {
205
+ return renderErrorPage(
206
+ signals.renderError.error,
207
+ signals.renderError.status,
208
+ segments,
209
+ [] as LayoutEntry[],
210
+ req,
211
+ match,
212
+ responseHeaders,
213
+ clientBootstrap
214
+ );
215
+ }
216
+ // No captured signal — return bare 500
217
+ return new Response(null, { status: 500, headers: responseHeaders });
218
+ }
219
+
220
+ // SSR shell rendering failed — the error was outside Suspense.
221
+ // Check captured signals (redirect, deny, render error).
222
+ const signalResponse = checkCapturedSignals();
223
+ if (signalResponse) return signalResponse;
224
+
225
+ // No tracked error — rethrow (infrastructure failure)
226
+ throw ssrError;
227
+ }
228
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Dev-mode RSC prop serialization warnings.
3
+ *
4
+ * Detects common non-serializable types in React Flight errors and provides
5
+ * actionable suggestions with the specific fix for each type.
6
+ *
7
+ * React's dev build logs "Only plain objects can be passed to Client Components"
8
+ * but the message is generic. This module adds timber-specific context:
9
+ * - Identifies the exact type (RegExp, URL, class instance, etc.)
10
+ * - Suggests the specific fix (e.g., .toString() for RegExp, .href for URL)
11
+ * - References the serialization audit document
12
+ *
13
+ * Dev-only — zero overhead in production.
14
+ *
15
+ * Design doc: design/30-rsc-serialization-audit.md §"Identified Improvements" #1
16
+ * Task: TIM-358
17
+ */
18
+
19
+ // ─── Types ────────────────────────────────────────────────────────────────
20
+
21
+ export interface NonSerializableTypeInfo {
22
+ /** The detected type name (e.g., 'RegExp', 'URL', 'class instance'). */
23
+ type: string;
24
+ /** Actionable fix suggestion. */
25
+ suggestion: string;
26
+ }
27
+
28
+ // ─── Detection Patterns ──────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Detection rules for common non-serializable types.
32
+ *
33
+ * Each rule has a pattern to match against the error message and
34
+ * the type info to return if matched. Rules are checked in order;
35
+ * first match wins.
36
+ */
37
+ const DETECTION_RULES: Array<{
38
+ pattern: RegExp;
39
+ info: NonSerializableTypeInfo;
40
+ }> = [
41
+ {
42
+ pattern: /RegExp/i,
43
+ info: {
44
+ type: 'RegExp',
45
+ suggestion:
46
+ 'Use .toString() to serialize, and new RegExp() to reconstruct on the client.',
47
+ },
48
+ },
49
+ {
50
+ // URL appears as a class instance error, but we detect it by name
51
+ pattern: /\bURL\b(?!SearchParams)/,
52
+ info: {
53
+ type: 'URL',
54
+ suggestion: 'Pass .href or .toString() instead of the URL object.',
55
+ },
56
+ },
57
+ {
58
+ pattern: /URLSearchParams/,
59
+ info: {
60
+ type: 'URLSearchParams',
61
+ suggestion:
62
+ 'Pass .toString() to serialize, or spread entries: Object.fromEntries(params).',
63
+ },
64
+ },
65
+ {
66
+ pattern: /Headers/,
67
+ info: {
68
+ type: 'Headers',
69
+ suggestion:
70
+ 'Convert to a plain object: Object.fromEntries(headers.entries()).',
71
+ },
72
+ },
73
+ {
74
+ pattern: /Symbol/i,
75
+ info: {
76
+ type: 'Symbol',
77
+ suggestion:
78
+ 'Symbols cannot be serialized. Use a string identifier instead.',
79
+ },
80
+ },
81
+ {
82
+ pattern: /Functions cannot be passed/i,
83
+ info: {
84
+ type: 'function',
85
+ suggestion:
86
+ 'Functions cannot cross the RSC boundary. Mark with "use server" for server actions, ' +
87
+ 'or restructure to pass data instead of callbacks.',
88
+ },
89
+ },
90
+ {
91
+ pattern: /Classes or null prototypes/i,
92
+ info: {
93
+ type: 'class instance',
94
+ suggestion:
95
+ 'Spread to a plain object: { ...instance } or extract the needed properties.',
96
+ },
97
+ },
98
+ {
99
+ // Generic fallback for "Only plain objects" errors not caught above
100
+ pattern: /Only plain objects can be passed to Client Components/i,
101
+ info: {
102
+ type: 'non-serializable object',
103
+ suggestion:
104
+ 'Convert to a plain object or primitive before passing to a client component. ' +
105
+ 'Supported types: string, number, boolean, null, undefined, Date, Map, Set, ' +
106
+ 'BigInt, Promise, ArrayBuffer, TypedArray, and plain objects/arrays.',
107
+ },
108
+ },
109
+ ];
110
+
111
+ // ─── Public API ───────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Detect a non-serializable type from an RSC error message.
115
+ *
116
+ * Returns type info with an actionable fix, or null if the error
117
+ * is not related to RSC prop serialization.
118
+ */
119
+ export function detectNonSerializableType(
120
+ errorMessage: string
121
+ ): NonSerializableTypeInfo | null {
122
+ if (!errorMessage) return null;
123
+
124
+ for (const rule of DETECTION_RULES) {
125
+ if (rule.pattern.test(errorMessage)) {
126
+ return rule.info;
127
+ }
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * Format a human-readable warning message for a non-serializable RSC prop.
135
+ *
136
+ * Includes the type, suggestion, and a reference to the serialization audit doc.
137
+ *
138
+ * @param info - The detected type info
139
+ * @param requestPath - Optional request path for context
140
+ * @param originalMessage - Optional original error message for debugging
141
+ */
142
+ export function formatRscPropWarning(
143
+ info: NonSerializableTypeInfo,
144
+ requestPath?: string,
145
+ originalMessage?: string
146
+ ): string {
147
+ let msg =
148
+ `Non-serializable RSC prop detected: ${info.type}\n` +
149
+ ` Fix: ${info.suggestion}\n` +
150
+ ' See: design/30-rsc-serialization-audit.md for full type support matrix';
151
+
152
+ if (requestPath) {
153
+ msg += `\n Request: ${requestPath}`;
154
+ }
155
+
156
+ if (originalMessage) {
157
+ msg += `\n Original error: ${originalMessage}`;
158
+ }
159
+
160
+ return msg;
161
+ }
162
+
163
+ /**
164
+ * Check an RSC onError error for non-serializable prop patterns and emit
165
+ * a dev warning if detected.
166
+ *
167
+ * Called from the RSC renderToReadableStream onError callback.
168
+ * No-ops in production.
169
+ *
170
+ * @param error - The error from onError
171
+ * @param requestPath - The request pathname for context
172
+ * @returns true if a warning was emitted
173
+ */
174
+ export function checkAndWarnRscPropError(
175
+ error: unknown,
176
+ requestPath: string
177
+ ): boolean {
178
+ if (process.env.NODE_ENV === 'production') return false;
179
+ if (!(error instanceof Error)) return false;
180
+
181
+ const info = detectNonSerializableType(error.message);
182
+ if (!info) return false;
183
+
184
+ const warning = formatRscPropWarning(info, requestPath, error.message);
185
+ process.stderr.write(`\x1b[33m[timber]\x1b[0m ${warning}\n`);
186
+ return true;
187
+ }