@timber-js/app 0.1.0 → 0.1.2
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/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +43 -58
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/// <reference types="@vitejs/plugin-rsc/types" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RSC Entry — Request handler for the RSC environment.
|
|
5
|
+
*
|
|
6
|
+
* This is a real TypeScript file, not codegen. It imports the route
|
|
7
|
+
* manifest from a virtual module and creates the request handler.
|
|
8
|
+
*
|
|
9
|
+
* The RSC entry renders the React element tree into an RSC Flight stream
|
|
10
|
+
* using @vitejs/plugin-rsc/rsc. This stream encodes server components as
|
|
11
|
+
* rendered output and client components ("use client") as serialized
|
|
12
|
+
* references. The stream is then passed to the SSR entry (in a separate
|
|
13
|
+
* Vite environment) which decodes it and renders HTML.
|
|
14
|
+
*
|
|
15
|
+
* Design docs: 18-build-system.md §"Entry Files", 02-rendering-pipeline.md
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// @ts-expect-error — virtual module provided by timber-routing plugin
|
|
19
|
+
import routeManifest from 'virtual:timber-route-manifest';
|
|
20
|
+
// @ts-expect-error — virtual module provided by timber-entries plugin
|
|
21
|
+
import config from 'virtual:timber-config';
|
|
22
|
+
// @ts-expect-error — virtual module provided by timber-build-manifest plugin
|
|
23
|
+
import buildManifest from 'virtual:timber-build-manifest';
|
|
24
|
+
|
|
25
|
+
import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc';
|
|
26
|
+
|
|
27
|
+
import { createPipeline } from '#/server/pipeline.js';
|
|
28
|
+
import { initDevTracing } from '#/server/tracing.js';
|
|
29
|
+
import type { PipelineConfig, RouteMatch, InterceptionContext } from '#/server/pipeline.js';
|
|
30
|
+
import { logRenderError } from '#/server/logger.js';
|
|
31
|
+
import { resolveLogMode } from '#/server/dev-logger.js';
|
|
32
|
+
import { createRouteMatcher, createMetadataRouteMatcher } from '#/server/route-matcher.js';
|
|
33
|
+
import type { ManifestSegmentNode } from '#/server/route-matcher.js';
|
|
34
|
+
import { DenySignal, RedirectSignal, RenderError } from '#/server/primitives.js';
|
|
35
|
+
import { buildClientScripts } from '#/server/html-injectors.js';
|
|
36
|
+
import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
|
|
37
|
+
import { renderDenyPage, renderDenyPageAsRsc } from '#/server/deny-renderer.js';
|
|
38
|
+
import type { LayoutEntry } from '#/server/deny-renderer.js';
|
|
39
|
+
import {
|
|
40
|
+
collectRouteCss,
|
|
41
|
+
collectRouteFonts,
|
|
42
|
+
collectRouteModulepreloads,
|
|
43
|
+
buildCssLinkTags,
|
|
44
|
+
buildFontPreloadTags,
|
|
45
|
+
buildModulepreloadTags,
|
|
46
|
+
} from '#/server/build-manifest.js';
|
|
47
|
+
import type { BuildManifest } from '#/server/build-manifest.js';
|
|
48
|
+
import { collectEarlyHintHeaders } from '#/server/early-hints.js';
|
|
49
|
+
import { sendEarlyHints103 } from '#/server/early-hints-sender.js';
|
|
50
|
+
import type { NavContext } from '#/server/ssr-entry.js';
|
|
51
|
+
import { buildRouteElement, RouteSignalWithContext } from '#/server/route-element-builder.js';
|
|
52
|
+
import { isActionRequest, handleActionRequest } from '#/server/action-handler.js';
|
|
53
|
+
import type { FormRerender } from '#/server/action-handler.js';
|
|
54
|
+
import type { BodyLimitsConfig } from '#/server/body-limits.js';
|
|
55
|
+
import { runWithFormFlash } from '#/server/form-flash.js';
|
|
56
|
+
|
|
57
|
+
import {
|
|
58
|
+
createDebugChannelSink,
|
|
59
|
+
buildSegmentInfo,
|
|
60
|
+
isRscPayloadRequest,
|
|
61
|
+
buildRedirectResponse,
|
|
62
|
+
escapeHtml,
|
|
63
|
+
isAbortError,
|
|
64
|
+
parseCookiesFromHeader,
|
|
65
|
+
RSC_CONTENT_TYPE,
|
|
66
|
+
} from './helpers.js';
|
|
67
|
+
import { handleApiRoute } from './api-handler.js';
|
|
68
|
+
import { renderErrorPage, renderNoMatchPage } from './error-renderer.js';
|
|
69
|
+
import { callSsr } from './ssr-bridge.js';
|
|
70
|
+
|
|
71
|
+
// Dev-only pipeline error handler, set by the dev server after import.
|
|
72
|
+
// In production this is always undefined — no overhead.
|
|
73
|
+
let _devPipelineErrorHandler: ((error: Error, phase: string) => void) | undefined;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Set the dev pipeline error handler.
|
|
77
|
+
*
|
|
78
|
+
* Called by the dev server after importing this module to wire pipeline
|
|
79
|
+
* errors into the Vite browser error overlay. No-op in production.
|
|
80
|
+
*/
|
|
81
|
+
export function setDevPipelineErrorHandler(handler: (error: Error, phase: string) => void): void {
|
|
82
|
+
_devPipelineErrorHandler = handler;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create the RSC request handler from the route manifest.
|
|
87
|
+
*
|
|
88
|
+
* The pipeline handles: proxy.ts → canonicalize → route match →
|
|
89
|
+
* 103 Early Hints → middleware.ts → render (RSC → SSR → HTML).
|
|
90
|
+
*/
|
|
91
|
+
async function createRequestHandler(manifest: typeof routeManifest, runtimeConfig: typeof config) {
|
|
92
|
+
// Initialize cookie signing secrets from config (design/29-cookies.md §"Signed Cookies")
|
|
93
|
+
const cookieSecrets = (runtimeConfig as Record<string, unknown>).cookieSecrets as
|
|
94
|
+
| string[]
|
|
95
|
+
| undefined;
|
|
96
|
+
if (cookieSecrets?.length) {
|
|
97
|
+
const { setCookieSecrets } = await import('#/server/request-context.js');
|
|
98
|
+
setCookieSecrets(cookieSecrets);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const matchRoute = createRouteMatcher(manifest);
|
|
102
|
+
const matchMetadataRoute = createMetadataRouteMatcher(manifest);
|
|
103
|
+
|
|
104
|
+
// Build the client bootstrap configuration.
|
|
105
|
+
// When client JavaScript is disabled, no scripts are injected
|
|
106
|
+
// (unless enableHMRInDev is true in dev mode — then only HMR client).
|
|
107
|
+
// In production, uses hashed chunk URLs from the build manifest.
|
|
108
|
+
const clientJsConfig = (runtimeConfig as Record<string, unknown>).clientJavascript as
|
|
109
|
+
| { disabled: boolean; enableHMRInDev: boolean }
|
|
110
|
+
| undefined;
|
|
111
|
+
const clientJsDisabled = clientJsConfig?.disabled ?? false;
|
|
112
|
+
const clientBootstrap = buildClientScripts({
|
|
113
|
+
...runtimeConfig,
|
|
114
|
+
clientJavascript: clientJsConfig ?? { disabled: false, enableHMRInDev: false },
|
|
115
|
+
buildManifest: buildManifest as BuildManifest,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Dev logging — initialize OTEL-based dev tracing once at handler creation.
|
|
119
|
+
// In production, isDev is false — no tracing, no overhead.
|
|
120
|
+
// The DevSpanProcessor handles all formatting and stderr output.
|
|
121
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
122
|
+
const slowPhaseMs = (runtimeConfig as Record<string, unknown>).slowPhaseMs as number | undefined;
|
|
123
|
+
|
|
124
|
+
if (isDev) {
|
|
125
|
+
const devLogMode = resolveLogMode();
|
|
126
|
+
if (devLogMode !== 'quiet') {
|
|
127
|
+
await initDevTracing({ mode: devLogMode, slowPhaseMs });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const typedBuildManifest = buildManifest as BuildManifest;
|
|
132
|
+
|
|
133
|
+
const pipelineConfig: PipelineConfig = {
|
|
134
|
+
proxyLoader: manifest.proxy?.load,
|
|
135
|
+
matchRoute,
|
|
136
|
+
matchMetadataRoute,
|
|
137
|
+
// 103 Early Hints — fires after route match, before middleware.
|
|
138
|
+
// Collects CSS, font, and JS chunk Link headers from the build manifest
|
|
139
|
+
// so the browser starts fetching critical resources while the server renders.
|
|
140
|
+
// In dev mode the manifest is empty — no hints are sent.
|
|
141
|
+
earlyHints: (match: RouteMatch, _req: Request, responseHeaders: Headers) => {
|
|
142
|
+
const segments = match.segments as unknown as Array<{
|
|
143
|
+
layout?: { filePath: string };
|
|
144
|
+
page?: { filePath: string };
|
|
145
|
+
}>;
|
|
146
|
+
const headers = collectEarlyHintHeaders(segments, typedBuildManifest, {
|
|
147
|
+
skipJs: clientJsDisabled,
|
|
148
|
+
});
|
|
149
|
+
for (const h of headers) {
|
|
150
|
+
responseHeaders.append('Link', h);
|
|
151
|
+
}
|
|
152
|
+
// Send 103 Early Hints if the platform supports it (Node.js, Bun).
|
|
153
|
+
// On Cloudflare, the CDN converts Link headers into 103 automatically.
|
|
154
|
+
sendEarlyHints103(headers);
|
|
155
|
+
},
|
|
156
|
+
render: async (
|
|
157
|
+
req: Request,
|
|
158
|
+
match: RouteMatch,
|
|
159
|
+
responseHeaders: Headers,
|
|
160
|
+
_requestHeaderOverlay: Headers,
|
|
161
|
+
interception?: InterceptionContext
|
|
162
|
+
) => {
|
|
163
|
+
return renderRoute(
|
|
164
|
+
req,
|
|
165
|
+
match,
|
|
166
|
+
responseHeaders,
|
|
167
|
+
clientBootstrap,
|
|
168
|
+
clientJsDisabled,
|
|
169
|
+
interception
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
renderNoMatch: async (req: Request, responseHeaders: Headers) => {
|
|
173
|
+
return renderNoMatchPage(req, manifest.root, responseHeaders, clientBootstrap);
|
|
174
|
+
},
|
|
175
|
+
interceptionRewrites: manifest.interceptionRewrites,
|
|
176
|
+
onPipelineError: isDev
|
|
177
|
+
? (error: Error, phase: string) => {
|
|
178
|
+
if (_devPipelineErrorHandler) _devPipelineErrorHandler(error, phase);
|
|
179
|
+
}
|
|
180
|
+
: undefined,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const pipeline = createPipeline(pipelineConfig);
|
|
184
|
+
|
|
185
|
+
// Wrap the pipeline to intercept server action requests before rendering.
|
|
186
|
+
// Actions bypass the normal pipeline (no route matching, no middleware)
|
|
187
|
+
// per design/08-forms-and-actions.md §"Middleware for Server Actions".
|
|
188
|
+
const csrfConfig = {
|
|
189
|
+
csrf: runtimeConfig.csrf,
|
|
190
|
+
allowedOrigins: (runtimeConfig as Record<string, unknown>).allowedOrigins as
|
|
191
|
+
| string[]
|
|
192
|
+
| undefined,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return async (req: Request): Promise<Response> => {
|
|
196
|
+
if (isActionRequest(req)) {
|
|
197
|
+
const actionResponse = await handleActionRequest(req, {
|
|
198
|
+
csrf: csrfConfig,
|
|
199
|
+
bodyLimits: {
|
|
200
|
+
limits: (runtimeConfig as Record<string, unknown>).limits as BodyLimitsConfig['limits'],
|
|
201
|
+
},
|
|
202
|
+
revalidateRenderer: async (path: string) => {
|
|
203
|
+
// Build the React element tree for the route at `path`.
|
|
204
|
+
// Returns the element tree (not serialized) so the action handler can
|
|
205
|
+
// combine it with the action result in a single renderToReadableStream call.
|
|
206
|
+
// Forward original request headers (cookies, session IDs, etc.).
|
|
207
|
+
const revalidateHeaders = new Headers(req.headers);
|
|
208
|
+
revalidateHeaders.set('Accept', 'text/x-component');
|
|
209
|
+
const revalidateReq = new Request(new URL(path, req.url), {
|
|
210
|
+
headers: revalidateHeaders,
|
|
211
|
+
});
|
|
212
|
+
const revalidateMatch = matchRoute(new URL(revalidateReq.url).pathname);
|
|
213
|
+
if (!revalidateMatch) {
|
|
214
|
+
throw new Error(`revalidatePath('${path}') — no matching route`);
|
|
215
|
+
}
|
|
216
|
+
const routeResult = await buildRouteElement(revalidateReq, revalidateMatch);
|
|
217
|
+
return {
|
|
218
|
+
element: routeResult.element,
|
|
219
|
+
headElements: routeResult.headElements,
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
if (actionResponse) {
|
|
224
|
+
// Check if this is a re-render signal (no-JS validation failure)
|
|
225
|
+
if ('rerender' in actionResponse) {
|
|
226
|
+
const formRerender = actionResponse as FormRerender;
|
|
227
|
+
// Re-render the page with the action result as flash data.
|
|
228
|
+
// Server components read it via getFormFlash() and pass it to
|
|
229
|
+
// client form components as the initial useActionState value.
|
|
230
|
+
const response = await runWithFormFlash(formRerender.rerender, () => pipeline(req));
|
|
231
|
+
return response;
|
|
232
|
+
}
|
|
233
|
+
return actionResponse;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return pipeline(req);
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Render a matched route to an HTML Response via RSC → SSR pipeline,
|
|
242
|
+
* or return a raw RSC Flight stream for client-side navigation requests.
|
|
243
|
+
*
|
|
244
|
+
* 1. Load page/layout components from the segment chain
|
|
245
|
+
* 2. Resolve metadata
|
|
246
|
+
* 3. Render to RSC Flight stream (serializes "use client" as references)
|
|
247
|
+
* 4. If Accept: text/x-component → return RSC stream directly
|
|
248
|
+
* Otherwise → pass RSC stream to SSR entry for HTML rendering
|
|
249
|
+
*/
|
|
250
|
+
async function renderRoute(
|
|
251
|
+
_req: Request,
|
|
252
|
+
match: RouteMatch,
|
|
253
|
+
responseHeaders: Headers,
|
|
254
|
+
clientBootstrap: ClientBootstrapConfig,
|
|
255
|
+
clientJsDisabled: boolean,
|
|
256
|
+
interception?: InterceptionContext
|
|
257
|
+
): Promise<Response> {
|
|
258
|
+
const segments = match.segments as unknown as ManifestSegmentNode[];
|
|
259
|
+
const leaf = segments[segments.length - 1];
|
|
260
|
+
|
|
261
|
+
// API routes (route.ts) — run access.ts standalone then dispatch to handler.
|
|
262
|
+
// No React render pass — AccessGate is not used, React.cache is not active.
|
|
263
|
+
// See design/04-authorization.md §"Auth in API Routes".
|
|
264
|
+
if (leaf.route && !leaf.page) {
|
|
265
|
+
return handleApiRoute(_req, match, segments, responseHeaders);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Build the React element tree — loads modules, runs access checks,
|
|
269
|
+
// resolves metadata. DenySignal/RedirectSignal propagate for HTTP handling.
|
|
270
|
+
let routeResult;
|
|
271
|
+
try {
|
|
272
|
+
routeResult = await buildRouteElement(_req, match, interception);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
// RouteSignalWithContext wraps DenySignal/RedirectSignal with layout context
|
|
275
|
+
if (error instanceof RouteSignalWithContext) {
|
|
276
|
+
const { signal, layoutComponents: lc, segments: segs } = error;
|
|
277
|
+
if (signal instanceof DenySignal) {
|
|
278
|
+
if (isRscPayloadRequest(_req)) {
|
|
279
|
+
return renderDenyPageAsRsc(
|
|
280
|
+
signal,
|
|
281
|
+
segs,
|
|
282
|
+
lc as LayoutEntry[],
|
|
283
|
+
responseHeaders,
|
|
284
|
+
createDebugChannelSink
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return renderDenyPage(
|
|
288
|
+
signal,
|
|
289
|
+
segs,
|
|
290
|
+
lc as LayoutEntry[],
|
|
291
|
+
_req,
|
|
292
|
+
match,
|
|
293
|
+
responseHeaders,
|
|
294
|
+
clientBootstrap,
|
|
295
|
+
createDebugChannelSink,
|
|
296
|
+
callSsr
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (signal instanceof RedirectSignal) {
|
|
300
|
+
return buildRedirectResponse(_req, signal, responseHeaders);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// No PageComponent found
|
|
304
|
+
if (error instanceof Error && error.message.startsWith('No page component')) {
|
|
305
|
+
return new Response(null, { status: 404 });
|
|
306
|
+
}
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const { element, headElements, layoutComponents, deferSuspenseFor } = routeResult;
|
|
311
|
+
|
|
312
|
+
// Build head HTML for injection into the SSR output
|
|
313
|
+
let headHtml = '';
|
|
314
|
+
|
|
315
|
+
// Collect CSS, fonts, and modulepreload from the build manifest for matched segments.
|
|
316
|
+
// In dev mode the manifest is empty — Vite HMR handles CSS/JS.
|
|
317
|
+
//
|
|
318
|
+
// Link headers (for 103 Early Hints) are emitted by the earlyHints pipeline
|
|
319
|
+
// stage before middleware runs. Here we only emit the <head> HTML fallback tags
|
|
320
|
+
// — these ensure resources load even on platforms without Early Hints support.
|
|
321
|
+
const typedManifest = buildManifest as BuildManifest;
|
|
322
|
+
const cssUrls = collectRouteCss(segments, typedManifest);
|
|
323
|
+
if (cssUrls.length > 0) {
|
|
324
|
+
headHtml += buildCssLinkTags(cssUrls);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const fontEntries = collectRouteFonts(segments, typedManifest);
|
|
328
|
+
if (fontEntries.length > 0) {
|
|
329
|
+
headHtml += buildFontPreloadTags(fontEntries);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Skip modulepreload tags when client JavaScript is disabled — no JS to preload.
|
|
333
|
+
if (!clientJsDisabled) {
|
|
334
|
+
const preloadUrls = collectRouteModulepreloads(segments, typedManifest);
|
|
335
|
+
if (preloadUrls.length > 0) {
|
|
336
|
+
headHtml += buildModulepreloadTags(preloadUrls);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
for (const el of headElements) {
|
|
341
|
+
if (el.tag === 'title' && el.content) {
|
|
342
|
+
headHtml += `<title>${escapeHtml(el.content)}</title>`;
|
|
343
|
+
} else if (el.attrs) {
|
|
344
|
+
const attrs = Object.entries(el.attrs)
|
|
345
|
+
.filter(([, v]) => v != null)
|
|
346
|
+
.map(([k, v]) => `${k}="${escapeHtml(v as string)}"`)
|
|
347
|
+
.join(' ');
|
|
348
|
+
headHtml += `<${el.tag} ${attrs}>`;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Render to RSC Flight stream.
|
|
353
|
+
// renderToReadableStream from @vitejs/plugin-rsc/rsc serializes:
|
|
354
|
+
// - Server components: rendered output (HTML-like structure)
|
|
355
|
+
// - Client components ("use client"): serialized references with module ID + export name
|
|
356
|
+
//
|
|
357
|
+
// The RSC plugin's renderToReadableStream(data, reactOptions, extraOptions):
|
|
358
|
+
// - reactOptions: passed to React (onError, signal, etc.)
|
|
359
|
+
// - extraOptions: { onClientReference } for tracking client deps
|
|
360
|
+
// The client manifest is created internally by the plugin.
|
|
361
|
+
//
|
|
362
|
+
// DenySignal detection: deny() in sync components throws during
|
|
363
|
+
// renderToReadableStream (caught in try/catch). deny() in async components
|
|
364
|
+
// fires onError during stream consumption. We capture it here and let
|
|
365
|
+
// SSR determine whether it was pre-flush (outside Suspense) or post-flush
|
|
366
|
+
// (inside Suspense) based on whether the SSR shell renders successfully.
|
|
367
|
+
let denySignal: DenySignal | null = null;
|
|
368
|
+
let redirectSignal: RedirectSignal | null = null;
|
|
369
|
+
let renderError: { error: unknown; status: number } | null = null;
|
|
370
|
+
let rscStream: ReadableStream<Uint8Array> | undefined;
|
|
371
|
+
try {
|
|
372
|
+
rscStream = renderToReadableStream(
|
|
373
|
+
element,
|
|
374
|
+
{
|
|
375
|
+
signal: _req.signal,
|
|
376
|
+
onError(error: unknown) {
|
|
377
|
+
// Connection abort (user refreshed or navigated away) — suppress.
|
|
378
|
+
// Not an application error; no need to track or log.
|
|
379
|
+
if (isAbortError(error) || _req.signal?.aborted) return;
|
|
380
|
+
if (error instanceof DenySignal) {
|
|
381
|
+
denySignal = error;
|
|
382
|
+
// Return structured digest for client-side error boundaries
|
|
383
|
+
return JSON.stringify({ type: 'deny', status: error.status, data: error.data });
|
|
384
|
+
}
|
|
385
|
+
if (error instanceof RedirectSignal) {
|
|
386
|
+
redirectSignal = error;
|
|
387
|
+
return JSON.stringify({
|
|
388
|
+
type: 'redirect',
|
|
389
|
+
location: error.location,
|
|
390
|
+
status: error.status,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (error instanceof RenderError) {
|
|
394
|
+
// Track the first render error for pre-flush handling
|
|
395
|
+
if (!renderError) {
|
|
396
|
+
renderError = { error, status: error.status };
|
|
397
|
+
}
|
|
398
|
+
logRenderError({ method: _req.method, path: new URL(_req.url).pathname, error });
|
|
399
|
+
return JSON.stringify({
|
|
400
|
+
type: 'render-error',
|
|
401
|
+
code: error.code,
|
|
402
|
+
data: error.digest.data,
|
|
403
|
+
status: error.status,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// Track unhandled errors for pre-flush handling (500 status)
|
|
407
|
+
if (!renderError) {
|
|
408
|
+
renderError = { error, status: 500 };
|
|
409
|
+
}
|
|
410
|
+
logRenderError({ method: _req.method, path: new URL(_req.url).pathname, error });
|
|
411
|
+
},
|
|
412
|
+
debugChannel: createDebugChannelSink(),
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
onClientReference(info: { id: string; name: string; deps: unknown }) {
|
|
416
|
+
// Client reference callback — invoked when a "use client"
|
|
417
|
+
// component is serialized into the RSC stream. Can be extended
|
|
418
|
+
// for CSS dep collection and Early Hints.
|
|
419
|
+
void info;
|
|
420
|
+
},
|
|
421
|
+
}
|
|
422
|
+
);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
if (error instanceof DenySignal) {
|
|
425
|
+
denySignal = error;
|
|
426
|
+
} else if (error instanceof RedirectSignal) {
|
|
427
|
+
redirectSignal = error;
|
|
428
|
+
} else {
|
|
429
|
+
// Synchronous render error — component threw during
|
|
430
|
+
// renderToReadableStream creation. Capture instead of crashing
|
|
431
|
+
// the server; the error page will be rendered below.
|
|
432
|
+
renderError = {
|
|
433
|
+
error,
|
|
434
|
+
status: error instanceof RenderError ? error.status : 500,
|
|
435
|
+
};
|
|
436
|
+
logRenderError({ method: _req.method, path: new URL(_req.url).pathname, error });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Synchronous redirect — redirect() in access.ts or a non-async component
|
|
441
|
+
// throws during renderToReadableStream creation. Return HTTP redirect.
|
|
442
|
+
if (redirectSignal) {
|
|
443
|
+
return buildRedirectResponse(_req, redirectSignal, responseHeaders);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Synchronous deny — deny() in a non-async component throws during
|
|
447
|
+
// renderToReadableStream creation, caught in the try/catch above.
|
|
448
|
+
if (denySignal) {
|
|
449
|
+
if (isRscPayloadRequest(_req)) {
|
|
450
|
+
return renderDenyPageAsRsc(
|
|
451
|
+
denySignal,
|
|
452
|
+
segments,
|
|
453
|
+
layoutComponents as LayoutEntry[],
|
|
454
|
+
responseHeaders,
|
|
455
|
+
createDebugChannelSink
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
return renderDenyPage(
|
|
459
|
+
denySignal,
|
|
460
|
+
segments,
|
|
461
|
+
layoutComponents as LayoutEntry[],
|
|
462
|
+
_req,
|
|
463
|
+
match,
|
|
464
|
+
responseHeaders,
|
|
465
|
+
clientBootstrap,
|
|
466
|
+
createDebugChannelSink,
|
|
467
|
+
callSsr
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Synchronous render error — renderToReadableStream threw before
|
|
472
|
+
// creating the stream. Render the error page with correct 5xx status.
|
|
473
|
+
// (Async render errors are tracked in onError and handled after SSR.)
|
|
474
|
+
if (renderError && !rscStream) {
|
|
475
|
+
return renderErrorPage(
|
|
476
|
+
renderError.error,
|
|
477
|
+
renderError.status,
|
|
478
|
+
segments,
|
|
479
|
+
layoutComponents as LayoutEntry[],
|
|
480
|
+
_req,
|
|
481
|
+
match,
|
|
482
|
+
responseHeaders,
|
|
483
|
+
clientBootstrap
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// For RSC payload requests (client navigation), return the RSC Flight
|
|
488
|
+
// stream directly — skip SSR HTML rendering entirely.
|
|
489
|
+
// See design/19-client-navigation.md §"RSC Payload Handling"
|
|
490
|
+
if (isRscPayloadRequest(_req)) {
|
|
491
|
+
// Read the first chunk from the RSC stream before committing headers.
|
|
492
|
+
// Async components (including page components wrapped in TracedPage)
|
|
493
|
+
// throw during stream consumption, not during renderToReadableStream.
|
|
494
|
+
// Reading one chunk triggers rendering of the initial component tree,
|
|
495
|
+
// allowing onError to capture DenySignal/RedirectSignal before we
|
|
496
|
+
// commit the response. Without this, the redirect digest is embedded
|
|
497
|
+
// in the RSC stream and surfaces as an unhandled error on the client.
|
|
498
|
+
// See TIM-344.
|
|
499
|
+
const reader = rscStream!.getReader();
|
|
500
|
+
const firstRead = await reader.read();
|
|
501
|
+
|
|
502
|
+
// Yield to the microtask queue so that async component rejections
|
|
503
|
+
// (e.g. an async-wrapped page component that throws redirect())
|
|
504
|
+
// propagate to the onError callback before we check the signals.
|
|
505
|
+
// The rejected Promise from an async component resolves in the next
|
|
506
|
+
// microtask after read(), so we need at least one tick.
|
|
507
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
508
|
+
|
|
509
|
+
// Check for redirect/deny signals detected during initial rendering
|
|
510
|
+
const trackedRedirect = redirectSignal as RedirectSignal | null;
|
|
511
|
+
if (trackedRedirect) {
|
|
512
|
+
reader.cancel();
|
|
513
|
+
return buildRedirectResponse(_req, trackedRedirect, responseHeaders);
|
|
514
|
+
}
|
|
515
|
+
if (denySignal) {
|
|
516
|
+
reader.cancel();
|
|
517
|
+
return renderDenyPageAsRsc(
|
|
518
|
+
denySignal,
|
|
519
|
+
segments,
|
|
520
|
+
layoutComponents as LayoutEntry[],
|
|
521
|
+
responseHeaders,
|
|
522
|
+
createDebugChannelSink
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Reconstruct the stream: prepend the buffered first chunk,
|
|
527
|
+
// then continue piping from the original reader.
|
|
528
|
+
const patchedStream = new ReadableStream<Uint8Array>({
|
|
529
|
+
start(controller) {
|
|
530
|
+
if (firstRead.value) controller.enqueue(firstRead.value);
|
|
531
|
+
if (firstRead.done) {
|
|
532
|
+
controller.close();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
async pull(controller) {
|
|
537
|
+
const { value, done } = await reader.read();
|
|
538
|
+
if (done) {
|
|
539
|
+
controller.close();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
controller.enqueue(value);
|
|
543
|
+
},
|
|
544
|
+
cancel() {
|
|
545
|
+
reader.cancel();
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
|
|
550
|
+
// Vary on Accept so CDNs cache HTML and RSC responses separately
|
|
551
|
+
// for the same URL. The client appends ?_rsc=<id> as a cache-bust,
|
|
552
|
+
// but Vary ensures correct behavior even without the query param.
|
|
553
|
+
responseHeaders.set('Vary', 'Accept');
|
|
554
|
+
|
|
555
|
+
// Send resolved head elements so the client can update document.title
|
|
556
|
+
// and <meta> tags after SPA navigation. See design/16-metadata.md.
|
|
557
|
+
const encoded = encodeURIComponent(JSON.stringify(headElements));
|
|
558
|
+
if (encoded.length <= 4096) {
|
|
559
|
+
responseHeaders.set('X-Timber-Head', encoded);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Send segment metadata so the client can populate its segment cache
|
|
563
|
+
// for state tree diffing on subsequent navigations.
|
|
564
|
+
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
565
|
+
const segmentInfo = buildSegmentInfo(segments, layoutComponents);
|
|
566
|
+
responseHeaders.set('X-Timber-Segments', JSON.stringify(segmentInfo));
|
|
567
|
+
|
|
568
|
+
// Send route params so the client can populate useParams() after
|
|
569
|
+
// SPA navigation. Without this, useParams() returns {}.
|
|
570
|
+
if (Object.keys(match.params).length > 0) {
|
|
571
|
+
responseHeaders.set('X-Timber-Params', JSON.stringify(match.params));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return new Response(patchedStream, {
|
|
575
|
+
status: 200,
|
|
576
|
+
headers: responseHeaders,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Progressive streaming: pipe the RSC stream directly to SSR without
|
|
581
|
+
// buffering. This enables proper Suspense streaming behavior.
|
|
582
|
+
//
|
|
583
|
+
// For async deny() (inside components that await before calling deny()),
|
|
584
|
+
// SSR will attempt to render the element tree progressively. Two outcomes:
|
|
585
|
+
//
|
|
586
|
+
// 1. deny() outside Suspense: the error appears in the RSC shell. SSR's
|
|
587
|
+
// renderToReadableStream fails (rejects). We catch the failure, check
|
|
588
|
+
// denySignal, and render the deny page with the correct status code.
|
|
589
|
+
//
|
|
590
|
+
// 2. deny() inside Suspense: the SSR shell succeeds (200 committed). The
|
|
591
|
+
// error streams into the connection as a React error boundary. The
|
|
592
|
+
// status is already committed — per design/05-streaming.md this is the
|
|
593
|
+
// expected degraded behavior for deny inside Suspense.
|
|
594
|
+
//
|
|
595
|
+
// Tee the RSC stream — one copy goes to SSR for HTML rendering,
|
|
596
|
+
// the other is inlined in the HTML for client-side hydration.
|
|
597
|
+
const [ssrStream, inlineStream] = rscStream!.tee();
|
|
598
|
+
|
|
599
|
+
// Embed segment metadata in HTML for initial hydration.
|
|
600
|
+
// The client reads this to populate its segment cache before the
|
|
601
|
+
// first navigation, enabling state tree diffing from the start.
|
|
602
|
+
// Skipped when client JS is disabled — no client JS to consume it.
|
|
603
|
+
const segmentScript = clientJsDisabled
|
|
604
|
+
? ''
|
|
605
|
+
: `<script>self.__timber_segments=${JSON.stringify(buildSegmentInfo(segments, layoutComponents))}</script>`;
|
|
606
|
+
|
|
607
|
+
// Embed route params in HTML so useParams() works on initial hydration.
|
|
608
|
+
// Without this, useParams() returns {} until the first client navigation.
|
|
609
|
+
const paramsScript =
|
|
610
|
+
clientJsDisabled || Object.keys(match.params).length === 0
|
|
611
|
+
? ''
|
|
612
|
+
: `<script>self.__timber_params=${JSON.stringify(match.params)}</script>`;
|
|
613
|
+
|
|
614
|
+
const navContext: NavContext = {
|
|
615
|
+
pathname: new URL(_req.url).pathname,
|
|
616
|
+
params: match.params,
|
|
617
|
+
searchParams: Object.fromEntries(new URL(_req.url).searchParams),
|
|
618
|
+
statusCode: 200,
|
|
619
|
+
responseHeaders,
|
|
620
|
+
headHtml: headHtml + clientBootstrap.preloadLinks + segmentScript + paramsScript,
|
|
621
|
+
bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
|
|
622
|
+
// Skip RSC inline stream when client JS is disabled — no client to hydrate.
|
|
623
|
+
rscStream: clientJsDisabled ? undefined : inlineStream,
|
|
624
|
+
deferSuspenseFor: deferSuspenseFor > 0 ? deferSuspenseFor : undefined,
|
|
625
|
+
signal: _req.signal,
|
|
626
|
+
cookies: parseCookiesFromHeader(_req.headers.get('cookie') ?? ''),
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// Helper: check if render-phase signals were captured and return the
|
|
630
|
+
// appropriate HTTP response. Used after both successful SSR (signal
|
|
631
|
+
// promotion from Suspense) and failed SSR (signal outside Suspense).
|
|
632
|
+
function checkCapturedSignals(): Response | Promise<Response> | null {
|
|
633
|
+
const sig = redirectSignal as RedirectSignal | null;
|
|
634
|
+
if (sig) return buildRedirectResponse(_req, sig, responseHeaders);
|
|
635
|
+
if (denySignal) {
|
|
636
|
+
return renderDenyPage(
|
|
637
|
+
denySignal, segments, layoutComponents as LayoutEntry[],
|
|
638
|
+
_req, match, responseHeaders, clientBootstrap, createDebugChannelSink, callSsr
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
const err = renderError as { error: unknown; status: number } | null;
|
|
642
|
+
if (err) {
|
|
643
|
+
return renderErrorPage(
|
|
644
|
+
err.error, err.status, segments, layoutComponents as LayoutEntry[],
|
|
645
|
+
_req, match, responseHeaders, clientBootstrap
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
try {
|
|
652
|
+
const ssrResponse = await callSsr(ssrStream, navContext);
|
|
653
|
+
|
|
654
|
+
// Signal promotion: yield one tick so async component rejections
|
|
655
|
+
// propagate to the RSC onError callback, then check if any signals
|
|
656
|
+
// were captured during rendering inside Suspense boundaries.
|
|
657
|
+
// The Response hasn't been sent yet — it's an unconsumed stream.
|
|
658
|
+
// See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
|
|
659
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
660
|
+
|
|
661
|
+
const promoted = checkCapturedSignals();
|
|
662
|
+
if (promoted) {
|
|
663
|
+
ssrResponse.body?.cancel();
|
|
664
|
+
return promoted;
|
|
665
|
+
}
|
|
666
|
+
return ssrResponse;
|
|
667
|
+
} catch (ssrError) {
|
|
668
|
+
// Connection abort — the client disconnected (page refresh, navigation
|
|
669
|
+
// away). No response needed; return empty 499 (client closed request).
|
|
670
|
+
if (isAbortError(ssrError) || _req.signal?.aborted) {
|
|
671
|
+
return new Response(null, { status: 499 });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// SSR shell rendering failed — the error was outside Suspense.
|
|
675
|
+
// Check captured signals (redirect, deny, render error).
|
|
676
|
+
const signalResponse = checkCapturedSignals();
|
|
677
|
+
if (signalResponse) return signalResponse;
|
|
678
|
+
|
|
679
|
+
// No tracked error — rethrow (infrastructure failure)
|
|
680
|
+
throw ssrError;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Re-export for generated entry points (e.g., Nitro node-server/bun) to wrap
|
|
685
|
+
// the handler with per-request 103 Early Hints sender via ALS.
|
|
686
|
+
export { runWithEarlyHintsSender } from '#/server/early-hints-sender.js';
|
|
687
|
+
|
|
688
|
+
export default await createRequestHandler(routeManifest, config);
|