@timber-js/app 0.1.1 → 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 +2 -1
- 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,663 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Entry — Client-side hydration and navigation bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* This is a real TypeScript file, not codegen. It initializes the
|
|
5
|
+
* client navigation runtime: segment router, prefetch cache, and
|
|
6
|
+
* history stack.
|
|
7
|
+
*
|
|
8
|
+
* Hydration works by:
|
|
9
|
+
* 1. Decoding the RSC payload embedded in the initial HTML response
|
|
10
|
+
* via createFromReadableStream from @vitejs/plugin-rsc/browser
|
|
11
|
+
* 2. Hydrating the decoded React tree via hydrateRoot
|
|
12
|
+
* 3. Setting up client-side navigation for subsequent page transitions
|
|
13
|
+
*
|
|
14
|
+
* After hydration, the browser entry:
|
|
15
|
+
* - Intercepts clicks on <a data-timber-link> for SPA navigation
|
|
16
|
+
* - Listens for mouseenter on <a data-timber-prefetch> for hover prefetch
|
|
17
|
+
* - Listens for popstate events for back/forward navigation
|
|
18
|
+
*
|
|
19
|
+
* Design docs: 18-build-system.md §"Entry Files", 19-client-navigation.md
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// @ts-expect-error — virtual module provided by timber-entries plugin
|
|
23
|
+
import config from 'virtual:timber-config';
|
|
24
|
+
|
|
25
|
+
import { createElement } from 'react';
|
|
26
|
+
import { hydrateRoot, createRoot, type Root } from 'react-dom/client';
|
|
27
|
+
import {
|
|
28
|
+
createFromReadableStream,
|
|
29
|
+
createFromFetch,
|
|
30
|
+
setServerCallback,
|
|
31
|
+
encodeReply,
|
|
32
|
+
} from '@vitejs/plugin-rsc/browser';
|
|
33
|
+
import { createRouter } from './router.js';
|
|
34
|
+
import type { RouterDeps, RouterInstance } from './router.js';
|
|
35
|
+
import { applyHeadElements } from './head.js';
|
|
36
|
+
import { setGlobalRouter, getRouter } from './router-ref.js';
|
|
37
|
+
import { TimberNuqsAdapter } from './nuqs-adapter.js';
|
|
38
|
+
import { isPageUnloading } from './unload-guard.js';
|
|
39
|
+
import { setCurrentParams } from './use-params.js';
|
|
40
|
+
import { ON_NAVIGATE_KEY } from './link-navigate-interceptor.js';
|
|
41
|
+
|
|
42
|
+
// ─── Server Action Dispatch ──────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Register the callServer callback for server action dispatch.
|
|
46
|
+
*
|
|
47
|
+
* When React encounters a server reference (from `'use server'` modules),
|
|
48
|
+
* it calls `callServer(id, args)` to dispatch the action to the server.
|
|
49
|
+
* The RSC plugin delegates to `globalThis.__viteRscCallServer` which is
|
|
50
|
+
* set by `setServerCallback`.
|
|
51
|
+
*
|
|
52
|
+
* The callback:
|
|
53
|
+
* 1. Serializes args via `encodeReply` (RSC wire format)
|
|
54
|
+
* 2. POSTs to the current URL with `Accept: text/x-component`
|
|
55
|
+
* 3. Decodes the RSC response stream
|
|
56
|
+
*
|
|
57
|
+
* See design/08-forms-and-actions.md §"Client-Side Form Mechanics"
|
|
58
|
+
*/
|
|
59
|
+
setServerCallback(async (id: string, args: unknown[]) => {
|
|
60
|
+
const body = await encodeReply(args);
|
|
61
|
+
|
|
62
|
+
// Track the X-Timber-Revalidation header from the response.
|
|
63
|
+
// We intercept the fetch promise to read headers before createFromFetch
|
|
64
|
+
// consumes the body stream.
|
|
65
|
+
let hasRevalidation = false;
|
|
66
|
+
let hasRedirect = false;
|
|
67
|
+
let headElementsJson: string | null = null;
|
|
68
|
+
|
|
69
|
+
const response = fetch(window.location.href, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Accept': 'text/x-component',
|
|
73
|
+
'x-rsc-action': id,
|
|
74
|
+
},
|
|
75
|
+
body,
|
|
76
|
+
}).then((res) => {
|
|
77
|
+
hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
|
|
78
|
+
hasRedirect = res.headers.get('X-Timber-Redirect') != null;
|
|
79
|
+
headElementsJson = res.headers.get('X-Timber-Head');
|
|
80
|
+
return res;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const decoded = await createFromFetch(response);
|
|
84
|
+
|
|
85
|
+
// Handle redirect — server encoded the redirect location in the RSC stream
|
|
86
|
+
// instead of returning HTTP 302. Perform a client-side SPA navigation.
|
|
87
|
+
if (hasRedirect) {
|
|
88
|
+
const wrapper = decoded as { _redirect: string; _status: number };
|
|
89
|
+
try {
|
|
90
|
+
const router = getRouter();
|
|
91
|
+
void router.navigate(wrapper._redirect);
|
|
92
|
+
} catch {
|
|
93
|
+
// Router not yet initialized — fall back to full navigation
|
|
94
|
+
window.location.href = wrapper._redirect;
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (hasRevalidation) {
|
|
100
|
+
// Piggybacked response: wrapper object { _action, _tree }
|
|
101
|
+
// Apply the revalidated tree directly — no separate router.refresh() needed.
|
|
102
|
+
const wrapper = decoded as { _action: unknown; _tree: unknown };
|
|
103
|
+
try {
|
|
104
|
+
const router = getRouter();
|
|
105
|
+
const headElements = headElementsJson ? JSON.parse(headElementsJson) : null;
|
|
106
|
+
router.applyRevalidation(wrapper._tree, headElements);
|
|
107
|
+
} catch {
|
|
108
|
+
// Router not yet initialized — fall through
|
|
109
|
+
}
|
|
110
|
+
return wrapper._action;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// No piggybacked revalidation — refresh to pick up any mutations.
|
|
114
|
+
// This covers actions that don't call revalidatePath().
|
|
115
|
+
try {
|
|
116
|
+
const router = getRouter();
|
|
117
|
+
void router.refresh();
|
|
118
|
+
} catch {
|
|
119
|
+
// Router not yet initialized (rare edge case during bootstrap)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return decoded;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ─── Bootstrap ───────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Bootstrap the client-side runtime.
|
|
129
|
+
*
|
|
130
|
+
* Hydrates the server-rendered HTML with React, then initializes
|
|
131
|
+
* client-side navigation for SPA transitions.
|
|
132
|
+
*/
|
|
133
|
+
function bootstrap(runtimeConfig: typeof config): void {
|
|
134
|
+
const _config = runtimeConfig;
|
|
135
|
+
|
|
136
|
+
// Take manual control of scroll restoration. React's render() on the
|
|
137
|
+
// document root resets scroll during DOM reconciliation, so the browser's
|
|
138
|
+
// native scroll restoration (scrollRestoration = 'auto') doesn't work —
|
|
139
|
+
// the browser restores scroll, then React's commit resets it to 0.
|
|
140
|
+
// We save/restore scroll positions explicitly in the history stack.
|
|
141
|
+
window.history.scrollRestoration = 'manual';
|
|
142
|
+
|
|
143
|
+
// Hydrate the React tree from the RSC payload.
|
|
144
|
+
//
|
|
145
|
+
// The RSC payload is embedded in the HTML as progressive inline script
|
|
146
|
+
// tags that call self.__timber_f.push([type, data]) as RSC chunks arrive.
|
|
147
|
+
// Typed tuples: [0] = bootstrap signal, [1, string] = Flight data chunk.
|
|
148
|
+
//
|
|
149
|
+
// We set up a ReadableStream fed by those push() calls so
|
|
150
|
+
// createFromReadableStream can decode the Flight protocol progressively.
|
|
151
|
+
//
|
|
152
|
+
// For the initial page load, the RSC payload is inlined in the HTML.
|
|
153
|
+
// For subsequent navigations, it's fetched from the server.
|
|
154
|
+
type FlightSegment = [isBootstrap: 0] | [isData: 1, data: string];
|
|
155
|
+
|
|
156
|
+
const timberChunks = (self as unknown as Record<string, FlightSegment[]>).__timber_f;
|
|
157
|
+
|
|
158
|
+
let reactRoot: Root | null = null;
|
|
159
|
+
let initialElement: unknown = null;
|
|
160
|
+
|
|
161
|
+
if (timberChunks) {
|
|
162
|
+
const encoder = new TextEncoder();
|
|
163
|
+
|
|
164
|
+
// Buffer to hold string data until the stream writer is ready.
|
|
165
|
+
// Scripts that execute before hydration starts push data here.
|
|
166
|
+
let dataBuffer: string[] | undefined = [];
|
|
167
|
+
let streamWriter: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
168
|
+
let streamFlushed = false;
|
|
169
|
+
|
|
170
|
+
/** Process a typed tuple from __timber_f. */
|
|
171
|
+
function handleSegment(seg: FlightSegment): void {
|
|
172
|
+
if (seg[0] === 0) {
|
|
173
|
+
// Bootstrap signal — initialize buffer (already done above)
|
|
174
|
+
if (!dataBuffer) dataBuffer = [];
|
|
175
|
+
} else if (seg[0] === 1) {
|
|
176
|
+
// Flight data chunk
|
|
177
|
+
if (streamWriter) {
|
|
178
|
+
streamWriter.enqueue(encoder.encode(seg[1]));
|
|
179
|
+
} else if (dataBuffer) {
|
|
180
|
+
dataBuffer.push(seg[1]);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Process any chunks that arrived before this script executed.
|
|
186
|
+
for (const seg of timberChunks) {
|
|
187
|
+
handleSegment(seg);
|
|
188
|
+
}
|
|
189
|
+
// Clear the array to release memory.
|
|
190
|
+
timberChunks.length = 0;
|
|
191
|
+
|
|
192
|
+
// Patch push() so subsequent script tags feed data in real time.
|
|
193
|
+
(timberChunks as unknown as { push: (seg: FlightSegment) => void }).push = handleSegment;
|
|
194
|
+
|
|
195
|
+
const rscPayload = new ReadableStream<Uint8Array>({
|
|
196
|
+
start(controller) {
|
|
197
|
+
streamWriter = controller;
|
|
198
|
+
// Flush buffered data into the stream.
|
|
199
|
+
if (dataBuffer) {
|
|
200
|
+
for (const data of dataBuffer) {
|
|
201
|
+
controller.enqueue(encoder.encode(data));
|
|
202
|
+
}
|
|
203
|
+
dataBuffer = undefined;
|
|
204
|
+
}
|
|
205
|
+
// If DOM already loaded (non-streaming or fast page), close now.
|
|
206
|
+
if (streamFlushed) {
|
|
207
|
+
controller.close();
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Close the stream when the document finishes loading.
|
|
213
|
+
// DOMContentLoaded fires after the HTML parser has processed all
|
|
214
|
+
// inline scripts (including streamed Suspense replacements and
|
|
215
|
+
// RSC data), so all push() calls have completed by this point.
|
|
216
|
+
//
|
|
217
|
+
// If the page is unloading (user refreshed or navigated away),
|
|
218
|
+
// do NOT close the stream. When the connection drops mid-stream,
|
|
219
|
+
// DOMContentLoaded fires because the parser finishes. Closing an
|
|
220
|
+
// incomplete RSC stream causes React's Flight client to throw
|
|
221
|
+
// "Connection closed." — a jarring error on a page being replaced.
|
|
222
|
+
// Leaving the stream open is harmless: the page is being torn down.
|
|
223
|
+
function onDOMContentLoaded(): void {
|
|
224
|
+
if (isPageUnloading()) return;
|
|
225
|
+
if (streamWriter && !streamFlushed) {
|
|
226
|
+
streamWriter.close();
|
|
227
|
+
streamFlushed = true;
|
|
228
|
+
dataBuffer = undefined;
|
|
229
|
+
}
|
|
230
|
+
streamFlushed = true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (document.readyState === 'loading') {
|
|
234
|
+
document.addEventListener('DOMContentLoaded', onDOMContentLoaded, false);
|
|
235
|
+
} else {
|
|
236
|
+
// DOM already parsed — close after a microtask to ensure
|
|
237
|
+
// any pending push() calls from inline scripts have executed.
|
|
238
|
+
setTimeout(onDOMContentLoaded);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const element = createFromReadableStream(rscPayload);
|
|
242
|
+
initialElement = element;
|
|
243
|
+
// Hydrate on document — the root layout renders the full <html> tree,
|
|
244
|
+
// so React owns the entire document from the root.
|
|
245
|
+
// Wrap with TimberNuqsAdapter so useQueryStates works out of the box.
|
|
246
|
+
const wrapped = createElement(TimberNuqsAdapter, null, element as React.ReactNode);
|
|
247
|
+
reactRoot = hydrateRoot(document, wrapped, {
|
|
248
|
+
// Suppress recoverable hydration errors from deny/error signals
|
|
249
|
+
// inside Suspense boundaries. The server already handled these
|
|
250
|
+
// (wrapStreamWithErrorHandling closes the stream cleanly after
|
|
251
|
+
// the shell is flushed). React replays the error during hydration
|
|
252
|
+
// but the server HTML is already correct — no recovery needed.
|
|
253
|
+
onRecoverableError(error: unknown) {
|
|
254
|
+
// Suppress errors during page unload (refresh/navigate away).
|
|
255
|
+
// The aborted stream causes incomplete HTML which React flags
|
|
256
|
+
// as a recoverable error — but the page is being replaced.
|
|
257
|
+
if (isPageUnloading()) return;
|
|
258
|
+
// Only log in dev — in production these are expected for
|
|
259
|
+
// deny() inside Suspense and streaming error boundaries.
|
|
260
|
+
if (process.env.NODE_ENV === 'development') {
|
|
261
|
+
console.debug('[timber] Hydration recoverable error:', error);
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
} else {
|
|
266
|
+
// No RSC payload available (plugin hasn't inlined it yet) — create a
|
|
267
|
+
// non-hydrated root so client navigation can still render RSC payloads.
|
|
268
|
+
// The initial SSR HTML remains as-is; the first client navigation will
|
|
269
|
+
// replace it with a React-managed tree.
|
|
270
|
+
reactRoot = createRoot(document);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Initialize the client-side navigation router.
|
|
274
|
+
const deps: RouterDeps = {
|
|
275
|
+
fetch: (url, init) => window.fetch(url, init),
|
|
276
|
+
pushState: (data, unused, url) => window.history.pushState(data, unused, url),
|
|
277
|
+
replaceState: (data, unused, url) => window.history.replaceState(data, unused, url),
|
|
278
|
+
scrollTo: (x, y) => {
|
|
279
|
+
window.scrollTo(x, y);
|
|
280
|
+
document.documentElement.scrollTop = y;
|
|
281
|
+
document.body.scrollTop = y;
|
|
282
|
+
// Also scroll any element explicitly marked as a scroll container.
|
|
283
|
+
for (const el of document.querySelectorAll('[data-timber-scroll-restoration]')) {
|
|
284
|
+
(el as HTMLElement).scrollTop = y;
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
getCurrentUrl: () => window.location.pathname + window.location.search,
|
|
288
|
+
getScrollY: () => {
|
|
289
|
+
if (window.scrollY || document.documentElement.scrollTop || document.body.scrollTop) {
|
|
290
|
+
return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop;
|
|
291
|
+
}
|
|
292
|
+
for (const el of document.querySelectorAll('[data-timber-scroll-restoration]')) {
|
|
293
|
+
if ((el as HTMLElement).scrollTop > 0) return (el as HTMLElement).scrollTop;
|
|
294
|
+
}
|
|
295
|
+
return 0;
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
// Decode RSC Flight stream using createFromFetch.
|
|
299
|
+
// createFromFetch takes a Promise<Response> and progressively
|
|
300
|
+
// parses the RSC stream as chunks arrive.
|
|
301
|
+
decodeRsc: (fetchPromise: Promise<Response>) => {
|
|
302
|
+
return createFromFetch(fetchPromise);
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
// Render decoded RSC tree into the hydrated React root.
|
|
306
|
+
// Wrap with TimberNuqsAdapter to maintain nuqs context across navigations.
|
|
307
|
+
renderRoot: (element: unknown) => {
|
|
308
|
+
if (reactRoot) {
|
|
309
|
+
const wrapped = createElement(TimberNuqsAdapter, null, element as React.ReactNode);
|
|
310
|
+
reactRoot.render(wrapped);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
// Schedule a callback after the next paint so scroll operations
|
|
315
|
+
// happen after React commits the new content to the DOM.
|
|
316
|
+
// Double-rAF ensures the browser has painted the new frame.
|
|
317
|
+
afterPaint: (callback: () => void) => {
|
|
318
|
+
requestAnimationFrame(() => {
|
|
319
|
+
requestAnimationFrame(callback);
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
// Apply resolved head elements (title, meta tags) to the DOM after
|
|
324
|
+
// SPA navigation. See design/16-metadata.md.
|
|
325
|
+
applyHead: applyHeadElements,
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const router = createRouter(deps);
|
|
329
|
+
setGlobalRouter(router);
|
|
330
|
+
|
|
331
|
+
// Store the initial page in the history stack so back-button works
|
|
332
|
+
// after the first navigation. We store the decoded RSC element so
|
|
333
|
+
// back navigation can replay it instantly without a server fetch.
|
|
334
|
+
router.historyStack.push(window.location.pathname + window.location.search, {
|
|
335
|
+
payload: initialElement,
|
|
336
|
+
headElements: null, // SSR already set the correct head
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Initialize history.state with scrollY for the initial entry.
|
|
340
|
+
// This ensures back navigation to the initial page restores scroll correctly.
|
|
341
|
+
window.history.replaceState({ timber: true, scrollY: 0 }, '');
|
|
342
|
+
|
|
343
|
+
// Populate the segment cache from server-embedded segment metadata.
|
|
344
|
+
// This enables state tree diffing from the very first client navigation.
|
|
345
|
+
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
346
|
+
const timberSegments = (self as unknown as Record<string, unknown>).__timber_segments;
|
|
347
|
+
if (Array.isArray(timberSegments)) {
|
|
348
|
+
router.initSegmentCache(timberSegments);
|
|
349
|
+
delete (self as unknown as Record<string, unknown>).__timber_segments;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Populate useParams() from server-embedded route params.
|
|
353
|
+
// Without this, useParams() returns {} until the first client navigation.
|
|
354
|
+
const timberParams = (self as unknown as Record<string, unknown>).__timber_params;
|
|
355
|
+
if (timberParams && typeof timberParams === 'object') {
|
|
356
|
+
setCurrentParams(timberParams as Record<string, string | string[]>);
|
|
357
|
+
delete (self as unknown as Record<string, unknown>).__timber_params;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Register popstate handler for back/forward navigation.
|
|
361
|
+
// Use pathname+search (not full href) to match the URL format used by
|
|
362
|
+
// navigate() — Link hrefs are relative paths like "/scroll-test/page-a".
|
|
363
|
+
// Read scrollY from history.state — the browser maintains per-entry state
|
|
364
|
+
// so duplicate URLs in history each have their own scroll position.
|
|
365
|
+
window.addEventListener('popstate', () => {
|
|
366
|
+
const state = window.history.state;
|
|
367
|
+
const scrollY = (state && typeof state.scrollY === 'number') ? state.scrollY : 0;
|
|
368
|
+
void router.handlePopState(window.location.pathname + window.location.search, scrollY);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Keep history.state.scrollY up to date as the user scrolls.
|
|
372
|
+
// This ensures that when the user presses back/forward, the departing
|
|
373
|
+
// page's scroll position is already saved in its history entry.
|
|
374
|
+
// Debounced to avoid excessive replaceState calls during smooth scrolling.
|
|
375
|
+
let scrollTimer: ReturnType<typeof setTimeout>;
|
|
376
|
+
function saveScrollPosition(): void {
|
|
377
|
+
clearTimeout(scrollTimer);
|
|
378
|
+
scrollTimer = setTimeout(() => {
|
|
379
|
+
const state = window.history.state;
|
|
380
|
+
if (state && typeof state === 'object') {
|
|
381
|
+
// Use getScrollY to capture scroll from overflow containers too.
|
|
382
|
+
window.history.replaceState({ ...state, scrollY: deps.getScrollY() }, '');
|
|
383
|
+
}
|
|
384
|
+
}, 100);
|
|
385
|
+
}
|
|
386
|
+
window.addEventListener('scroll', saveScrollPosition, { passive: true });
|
|
387
|
+
|
|
388
|
+
// Delegate click events on <a data-timber-link> for SPA navigation.
|
|
389
|
+
// Uses event delegation on document for efficiency — no per-link listeners.
|
|
390
|
+
document.addEventListener('click', (event: MouseEvent) => {
|
|
391
|
+
handleLinkClick(event, router);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Delegate mouseenter events on <a data-timber-prefetch> for hover prefetch.
|
|
395
|
+
// Uses capture phase to detect mouseenter on nested elements.
|
|
396
|
+
document.addEventListener(
|
|
397
|
+
'mouseenter',
|
|
398
|
+
(event: MouseEvent) => {
|
|
399
|
+
handleLinkHover(event, router);
|
|
400
|
+
},
|
|
401
|
+
true // capture phase — mouseenter doesn't bubble
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
// Dev-only: Listen for RSC module invalidation events from @vitejs/plugin-rsc.
|
|
405
|
+
// When a server component is edited, the RSC plugin sends an "rsc:update"
|
|
406
|
+
// event. We trigger a router.refresh() to re-fetch the RSC payload with
|
|
407
|
+
// the updated server code. This avoids a full page reload while still
|
|
408
|
+
// picking up server-side changes.
|
|
409
|
+
// See design/21-dev-server.md §"HMR Wiring"
|
|
410
|
+
// Vite injects import.meta.hot in dev mode. Cast to access it without
|
|
411
|
+
// requiring vite/client types in the package tsconfig.
|
|
412
|
+
const hot = (
|
|
413
|
+
import.meta as unknown as {
|
|
414
|
+
hot?: {
|
|
415
|
+
on(event: string, cb: (...args: unknown[]) => void): void;
|
|
416
|
+
send(event: string, data: unknown): void;
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
).hot;
|
|
420
|
+
if (hot) {
|
|
421
|
+
hot.on('rsc:update', () => {
|
|
422
|
+
void router.refresh();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Listen for dev warnings forwarded from the server via WebSocket.
|
|
426
|
+
// See dev-warnings.ts — emitOnce() sends these via server.hot.send().
|
|
427
|
+
hot.on('timber:dev-warning', (data: unknown) => {
|
|
428
|
+
const warning = data as { level: string; message: string };
|
|
429
|
+
if (warning.level === 'error') {
|
|
430
|
+
console.error(warning.message);
|
|
431
|
+
} else {
|
|
432
|
+
console.warn(warning.message);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Listen for server console logs forwarded via WebSocket.
|
|
437
|
+
// Replays them in the browser console with a [SERVER] prefix
|
|
438
|
+
// so developers can see server output without switching to the terminal.
|
|
439
|
+
// See plugins/dev-logs.ts.
|
|
440
|
+
setupServerLogReplay(hot);
|
|
441
|
+
|
|
442
|
+
// Forward uncaught client errors to the server for the dev overlay.
|
|
443
|
+
// The server source-maps the stack and sends it back via Vite's
|
|
444
|
+
// error overlay protocol. See dev-server.ts §client error listener.
|
|
445
|
+
setupClientErrorForwarding(hot);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ─── Server Log Replay (Dev Only) ─────────────────────────────────
|
|
450
|
+
|
|
451
|
+
/** Payload shape from plugins/dev-logs.ts */
|
|
452
|
+
interface ServerLogPayload {
|
|
453
|
+
level: 'log' | 'warn' | 'error' | 'debug' | 'info';
|
|
454
|
+
args: unknown[];
|
|
455
|
+
location: string | null;
|
|
456
|
+
timestamp: number;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Deserialize a serialized arg back into a console-friendly value.
|
|
461
|
+
*
|
|
462
|
+
* Handles Error objects (serialized as { __type: 'Error', ... }),
|
|
463
|
+
* Maps, Sets, and passes everything else through.
|
|
464
|
+
*/
|
|
465
|
+
function deserializeArg(arg: unknown): unknown {
|
|
466
|
+
if (arg === '[undefined]') return undefined;
|
|
467
|
+
if (arg === null || typeof arg !== 'object') return arg;
|
|
468
|
+
|
|
469
|
+
const obj = arg as Record<string, unknown>;
|
|
470
|
+
|
|
471
|
+
if (obj.__type === 'Error') {
|
|
472
|
+
const err = new Error(obj.message as string);
|
|
473
|
+
err.name = obj.name as string;
|
|
474
|
+
if (obj.stack) err.stack = obj.stack as string;
|
|
475
|
+
return err;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (obj.__type === 'Map') {
|
|
479
|
+
return new Map(
|
|
480
|
+
Object.entries(obj.entries as Record<string, unknown>).map(([k, v]) => [k, deserializeArg(v)])
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (obj.__type === 'Set') {
|
|
485
|
+
return new Set((obj.values as unknown[]).map(deserializeArg));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (Array.isArray(arg)) {
|
|
489
|
+
return arg.map(deserializeArg);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Plain object — recurse
|
|
493
|
+
const result: Record<string, unknown> = {};
|
|
494
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
495
|
+
result[key] = deserializeArg(value);
|
|
496
|
+
}
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Set up the HMR listener that replays server console output in the browser.
|
|
502
|
+
*
|
|
503
|
+
* Each message arrives with a log level and serialized args. We prepend
|
|
504
|
+
* a styled "[SERVER]" badge and call the matching console method.
|
|
505
|
+
*/
|
|
506
|
+
function setupServerLogReplay(hot: {
|
|
507
|
+
on(event: string, cb: (...args: unknown[]) => void): void;
|
|
508
|
+
}): void {
|
|
509
|
+
/** CSS styles for the [SERVER] badge in browser console. */
|
|
510
|
+
const BADGE_STYLES: Record<string, string> = {
|
|
511
|
+
log: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
512
|
+
info: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
513
|
+
warn: 'background: #f5a623; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
514
|
+
error:
|
|
515
|
+
'background: #e00; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
516
|
+
debug:
|
|
517
|
+
'background: #666; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
hot.on('timber:server-log', (data: unknown) => {
|
|
521
|
+
const payload = data as ServerLogPayload;
|
|
522
|
+
const level = payload.level;
|
|
523
|
+
const fn = console[level] ?? console.log;
|
|
524
|
+
const args = payload.args.map(deserializeArg);
|
|
525
|
+
|
|
526
|
+
const badge = `%cSERVER`;
|
|
527
|
+
const style = BADGE_STYLES[level] ?? BADGE_STYLES.log;
|
|
528
|
+
const locationSuffix = payload.location ? ` (${payload.location})` : '';
|
|
529
|
+
|
|
530
|
+
fn.call(console, badge, style, ...args, locationSuffix ? `\n → ${payload.location}` : '');
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ─── Client Error Forwarding (Dev Only) ──────────────────────────
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Set up global error handlers that forward uncaught client-side
|
|
538
|
+
* errors to the dev server via Vite's HMR channel.
|
|
539
|
+
*
|
|
540
|
+
* The server receives 'timber:client-error' events, and echoes them
|
|
541
|
+
* back as Vite '{ type: "error" }' payloads to trigger the overlay.
|
|
542
|
+
*/
|
|
543
|
+
function setupClientErrorForwarding(hot: { send(event: string, data: unknown): void }): void {
|
|
544
|
+
window.addEventListener('error', (event: ErrorEvent) => {
|
|
545
|
+
// Skip errors without useful information
|
|
546
|
+
if (!event.error && !event.message) return;
|
|
547
|
+
// Skip errors during page unload — these are abort-related, not application errors
|
|
548
|
+
if (isPageUnloading()) return;
|
|
549
|
+
|
|
550
|
+
const error = event.error;
|
|
551
|
+
hot.send('timber:client-error', {
|
|
552
|
+
message: error?.message ?? event.message,
|
|
553
|
+
stack: error?.stack ?? '',
|
|
554
|
+
componentStack: error?.componentStack ?? null,
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
|
|
559
|
+
const reason = event.reason;
|
|
560
|
+
if (!reason) return;
|
|
561
|
+
// Skip rejections during page unload — aborted fetches/streams cause these
|
|
562
|
+
if (isPageUnloading()) return;
|
|
563
|
+
|
|
564
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
565
|
+
const stack = reason instanceof Error ? (reason.stack ?? '') : '';
|
|
566
|
+
|
|
567
|
+
hot.send('timber:client-error', {
|
|
568
|
+
message,
|
|
569
|
+
stack,
|
|
570
|
+
componentStack: null,
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ─── Link Click Interception ─────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Handle click events on timber links. Intercepts clicks on <a> elements
|
|
579
|
+
* marked with data-timber-link and triggers SPA navigation instead of
|
|
580
|
+
* a full page load.
|
|
581
|
+
*
|
|
582
|
+
* Passes through to default browser behavior when:
|
|
583
|
+
* - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
|
|
584
|
+
* - The click is not the primary button
|
|
585
|
+
* - The link has a target attribute (e.g., target="_blank")
|
|
586
|
+
* - The link has a download attribute
|
|
587
|
+
*/
|
|
588
|
+
function handleLinkClick(event: MouseEvent, router: RouterInstance): void {
|
|
589
|
+
// Only intercept primary clicks without modifier keys
|
|
590
|
+
if (event.button !== 0) return;
|
|
591
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
592
|
+
if (event.defaultPrevented) return;
|
|
593
|
+
|
|
594
|
+
// Find the closest <a> ancestor with data-timber-link
|
|
595
|
+
const anchor = (event.target as Element).closest?.(
|
|
596
|
+
'a[data-timber-link]'
|
|
597
|
+
) as HTMLAnchorElement | null;
|
|
598
|
+
if (!anchor) return;
|
|
599
|
+
|
|
600
|
+
// Don't intercept links that should open externally
|
|
601
|
+
if (anchor.target && anchor.target !== '_self') return;
|
|
602
|
+
if (anchor.hasAttribute('download')) return;
|
|
603
|
+
|
|
604
|
+
const href = anchor.getAttribute('href');
|
|
605
|
+
if (!href) return;
|
|
606
|
+
|
|
607
|
+
// Prevent default navigation
|
|
608
|
+
event.preventDefault();
|
|
609
|
+
|
|
610
|
+
// Call onNavigate if registered on this anchor (via LinkNavigateInterceptor).
|
|
611
|
+
// If the handler calls preventDefault(), skip the default SPA navigation —
|
|
612
|
+
// the caller is responsible for navigating (e.g. via router.push()).
|
|
613
|
+
const onNavigate = anchor[ON_NAVIGATE_KEY];
|
|
614
|
+
if (onNavigate) {
|
|
615
|
+
let prevented = false;
|
|
616
|
+
onNavigate({
|
|
617
|
+
preventDefault: () => {
|
|
618
|
+
prevented = true;
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
if (prevented) return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Check scroll preference from data attribute
|
|
625
|
+
const scroll = anchor.getAttribute('data-timber-scroll') !== 'false';
|
|
626
|
+
|
|
627
|
+
// Trigger SPA navigation
|
|
628
|
+
void router.navigate(href, { scroll });
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ─── Prefetch on Hover ───────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Handle mouseenter events on prefetch-enabled links. When the user
|
|
635
|
+
* hovers over <a data-timber-prefetch>, the RSC payload is fetched
|
|
636
|
+
* and cached for near-instant navigation.
|
|
637
|
+
*
|
|
638
|
+
* See design/19-client-navigation.md §"Prefetch Cache"
|
|
639
|
+
*/
|
|
640
|
+
function handleLinkHover(event: MouseEvent, router: RouterInstance): void {
|
|
641
|
+
const anchor = (event.target as Element).closest?.(
|
|
642
|
+
'a[data-timber-prefetch]'
|
|
643
|
+
) as HTMLAnchorElement | null;
|
|
644
|
+
if (!anchor) return;
|
|
645
|
+
|
|
646
|
+
const href = anchor.getAttribute('href');
|
|
647
|
+
if (!href) return;
|
|
648
|
+
|
|
649
|
+
router.prefetch(href);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
bootstrap(config);
|
|
653
|
+
|
|
654
|
+
// Signal that the client runtime has been initialized.
|
|
655
|
+
// Used by E2E tests to wait for hydration before interacting.
|
|
656
|
+
// We append a <meta name="timber-ready"> tag rather than setting a
|
|
657
|
+
// data attribute on <html>. Since React owns the entire document
|
|
658
|
+
// via hydrateRoot(document, ...), mutating <html> attributes causes
|
|
659
|
+
// hydration mismatch warnings. Dynamically-added <meta> tags don't
|
|
660
|
+
// conflict because React doesn't reconcile them.
|
|
661
|
+
const readyMeta = document.createElement('meta');
|
|
662
|
+
readyMeta.name = 'timber-ready';
|
|
663
|
+
document.head.appendChild(readyMeta);
|