@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,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracing — per-request trace ID via AsyncLocalStorage, OTEL span helpers.
|
|
3
|
+
*
|
|
4
|
+
* traceId() is always available in server code (middleware, access, components, actions).
|
|
5
|
+
* Returns a 32-char lowercase hex string — the OTEL trace ID when an SDK is active,
|
|
6
|
+
* or a crypto.randomUUID()-derived fallback otherwise.
|
|
7
|
+
*
|
|
8
|
+
* See design/17-logging.md §"trace_id is Always Set"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
// ─── ALS Store ────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface TraceStore {
|
|
17
|
+
/** 32-char lowercase hex trace ID (OTEL or UUID fallback). */
|
|
18
|
+
traceId: string;
|
|
19
|
+
/** OTEL span ID if available, undefined otherwise. */
|
|
20
|
+
spanId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const traceAls = new AsyncLocalStorage<TraceStore>();
|
|
24
|
+
|
|
25
|
+
// ─── Public API ───────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the current request's trace ID — always a 32-char lowercase hex string.
|
|
29
|
+
*
|
|
30
|
+
* With OTEL: the real OTEL trace ID (matches Jaeger/Honeycomb/Datadog).
|
|
31
|
+
* Without OTEL: crypto.randomUUID() with hyphens stripped.
|
|
32
|
+
*
|
|
33
|
+
* Throws if called outside a request context (no ALS store).
|
|
34
|
+
*/
|
|
35
|
+
export function traceId(): string {
|
|
36
|
+
const store = traceAls.getStore();
|
|
37
|
+
if (!store) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'[timber] traceId() called outside of a request context. ' +
|
|
40
|
+
'It can only be used in middleware, access checks, server components, and server actions.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return store.traceId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns the current OTEL span ID if available, undefined otherwise.
|
|
48
|
+
*/
|
|
49
|
+
export function spanId(): string | undefined {
|
|
50
|
+
return traceAls.getStore()?.spanId;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Framework-Internal Helpers ───────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate a 32-char lowercase hex ID from crypto.randomUUID().
|
|
57
|
+
* Same format as OTEL trace IDs — zero-friction upgrade path.
|
|
58
|
+
*/
|
|
59
|
+
export function generateTraceId(): string {
|
|
60
|
+
return randomUUID().replace(/-/g, '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run a callback within a trace context. Used by the pipeline to establish
|
|
65
|
+
* per-request ALS scope.
|
|
66
|
+
*/
|
|
67
|
+
export function runWithTraceId<T>(id: string, fn: () => T): T {
|
|
68
|
+
return traceAls.run({ traceId: id }, fn);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Replace the trace ID in the current ALS store. Used when OTEL creates
|
|
73
|
+
* a root span and we want to switch from the UUID fallback to the real
|
|
74
|
+
* OTEL trace ID.
|
|
75
|
+
*/
|
|
76
|
+
export function replaceTraceId(newTraceId: string, newSpanId?: string): void {
|
|
77
|
+
const store = traceAls.getStore();
|
|
78
|
+
if (store) {
|
|
79
|
+
store.traceId = newTraceId;
|
|
80
|
+
store.spanId = newSpanId;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Update the span ID in the current ALS store. Used when entering a new
|
|
86
|
+
* OTEL span to keep log–trace correlation accurate.
|
|
87
|
+
*/
|
|
88
|
+
export function updateSpanId(newSpanId: string | undefined): void {
|
|
89
|
+
const store = traceAls.getStore();
|
|
90
|
+
if (store) {
|
|
91
|
+
store.spanId = newSpanId;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get the current trace store, or undefined if outside a request context.
|
|
97
|
+
* Framework-internal — use traceId()/spanId() in user code.
|
|
98
|
+
*/
|
|
99
|
+
export function getTraceStore(): TraceStore | undefined {
|
|
100
|
+
return traceAls.getStore();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Dev-Mode OTEL Auto-Init ─────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Initialize a minimal OTEL SDK in dev mode so spans are recorded and
|
|
107
|
+
* fed to the DevSpanProcessor for dev log output.
|
|
108
|
+
*
|
|
109
|
+
* If the user already configured an OTEL SDK in register(), we add
|
|
110
|
+
* our DevSpanProcessor alongside theirs. If no SDK is configured,
|
|
111
|
+
* we create a BasicTracerProvider with our processor.
|
|
112
|
+
*
|
|
113
|
+
* Only called in dev mode — zero overhead in production.
|
|
114
|
+
*/
|
|
115
|
+
export async function initDevTracing(
|
|
116
|
+
config: import('./dev-logger.js').DevLoggerConfig
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
const api = await getOtelApi();
|
|
119
|
+
if (!api) return;
|
|
120
|
+
|
|
121
|
+
const { DevSpanProcessor } = await import('./dev-span-processor.js');
|
|
122
|
+
const { BasicTracerProvider } = await import('@opentelemetry/sdk-trace-base');
|
|
123
|
+
const { AsyncLocalStorageContextManager } = await import('@opentelemetry/context-async-hooks');
|
|
124
|
+
const processor = new DevSpanProcessor(config);
|
|
125
|
+
|
|
126
|
+
// Register a context manager so OTEL can propagate the active span
|
|
127
|
+
// across async boundaries. Without this, startActiveSpan can't make
|
|
128
|
+
// spans "active" — child spans get random trace IDs and getActiveSpan()
|
|
129
|
+
// returns undefined.
|
|
130
|
+
const contextManager = new AsyncLocalStorageContextManager();
|
|
131
|
+
contextManager.enable();
|
|
132
|
+
api.context.setGlobalContextManager(contextManager);
|
|
133
|
+
|
|
134
|
+
// Create a minimal TracerProvider with our DevSpanProcessor.
|
|
135
|
+
// If the user also configures an SDK in register(), their provider
|
|
136
|
+
// will coexist — the global provider set last wins for new tracers,
|
|
137
|
+
// but our processor captures all spans from the timber.js tracer.
|
|
138
|
+
const provider = new BasicTracerProvider({
|
|
139
|
+
spanProcessors: [processor],
|
|
140
|
+
});
|
|
141
|
+
api.trace.setGlobalTracerProvider(provider);
|
|
142
|
+
|
|
143
|
+
// Reset cached tracer so next getTracer() picks up the new provider
|
|
144
|
+
_tracer = undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── OTEL Span Helpers ───────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Attempt to get the @opentelemetry/api tracer. Returns undefined if the
|
|
151
|
+
* package is not installed or no SDK is registered.
|
|
152
|
+
*
|
|
153
|
+
* timber.js depends on @opentelemetry/api as the vendor-neutral interface.
|
|
154
|
+
* The API is a no-op by default — spans are only emitted when the developer
|
|
155
|
+
* initializes an SDK in register().
|
|
156
|
+
*/
|
|
157
|
+
let _otelApi: typeof import('@opentelemetry/api') | null | undefined;
|
|
158
|
+
|
|
159
|
+
async function getOtelApi(): Promise<typeof import('@opentelemetry/api') | null> {
|
|
160
|
+
if (_otelApi === undefined) {
|
|
161
|
+
try {
|
|
162
|
+
_otelApi = await import('@opentelemetry/api');
|
|
163
|
+
} catch {
|
|
164
|
+
_otelApi = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return _otelApi;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** OTEL tracer instance, lazily created. */
|
|
171
|
+
let _tracer: import('@opentelemetry/api').Tracer | null | undefined;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get the timber.js OTEL tracer. Returns null if @opentelemetry/api is not available.
|
|
175
|
+
*/
|
|
176
|
+
export async function getTracer(): Promise<import('@opentelemetry/api').Tracer | null> {
|
|
177
|
+
if (_tracer === undefined) {
|
|
178
|
+
const api = await getOtelApi();
|
|
179
|
+
if (api) {
|
|
180
|
+
_tracer = api.trace.getTracer('timber.js');
|
|
181
|
+
} else {
|
|
182
|
+
_tracer = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return _tracer;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Run a function within an OTEL span. If OTEL is not available, runs the function
|
|
190
|
+
* directly without any span overhead.
|
|
191
|
+
*
|
|
192
|
+
* Automatically:
|
|
193
|
+
* - Creates the span as a child of the current context
|
|
194
|
+
* - Updates the ALS span ID for log–trace correlation
|
|
195
|
+
* - Ends the span when the function completes
|
|
196
|
+
* - Records exceptions on error
|
|
197
|
+
*/
|
|
198
|
+
export async function withSpan<T>(
|
|
199
|
+
name: string,
|
|
200
|
+
attributes: Record<string, string | number | boolean>,
|
|
201
|
+
fn: () => T | Promise<T>
|
|
202
|
+
): Promise<T> {
|
|
203
|
+
const tracer = await getTracer();
|
|
204
|
+
if (!tracer) {
|
|
205
|
+
return fn();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const api = (await getOtelApi())!;
|
|
209
|
+
return tracer.startActiveSpan(name, { attributes }, async (span) => {
|
|
210
|
+
const prevSpanId = spanId();
|
|
211
|
+
updateSpanId(span.spanContext().spanId);
|
|
212
|
+
try {
|
|
213
|
+
const result = await fn();
|
|
214
|
+
span.setStatus({ code: api.SpanStatusCode.OK });
|
|
215
|
+
return result;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
span.setStatus({ code: api.SpanStatusCode.ERROR });
|
|
218
|
+
if (error instanceof Error) {
|
|
219
|
+
span.recordException(error);
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
222
|
+
} finally {
|
|
223
|
+
span.end();
|
|
224
|
+
updateSpanId(prevSpanId);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Set an attribute on the current active span (if any).
|
|
231
|
+
* Used for setting span attributes after span creation (e.g. timber.result on access spans).
|
|
232
|
+
*/
|
|
233
|
+
export async function setSpanAttribute(
|
|
234
|
+
key: string,
|
|
235
|
+
value: string | number | boolean
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
const api = await getOtelApi();
|
|
238
|
+
if (!api) return;
|
|
239
|
+
|
|
240
|
+
const activeSpan = api.trace.getActiveSpan();
|
|
241
|
+
if (activeSpan) {
|
|
242
|
+
activeSpan.setAttribute(key, value);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Add a span event to the current active span (if any).
|
|
248
|
+
* Used for timber.cache HIT/MISS events — recorded as span events, not child spans.
|
|
249
|
+
*/
|
|
250
|
+
export async function addSpanEvent(
|
|
251
|
+
name: string,
|
|
252
|
+
attributes?: Record<string, string | number | boolean>
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
const api = await getOtelApi();
|
|
255
|
+
if (!api) return;
|
|
256
|
+
|
|
257
|
+
const activeSpan = api.trace.getActiveSpan();
|
|
258
|
+
if (activeSpan) {
|
|
259
|
+
activeSpan.addEvent(name, attributes);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Try to extract the OTEL trace ID from the current active span context.
|
|
265
|
+
* Returns undefined if OTEL is not active or no span exists.
|
|
266
|
+
*/
|
|
267
|
+
export async function getOtelTraceId(): Promise<{ traceId: string; spanId: string } | undefined> {
|
|
268
|
+
const api = await getOtelApi();
|
|
269
|
+
if (!api) return undefined;
|
|
270
|
+
|
|
271
|
+
const activeSpan = api.trace.getActiveSpan();
|
|
272
|
+
if (!activeSpan) return undefined;
|
|
273
|
+
|
|
274
|
+
const ctx = activeSpan.spanContext();
|
|
275
|
+
// OTEL uses "0000000000000000" as invalid trace IDs
|
|
276
|
+
if (!ctx.traceId || ctx.traceId === '00000000000000000000000000000000') {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { traceId: ctx.traceId, spanId: ctx.spanId };
|
|
281
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Element tree construction for timber.js rendering.
|
|
3
|
+
*
|
|
4
|
+
* Builds a unified React element tree from a matched segment chain, bottom-up:
|
|
5
|
+
* page → status-code error boundaries → access gates → layout → repeat up segment chain
|
|
6
|
+
*
|
|
7
|
+
* The tree is rendered via a single `renderToReadableStream` call,
|
|
8
|
+
* giving one `React.cache` scope for the entire route.
|
|
9
|
+
*
|
|
10
|
+
* See design/02-rendering-pipeline.md §"Element Tree Construction"
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SegmentNode, RouteFile } from '#/routing/types.js';
|
|
14
|
+
import { TimberErrorBoundary } from '#/client/error-boundary.js';
|
|
15
|
+
|
|
16
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** A loaded module for a route file convention. */
|
|
19
|
+
export interface LoadedModule {
|
|
20
|
+
/** The default export (component, access function, etc.) */
|
|
21
|
+
default?: unknown;
|
|
22
|
+
/** Named exports (for route.ts method handlers, metadata, etc.) */
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Function that loads a route file's module. */
|
|
27
|
+
export type ModuleLoader = (file: RouteFile) => LoadedModule | Promise<LoadedModule>;
|
|
28
|
+
|
|
29
|
+
/** A React element — kept opaque to avoid a React dependency in this module. */
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
export type ReactElement = any;
|
|
32
|
+
|
|
33
|
+
/** Function that creates a React element. Matches React.createElement signature. */
|
|
34
|
+
export type CreateElement = (
|
|
35
|
+
type: unknown,
|
|
36
|
+
props: Record<string, unknown> | null,
|
|
37
|
+
...children: unknown[]
|
|
38
|
+
) => ReactElement;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolved slot content for a layout.
|
|
42
|
+
* Key is slot name (without @), value is the element tree for that slot.
|
|
43
|
+
*/
|
|
44
|
+
export type SlotElements = Map<string, ReactElement>;
|
|
45
|
+
|
|
46
|
+
/** Configuration for the tree builder. */
|
|
47
|
+
export interface TreeBuilderConfig {
|
|
48
|
+
/** The matched segment chain from root to leaf. */
|
|
49
|
+
segments: SegmentNode[];
|
|
50
|
+
/** Route params extracted by the matcher (catch-all segments produce string[]). */
|
|
51
|
+
params: Record<string, string | string[]>;
|
|
52
|
+
/** Parsed search params (typed or URLSearchParams). */
|
|
53
|
+
searchParams: unknown;
|
|
54
|
+
/** Loads a route file's module. */
|
|
55
|
+
loadModule: ModuleLoader;
|
|
56
|
+
/** React.createElement or equivalent. */
|
|
57
|
+
createElement: CreateElement;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Component wrappers ──────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Framework-injected access gate component.
|
|
64
|
+
*
|
|
65
|
+
* When `verdict` is provided (from the pre-render pass), AccessGate replays
|
|
66
|
+
* the stored result synchronously — no re-execution, no async, immune to
|
|
67
|
+
* Suspense timing. When `verdict` is absent, falls back to calling `accessFn`
|
|
68
|
+
* (backward compat for tree-builder.ts which doesn't run a pre-render pass).
|
|
69
|
+
*/
|
|
70
|
+
export interface AccessGateProps {
|
|
71
|
+
accessFn: (ctx: { params: Record<string, string | string[]>; searchParams: unknown }) => unknown;
|
|
72
|
+
params: Record<string, string | string[]>;
|
|
73
|
+
searchParams: unknown;
|
|
74
|
+
/** Segment name for dev logging (e.g. "authenticated", "dashboard"). */
|
|
75
|
+
segmentName?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Pre-computed verdict from the pre-render pass. When set, AccessGate
|
|
78
|
+
* replays this verdict synchronously instead of calling accessFn.
|
|
79
|
+
* - 'pass': render children
|
|
80
|
+
* - DenySignal/RedirectSignal: throw synchronously
|
|
81
|
+
*/
|
|
82
|
+
verdict?: 'pass' | import('./primitives.js').DenySignal | import('./primitives.js').RedirectSignal;
|
|
83
|
+
children: ReactElement;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Framework-injected slot access gate component.
|
|
88
|
+
* On denial, renders denied.tsx → default.tsx → null instead of failing the page.
|
|
89
|
+
*/
|
|
90
|
+
export interface SlotAccessGateProps {
|
|
91
|
+
accessFn: (ctx: { params: Record<string, string | string[]>; searchParams: unknown }) => unknown;
|
|
92
|
+
params: Record<string, string | string[]>;
|
|
93
|
+
searchParams: unknown;
|
|
94
|
+
deniedFallback: ReactElement | null;
|
|
95
|
+
defaultFallback: ReactElement | null;
|
|
96
|
+
children: ReactElement;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Framework-injected error boundary wrapper.
|
|
101
|
+
* Wraps content with status-code error boundary handling.
|
|
102
|
+
*/
|
|
103
|
+
export interface ErrorBoundaryProps {
|
|
104
|
+
fallbackComponent: ReactElement | null;
|
|
105
|
+
status?: number;
|
|
106
|
+
children: ReactElement;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Tree Builder ────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Result of building the element tree.
|
|
113
|
+
*/
|
|
114
|
+
export interface TreeBuildResult {
|
|
115
|
+
/** The root React element tree ready for renderToReadableStream. */
|
|
116
|
+
tree: ReactElement;
|
|
117
|
+
/** Whether the leaf segment is a route.ts (API endpoint) rather than a page. */
|
|
118
|
+
isApiRoute: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build the unified element tree from a matched segment chain.
|
|
123
|
+
*
|
|
124
|
+
* Construction is bottom-up:
|
|
125
|
+
* 1. Start with the page component (leaf segment)
|
|
126
|
+
* 2. Wrap in status-code error boundaries (fallback chain)
|
|
127
|
+
* 3. Wrap in AccessGate (if segment has access.ts)
|
|
128
|
+
* 4. Pass as children to the segment's layout
|
|
129
|
+
* 5. Repeat up the segment chain to root
|
|
130
|
+
*
|
|
131
|
+
* Parallel slots are resolved at each layout level and composed as named props.
|
|
132
|
+
*/
|
|
133
|
+
export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeBuildResult> {
|
|
134
|
+
const { segments, params, searchParams, loadModule, createElement } = config;
|
|
135
|
+
|
|
136
|
+
if (segments.length === 0) {
|
|
137
|
+
throw new Error('[timber] buildElementTree: empty segment chain');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const leaf = segments[segments.length - 1];
|
|
141
|
+
|
|
142
|
+
// API routes (route.ts) don't build a React tree
|
|
143
|
+
if (leaf.route && !leaf.page) {
|
|
144
|
+
return { tree: null, isApiRoute: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Start with the page component
|
|
148
|
+
const pageModule = leaf.page ? await loadModule(leaf.page) : null;
|
|
149
|
+
const PageComponent = pageModule?.default as ((...args: unknown[]) => ReactElement) | undefined;
|
|
150
|
+
|
|
151
|
+
if (!PageComponent) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`[timber] No page component found for route at ${leaf.urlPath}. ` +
|
|
154
|
+
'Each route must have a page.tsx or route.ts.'
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Build the page element with params and searchParams props
|
|
159
|
+
let element: ReactElement = createElement(PageComponent, { params, searchParams });
|
|
160
|
+
|
|
161
|
+
// Build tree bottom-up: wrap page, then walk segments from leaf to root
|
|
162
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
163
|
+
const segment = segments[i];
|
|
164
|
+
|
|
165
|
+
// Wrap in error boundaries (status-code files + error.tsx)
|
|
166
|
+
element = await wrapWithErrorBoundaries(segment, element, loadModule, createElement);
|
|
167
|
+
|
|
168
|
+
// Wrap in AccessGate if segment has access.ts
|
|
169
|
+
if (segment.access) {
|
|
170
|
+
const accessModule = await loadModule(segment.access);
|
|
171
|
+
const accessFn = accessModule.default as AccessGateProps['accessFn'];
|
|
172
|
+
element = createElement('timber:access-gate', {
|
|
173
|
+
accessFn,
|
|
174
|
+
params,
|
|
175
|
+
searchParams,
|
|
176
|
+
segmentName: segment.segmentName,
|
|
177
|
+
children: element,
|
|
178
|
+
} satisfies AccessGateProps);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Wrap in layout (if exists and not the leaf's page-level wrapping)
|
|
182
|
+
if (segment.layout) {
|
|
183
|
+
const layoutModule = await loadModule(segment.layout);
|
|
184
|
+
const LayoutComponent = layoutModule.default as
|
|
185
|
+
| ((...args: unknown[]) => ReactElement)
|
|
186
|
+
| undefined;
|
|
187
|
+
|
|
188
|
+
if (LayoutComponent) {
|
|
189
|
+
// Resolve parallel slots for this layout
|
|
190
|
+
const slotProps: Record<string, ReactElement> = {};
|
|
191
|
+
if (segment.slots.size > 0) {
|
|
192
|
+
for (const [slotName, slotNode] of segment.slots) {
|
|
193
|
+
slotProps[slotName] = await buildSlotElement(
|
|
194
|
+
slotNode,
|
|
195
|
+
params,
|
|
196
|
+
searchParams,
|
|
197
|
+
loadModule,
|
|
198
|
+
createElement
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
element = createElement(LayoutComponent, {
|
|
204
|
+
...slotProps,
|
|
205
|
+
params,
|
|
206
|
+
searchParams,
|
|
207
|
+
children: element,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { tree: element, isApiRoute: false };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Slot Element Builder ────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build the element tree for a parallel slot.
|
|
220
|
+
*
|
|
221
|
+
* Slots have their own access.ts (SlotAccessGate) and error boundaries.
|
|
222
|
+
* On access denial: denied.tsx → default.tsx → null (graceful degradation).
|
|
223
|
+
*/
|
|
224
|
+
async function buildSlotElement(
|
|
225
|
+
slotNode: SegmentNode,
|
|
226
|
+
params: Record<string, string | string[]>,
|
|
227
|
+
searchParams: unknown,
|
|
228
|
+
loadModule: ModuleLoader,
|
|
229
|
+
createElement: CreateElement
|
|
230
|
+
): Promise<ReactElement> {
|
|
231
|
+
// Load slot page
|
|
232
|
+
const pageModule = slotNode.page ? await loadModule(slotNode.page) : null;
|
|
233
|
+
const PageComponent = pageModule?.default as ((...args: unknown[]) => ReactElement) | undefined;
|
|
234
|
+
|
|
235
|
+
// Load default.tsx fallback
|
|
236
|
+
const defaultModule = slotNode.default ? await loadModule(slotNode.default) : null;
|
|
237
|
+
const DefaultComponent = defaultModule?.default as
|
|
238
|
+
| ((...args: unknown[]) => ReactElement)
|
|
239
|
+
| undefined;
|
|
240
|
+
|
|
241
|
+
// If no page, render default.tsx or null
|
|
242
|
+
if (!PageComponent) {
|
|
243
|
+
return DefaultComponent ? createElement(DefaultComponent, { params, searchParams }) : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let element: ReactElement = createElement(PageComponent, { params, searchParams });
|
|
247
|
+
|
|
248
|
+
// Wrap in error boundaries
|
|
249
|
+
element = await wrapWithErrorBoundaries(slotNode, element, loadModule, createElement);
|
|
250
|
+
|
|
251
|
+
// Wrap in SlotAccessGate if slot has access.ts
|
|
252
|
+
if (slotNode.access) {
|
|
253
|
+
const accessModule = await loadModule(slotNode.access);
|
|
254
|
+
const accessFn = accessModule.default as SlotAccessGateProps['accessFn'];
|
|
255
|
+
|
|
256
|
+
// Load denied.tsx
|
|
257
|
+
const deniedModule = slotNode.denied ? await loadModule(slotNode.denied) : null;
|
|
258
|
+
const DeniedComponent = deniedModule?.default as
|
|
259
|
+
| ((...args: unknown[]) => ReactElement)
|
|
260
|
+
| undefined;
|
|
261
|
+
|
|
262
|
+
const deniedFallback = DeniedComponent
|
|
263
|
+
? createElement(DeniedComponent, {
|
|
264
|
+
slot: slotNode.segmentName.replace(/^@/, ''),
|
|
265
|
+
dangerouslyPassData: undefined,
|
|
266
|
+
})
|
|
267
|
+
: null;
|
|
268
|
+
const defaultFallback = DefaultComponent
|
|
269
|
+
? createElement(DefaultComponent, { params, searchParams })
|
|
270
|
+
: null;
|
|
271
|
+
|
|
272
|
+
element = createElement('timber:slot-access-gate', {
|
|
273
|
+
accessFn,
|
|
274
|
+
params,
|
|
275
|
+
searchParams,
|
|
276
|
+
deniedFallback,
|
|
277
|
+
defaultFallback,
|
|
278
|
+
children: element,
|
|
279
|
+
} satisfies SlotAccessGateProps);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return element;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── Error Boundary Wrapping ─────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Wrap an element with error boundaries from a segment's status-code files.
|
|
289
|
+
*
|
|
290
|
+
* Wrapping order (innermost to outermost):
|
|
291
|
+
* 1. Specific status files (503.tsx, 429.tsx, etc.)
|
|
292
|
+
* 2. Category catch-alls (4xx.tsx, 5xx.tsx)
|
|
293
|
+
* 3. error.tsx (general error boundary)
|
|
294
|
+
*
|
|
295
|
+
* This creates the fallback chain described in design/10-error-handling.md.
|
|
296
|
+
*/
|
|
297
|
+
async function wrapWithErrorBoundaries(
|
|
298
|
+
segment: SegmentNode,
|
|
299
|
+
element: ReactElement,
|
|
300
|
+
loadModule: ModuleLoader,
|
|
301
|
+
createElement: CreateElement
|
|
302
|
+
): Promise<ReactElement> {
|
|
303
|
+
// Wrapping is applied inside-out. The last wrap call produces the outermost boundary.
|
|
304
|
+
// Order: specific status → category → error.tsx (outermost)
|
|
305
|
+
|
|
306
|
+
if (segment.statusFiles) {
|
|
307
|
+
// Wrap with specific status files (innermost — highest priority at runtime)
|
|
308
|
+
for (const [key, file] of segment.statusFiles) {
|
|
309
|
+
if (key !== '4xx' && key !== '5xx') {
|
|
310
|
+
const status = parseInt(key, 10);
|
|
311
|
+
if (!isNaN(status)) {
|
|
312
|
+
const mod = await loadModule(file);
|
|
313
|
+
const Component = mod.default;
|
|
314
|
+
if (Component) {
|
|
315
|
+
element = createElement(TimberErrorBoundary, {
|
|
316
|
+
fallbackComponent: Component,
|
|
317
|
+
status,
|
|
318
|
+
children: element,
|
|
319
|
+
} satisfies ErrorBoundaryProps);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Wrap with category catch-alls (4xx.tsx, 5xx.tsx)
|
|
326
|
+
for (const [key, file] of segment.statusFiles) {
|
|
327
|
+
if (key === '4xx' || key === '5xx') {
|
|
328
|
+
const mod = await loadModule(file);
|
|
329
|
+
const Component = mod.default;
|
|
330
|
+
if (Component) {
|
|
331
|
+
element = createElement(TimberErrorBoundary, {
|
|
332
|
+
fallbackComponent: Component,
|
|
333
|
+
status: key === '4xx' ? 400 : 500, // category marker
|
|
334
|
+
children: element,
|
|
335
|
+
} satisfies ErrorBoundaryProps);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Wrap with error.tsx (outermost — catches anything not matched by status files)
|
|
342
|
+
if (segment.error) {
|
|
343
|
+
const errorModule = await loadModule(segment.error);
|
|
344
|
+
const ErrorComponent = errorModule.default;
|
|
345
|
+
if (ErrorComponent) {
|
|
346
|
+
element = createElement(TimberErrorBoundary, {
|
|
347
|
+
fallbackComponent: ErrorComponent,
|
|
348
|
+
children: element,
|
|
349
|
+
} satisfies ErrorBoundaryProps);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return element;
|
|
354
|
+
}
|