@timber-js/app 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/als-registry-c0AGnbqS.js +39 -0
- package/dist/_chunks/als-registry-c0AGnbqS.js.map +1 -0
- package/dist/_chunks/{interception-c-a3uODY.js → interception-DGDIjDbR.js} +10 -3
- package/dist/_chunks/interception-DGDIjDbR.js.map +1 -0
- package/dist/_chunks/{metadata-routes-BDnswgRO.js → metadata-routes-CQCnF4VK.js} +14 -2
- package/dist/_chunks/metadata-routes-CQCnF4VK.js.map +1 -0
- package/dist/_chunks/{request-context-BzES06i1.js → request-context-C69VW4xS.js} +2 -4
- package/dist/_chunks/request-context-C69VW4xS.js.map +1 -0
- package/dist/_chunks/ssr-data-B2yikEEB.js +90 -0
- package/dist/_chunks/ssr-data-B2yikEEB.js.map +1 -0
- package/dist/_chunks/{tracing-BtOwb8O6.js → tracing-tIvqStk8.js} +2 -3
- package/dist/_chunks/tracing-tIvqStk8.js.map +1 -0
- package/dist/_chunks/{use-cookie-D2cZu0jK.js → use-cookie-D5aS4slY.js} +2 -2
- package/dist/_chunks/{use-cookie-D2cZu0jK.js.map → use-cookie-D5aS4slY.js.map} +1 -1
- package/dist/_chunks/{use-query-states-wEXY2JQB.js → use-query-states-DAhgj8Gx.js} +1 -1
- package/dist/_chunks/{use-query-states-wEXY2JQB.js.map → use-query-states-DAhgj8Gx.js.map} +1 -1
- package/dist/cache/index.js +2 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +35 -25
- package/dist/client/index.js.map +1 -1
- package/dist/client/router-ref.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/ssr-data.d.ts +3 -0
- package/dist/client/ssr-data.d.ts.map +1 -1
- package/dist/client/state.d.ts +47 -0
- package/dist/client/state.d.ts.map +1 -0
- package/dist/client/types.d.ts +10 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/unload-guard.d.ts +3 -0
- package/dist/client/unload-guard.d.ts.map +1 -1
- package/dist/client/use-params.d.ts +3 -0
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts +3 -0
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/cookies/index.js +4 -2
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/rsc-runtime/browser.d.ts +13 -0
- package/dist/rsc-runtime/browser.d.ts.map +1 -0
- package/dist/rsc-runtime/rsc.d.ts +14 -0
- package/dist/rsc-runtime/rsc.d.ts.map +1 -0
- package/dist/rsc-runtime/ssr.d.ts +13 -0
- package/dist/rsc-runtime/ssr.d.ts.map +1 -0
- package/dist/search-params/builtin-codecs.d.ts +105 -0
- package/dist/search-params/builtin-codecs.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +1 -0
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +167 -2
- package/dist/search-params/index.js.map +1 -1
- package/dist/server/actions.d.ts +2 -7
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +80 -0
- package/dist/server/als-registry.d.ts.map +1 -0
- package/dist/server/early-hints-sender.d.ts.map +1 -1
- package/dist/server/form-flash.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +242 -76
- package/dist/server/index.js.map +1 -1
- package/dist/server/metadata-routes.d.ts +27 -0
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +7 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +14 -6
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +2 -32
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +5 -0
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +25 -0
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts +43 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts +52 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -0
- package/dist/server/rsc-prop-warnings.d.ts +53 -0
- package/dist/server/rsc-prop-warnings.d.ts.map +1 -0
- package/dist/server/server-timing.d.ts +49 -0
- package/dist/server/server-timing.d.ts.map +1 -0
- package/dist/server/tracing.d.ts +2 -6
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/server/types.d.ts +11 -0
- package/dist/server/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/router-ref.ts +6 -12
- package/src/client/router.ts +25 -20
- package/src/client/ssr-data.ts +25 -9
- package/src/client/state.ts +83 -0
- package/src/client/types.ts +18 -1
- package/src/client/unload-guard.ts +6 -3
- package/src/client/use-params.ts +10 -13
- package/src/client/use-search-params.ts +9 -5
- package/src/plugins/shims.ts +26 -2
- package/src/routing/scanner.ts +18 -2
- package/src/rsc-runtime/browser.ts +18 -0
- package/src/rsc-runtime/rsc.ts +19 -0
- package/src/rsc-runtime/ssr.ts +13 -0
- package/src/search-params/builtin-codecs.ts +228 -0
- package/src/search-params/index.ts +11 -0
- package/src/server/action-handler.ts +1 -1
- package/src/server/actions.ts +4 -10
- package/src/server/als-registry.ts +116 -0
- package/src/server/deny-renderer.ts +1 -1
- package/src/server/early-hints-sender.ts +1 -3
- package/src/server/form-flash.ts +1 -5
- package/src/server/index.ts +1 -0
- package/src/server/metadata-routes.ts +61 -0
- package/src/server/pipeline.ts +164 -38
- package/src/server/primitives.ts +110 -6
- package/src/server/request-context.ts +8 -36
- package/src/server/route-matcher.ts +25 -2
- package/src/server/rsc-entry/error-renderer.ts +1 -1
- package/src/server/rsc-entry/index.ts +42 -380
- package/src/server/rsc-entry/rsc-payload.ts +126 -0
- package/src/server/rsc-entry/rsc-stream.ts +162 -0
- package/src/server/rsc-entry/ssr-renderer.ts +228 -0
- package/src/server/rsc-prop-warnings.ts +187 -0
- package/src/server/server-timing.ts +132 -0
- package/src/server/ssr-entry.ts +1 -1
- package/src/server/tracing.ts +3 -11
- package/src/server/types.ts +16 -0
- package/dist/_chunks/interception-c-a3uODY.js.map +0 -1
- package/dist/_chunks/metadata-routes-BDnswgRO.js.map +0 -1
- package/dist/_chunks/request-context-BzES06i1.js.map +0 -1
- package/dist/_chunks/ssr-data-BgSwMbN9.js +0 -38
- package/dist/_chunks/ssr-data-BgSwMbN9.js.map +0 -1
- 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
|
+
}
|