@timber-js/app 0.2.0-alpha.60 → 0.2.0-alpha.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{als-registry-Ba7URUIn.js → als-registry-BJARkOcu.js} +1 -1
- package/dist/_chunks/{als-registry-Ba7URUIn.js.map → als-registry-BJARkOcu.js.map} +1 -1
- package/dist/_chunks/{define-D5STJpIr.js → define-Djpqoe1K.js} +2 -2
- package/dist/_chunks/{define-D5STJpIr.js.map → define-Djpqoe1K.js.map} +1 -1
- package/dist/_chunks/{define-cookie-DtAavax4.js → define-cookie-B2djY9w0.js} +4 -4
- package/dist/_chunks/{define-cookie-DtAavax4.js.map → define-cookie-B2djY9w0.js.map} +1 -1
- package/dist/_chunks/{define-TK8C1M3x.js → define-hdajFTq7.js} +2 -2
- package/dist/_chunks/{define-TK8C1M3x.js.map → define-hdajFTq7.js.map} +1 -1
- package/dist/_chunks/{error-boundary-DpZJBCqh.js → error-boundary-A_sgyyUP.js} +1 -1
- package/dist/_chunks/{error-boundary-DpZJBCqh.js.map → error-boundary-A_sgyyUP.js.map} +1 -1
- package/dist/_chunks/{tracing-VYETCQsg.js → handler-store-CaE0ZgVG.js} +54 -3
- package/dist/_chunks/handler-store-CaE0ZgVG.js.map +1 -0
- package/dist/_chunks/{interception-Cey5DCGr.js → interception-BVm64Jr5.js} +7 -13
- package/dist/_chunks/interception-BVm64Jr5.js.map +1 -0
- package/dist/_chunks/{metadata-routes-BU684ls2.js → metadata-routes-DS3eKNmf.js} +1 -1
- package/dist/_chunks/{metadata-routes-BU684ls2.js.map → metadata-routes-DS3eKNmf.js.map} +1 -1
- package/dist/_chunks/{request-context-0wfZsnhh.js → request-context-B_u9dyhZ.js} +4 -4
- package/dist/_chunks/{request-context-0wfZsnhh.js.map → request-context-B_u9dyhZ.js.map} +1 -1
- package/dist/_chunks/segment-classify-BDNn6EzD.js +65 -0
- package/dist/_chunks/segment-classify-BDNn6EzD.js.map +1 -0
- package/dist/_chunks/{segment-context-CyaM1mrD.js → segment-context-CVRHlkkQ.js} +1 -1
- package/dist/_chunks/{segment-context-CyaM1mrD.js.map → segment-context-CVRHlkkQ.js.map} +1 -1
- package/dist/_chunks/{stale-reload-DKN3aXxR.js → stale-reload-BeyHXZ5B.js} +5 -2
- package/dist/_chunks/{stale-reload-DKN3aXxR.js.map → stale-reload-BeyHXZ5B.js.map} +1 -1
- package/dist/_chunks/{use-query-states-wEXY2JQB.js → use-query-states-DAhgj8Gx.js} +1 -1
- package/dist/_chunks/{use-query-states-wEXY2JQB.js.map → use-query-states-DAhgj8Gx.js.map} +1 -1
- package/dist/_chunks/{wrappers-BaG1bnM3.js → wrappers-CJQ3KwVr.js} +1 -1
- package/dist/_chunks/{wrappers-BaG1bnM3.js.map → wrappers-CJQ3KwVr.js.map} +1 -1
- package/dist/cache/cache-api.d.ts +24 -0
- package/dist/cache/cache-api.d.ts.map +1 -0
- package/dist/cache/handler-store.d.ts +31 -0
- package/dist/cache/handler-store.d.ts.map +1 -0
- package/dist/cache/index.d.ts +2 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +33 -2
- package/dist/cache/index.js.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +50 -21
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +6 -0
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +12 -0
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -1
- package/dist/index.js +55 -3
- package/dist/index.js.map +1 -1
- package/dist/params/define.d.ts.map +1 -1
- package/dist/params/index.js +3 -3
- package/dist/plugins/dev-browser-logs.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/routing/index.d.ts +2 -0
- package/dist/routing/index.d.ts.map +1 -1
- package/dist/routing/index.js +3 -2
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/segment-classify.d.ts +46 -0
- package/dist/routing/segment-classify.d.ts.map +1 -0
- package/dist/search-params/index.js +3 -3
- package/dist/server/actions.d.ts +0 -3
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/index.js +100 -17
- package/dist/server/index.js.map +1 -1
- package/dist/server/pipeline-interception.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/api-handler.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-bridge.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/safe-load.d.ts +46 -0
- package/dist/server/safe-load.d.ts.map +1 -0
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/stream-utils.d.ts.map +1 -1
- package/dist/server/tracing.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cache/cache-api.ts +38 -0
- package/src/cache/handler-store.ts +68 -0
- package/src/cache/index.ts +2 -1
- package/src/client/browser-entry.ts +90 -31
- package/src/client/index.ts +1 -1
- package/src/client/link.tsx +81 -46
- package/src/client/rsc-fetch.ts +3 -1
- package/src/client/stale-reload.ts +19 -2
- package/src/client/transition-root.tsx +27 -0
- package/src/params/define.ts +11 -4
- package/src/plugins/dev-browser-logs.ts +10 -0
- package/src/plugins/entries.ts +61 -0
- package/src/plugins/routing.ts +1 -1
- package/src/routing/index.ts +2 -0
- package/src/routing/scanner.ts +7 -16
- package/src/routing/segment-classify.ts +89 -0
- package/src/server/actions.ts +7 -6
- package/src/server/deny-renderer.ts +4 -3
- package/src/server/error-boundary-wrapper.ts +9 -6
- package/src/server/fallback-error.ts +20 -7
- package/src/server/pipeline-interception.ts +16 -15
- package/src/server/pipeline.ts +20 -2
- package/src/server/route-element-builder.ts +5 -4
- package/src/server/rsc-entry/api-handler.ts +4 -3
- package/src/server/rsc-entry/error-renderer.ts +25 -10
- package/src/server/rsc-entry/index.ts +24 -0
- package/src/server/rsc-entry/rsc-payload.ts +1 -1
- package/src/server/rsc-entry/ssr-bridge.ts +13 -4
- package/src/server/rsc-entry/ssr-renderer.ts +12 -1
- package/src/server/safe-load.ts +60 -0
- package/src/server/slot-resolver.ts +3 -1
- package/src/server/stream-utils.ts +10 -6
- package/src/server/tracing.ts +14 -3
- package/dist/_chunks/interception-Cey5DCGr.js.map +0 -1
- package/dist/_chunks/tracing-VYETCQsg.js.map +0 -1
|
@@ -176,3 +176,30 @@ export function navigateTransition(
|
|
|
176
176
|
export function isTransitionRootReady(): boolean {
|
|
177
177
|
return _transitionRender !== null;
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Install one-shot deferred callbacks for the no-RSC bootstrap path (TIM-600).
|
|
182
|
+
*
|
|
183
|
+
* When there's no RSC payload, we can't create a React root immediately —
|
|
184
|
+
* `createRoot(document).render(...)` would blank the SSR HTML. Instead,
|
|
185
|
+
* this sets up `_transitionRender` and `_navigateTransition` so that the
|
|
186
|
+
* first client navigation triggers root creation via `createAndMount`.
|
|
187
|
+
*
|
|
188
|
+
* After `createAndMount` runs, TransitionRoot renders and overwrites these
|
|
189
|
+
* callbacks with its real `startTransition`-based implementations.
|
|
190
|
+
*/
|
|
191
|
+
export function installDeferredNavigation(createAndMount: (initial: ReactNode) => void): void {
|
|
192
|
+
let mounted = false;
|
|
193
|
+
const mountOnce = (element: ReactNode) => {
|
|
194
|
+
if (mounted) return;
|
|
195
|
+
mounted = true;
|
|
196
|
+
createAndMount(element);
|
|
197
|
+
};
|
|
198
|
+
_transitionRender = (element: ReactNode) => {
|
|
199
|
+
mountOnce(element);
|
|
200
|
+
};
|
|
201
|
+
_navigateTransition = async (_pendingUrl: string, perform: () => Promise<ReactNode>) => {
|
|
202
|
+
const element = await perform();
|
|
203
|
+
mountOnce(element);
|
|
204
|
+
};
|
|
205
|
+
}
|
package/src/params/define.ts
CHANGED
|
@@ -297,16 +297,23 @@ export function defineSegmentParams<C extends Record<string, ParamField>>(
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
// ---- load ----
|
|
300
|
-
// ALS-backed: reads
|
|
301
|
-
//
|
|
300
|
+
// ALS-backed: reads segment params from the current request context.
|
|
301
|
+
// Server-only — throws on client.
|
|
302
|
+
//
|
|
303
|
+
// The pipeline already coerces params via coerceSegmentParams() which
|
|
304
|
+
// calls parse() and stores typed values in ALS via setSegmentParams().
|
|
305
|
+
// We return those directly instead of re-parsing, because codecs may
|
|
306
|
+
// not be idempotent (e.g., a codec that only accepts raw strings would
|
|
307
|
+
// throw if given an already-parsed value). See TIM-574.
|
|
302
308
|
async function load(): Promise<T> {
|
|
303
309
|
if (typeof window !== 'undefined') {
|
|
304
310
|
throw new Error(
|
|
305
311
|
'[timber] segmentParams.load() is server-only. ' + 'Use useSegmentParams() on the client.'
|
|
306
312
|
);
|
|
307
313
|
}
|
|
308
|
-
const
|
|
309
|
-
return
|
|
314
|
+
const params = await getRawSegmentParams();
|
|
315
|
+
// params are already coerced by the pipeline — return as-is.
|
|
316
|
+
return params as unknown as T;
|
|
310
317
|
}
|
|
311
318
|
|
|
312
319
|
const definition: ParamsDefinition<T> = {
|
|
@@ -233,6 +233,16 @@ export function timberDevBrowserLogs(ctx: PluginContext): Plugin {
|
|
|
233
233
|
const threshold = ctx.config.devBrowserLogs ?? 'warn';
|
|
234
234
|
if (threshold === 'none') return;
|
|
235
235
|
|
|
236
|
+
// Register the client injection script on globalThis so the RSC entry
|
|
237
|
+
// can include it in headHtml for all Timber route responses.
|
|
238
|
+
// transformIndexHtml only runs for Vite's index.html fallback, not
|
|
239
|
+
// for responses served by createTimberMiddleware (TIM-575).
|
|
240
|
+
const script = generateClientScript(threshold);
|
|
241
|
+
if (script) {
|
|
242
|
+
(globalThis as Record<string, unknown>).__timber_dev_browser_log_script =
|
|
243
|
+
`<script type="module">${script}</script>`;
|
|
244
|
+
}
|
|
245
|
+
|
|
236
246
|
// Listen for browser log messages via HMR WebSocket
|
|
237
247
|
server.hot.on(HMR_EVENT, (payload: BrowserLogPayload) => {
|
|
238
248
|
try {
|
package/src/plugins/entries.ts
CHANGED
|
@@ -34,6 +34,7 @@ const VIRTUAL_IDS = {
|
|
|
34
34
|
browserEntry: 'virtual:timber-browser-entry',
|
|
35
35
|
config: 'virtual:timber-config',
|
|
36
36
|
instrumentation: 'virtual:timber-instrumentation',
|
|
37
|
+
cacheHandler: 'virtual:timber-cache-handler',
|
|
37
38
|
} as const;
|
|
38
39
|
|
|
39
40
|
/**
|
|
@@ -57,6 +58,9 @@ const RESOLVED_CONFIG_ID = `\0${VIRTUAL_IDS.config}`;
|
|
|
57
58
|
/** The \0-prefixed resolved ID for virtual:timber-instrumentation */
|
|
58
59
|
const RESOLVED_INSTRUMENTATION_ID = `\0${VIRTUAL_IDS.instrumentation}`;
|
|
59
60
|
|
|
61
|
+
/** The \0-prefixed resolved ID for virtual:timber-cache-handler */
|
|
62
|
+
const RESOLVED_CACHE_HANDLER_ID = `\0${VIRTUAL_IDS.cacheHandler}`;
|
|
63
|
+
|
|
60
64
|
/**
|
|
61
65
|
* Strip the \0 prefix from a module ID.
|
|
62
66
|
*
|
|
@@ -175,6 +179,53 @@ export function generateInstrumentationModule(instrumentationPath: string | null
|
|
|
175
179
|
].join('\n');
|
|
176
180
|
}
|
|
177
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Detect the user's timber.config file at the project root.
|
|
184
|
+
* Returns the absolute path or null if not found.
|
|
185
|
+
*/
|
|
186
|
+
function detectConfigFile(root: string): string | null {
|
|
187
|
+
const names = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];
|
|
188
|
+
for (const name of names) {
|
|
189
|
+
const candidate = resolve(root, name);
|
|
190
|
+
if (existsSync(candidate)) return candidate;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Generate the virtual:timber-cache-handler module source.
|
|
197
|
+
*
|
|
198
|
+
* When the user's config has a cacheHandler, generates a module that
|
|
199
|
+
* dynamically imports the config file and extracts the cacheHandler.
|
|
200
|
+
* The cacheHandler is a class instance (e.g. RedisCacheHandler) that
|
|
201
|
+
* cannot be JSON-serialized into virtual:timber-config, so it must
|
|
202
|
+
* be loaded at runtime via dynamic import. See TIM-599.
|
|
203
|
+
*/
|
|
204
|
+
function generateCacheHandlerModule(configPath: string | null, hasCacheHandler: boolean): string {
|
|
205
|
+
if (configPath && hasCacheHandler) {
|
|
206
|
+
return [
|
|
207
|
+
'// Auto-generated cache handler loader — do not edit.',
|
|
208
|
+
'// Generated by timber-entries plugin.',
|
|
209
|
+
'',
|
|
210
|
+
`export default async function loadCacheHandler() {`,
|
|
211
|
+
` const mod = await import(${JSON.stringify(configPath)});`,
|
|
212
|
+
` const config = mod.default ?? mod;`,
|
|
213
|
+
` return config.cacheHandler ?? null;`,
|
|
214
|
+
`}`,
|
|
215
|
+
].join('\n');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return [
|
|
219
|
+
'// Auto-generated cache handler loader — do not edit.',
|
|
220
|
+
'// Generated by timber-entries plugin.',
|
|
221
|
+
'// No cacheHandler configured in timber.config.',
|
|
222
|
+
'',
|
|
223
|
+
`export default async function loadCacheHandler() {`,
|
|
224
|
+
` return null;`,
|
|
225
|
+
`}`,
|
|
226
|
+
].join('\n');
|
|
227
|
+
}
|
|
228
|
+
|
|
178
229
|
/**
|
|
179
230
|
* Create the timber-entries Vite plugin.
|
|
180
231
|
*
|
|
@@ -217,6 +268,11 @@ export function timberEntries(ctx: PluginContext): Plugin {
|
|
|
217
268
|
return RESOLVED_INSTRUMENTATION_ID;
|
|
218
269
|
}
|
|
219
270
|
|
|
271
|
+
// Check cache handler virtual module
|
|
272
|
+
if (cleanId === VIRTUAL_IDS.cacheHandler) {
|
|
273
|
+
return RESOLVED_CACHE_HANDLER_ID;
|
|
274
|
+
}
|
|
275
|
+
|
|
220
276
|
return null;
|
|
221
277
|
},
|
|
222
278
|
|
|
@@ -234,6 +290,11 @@ export function timberEntries(ctx: PluginContext): Plugin {
|
|
|
234
290
|
const instrumentationPath = detectInstrumentationFile(ctx.root);
|
|
235
291
|
return generateInstrumentationModule(instrumentationPath);
|
|
236
292
|
}
|
|
293
|
+
if (id === RESOLVED_CACHE_HANDLER_ID) {
|
|
294
|
+
const configPath = detectConfigFile(ctx.root);
|
|
295
|
+
const hasCacheHandler = ctx.config.cacheHandler != null;
|
|
296
|
+
return generateCacheHandlerModule(configPath, hasCacheHandler);
|
|
297
|
+
}
|
|
237
298
|
return null;
|
|
238
299
|
},
|
|
239
300
|
|
package/src/plugins/routing.ts
CHANGED
|
@@ -28,7 +28,7 @@ const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_MODULE_ID}`;
|
|
|
28
28
|
* File convention names we track for changes that require manifest regeneration.
|
|
29
29
|
*/
|
|
30
30
|
const ROUTE_FILE_PATTERNS =
|
|
31
|
-
/\/(page|layout|middleware|access|route|error|default|denied|params|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|twitter-image|apple-icon)\./;
|
|
31
|
+
/\/(page|layout|middleware|access|route|error|global-error|default|denied|params|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|twitter-image|apple-icon)\./;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Create the timber-routing Vite plugin.
|
package/src/routing/index.ts
CHANGED
|
@@ -12,3 +12,5 @@ export type {
|
|
|
12
12
|
export { DEFAULT_PAGE_EXTENSIONS, INTERCEPTION_MARKERS } from './types.js';
|
|
13
13
|
export { collectInterceptionRewrites } from './interception.js';
|
|
14
14
|
export type { InterceptionRewrite } from './interception.js';
|
|
15
|
+
export { classifyUrlSegment } from './segment-classify.js';
|
|
16
|
+
export type { UrlSegment } from './segment-classify.js';
|
package/src/routing/scanner.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
ScannerConfig,
|
|
19
19
|
InterceptionMarker,
|
|
20
20
|
} from './types.js';
|
|
21
|
+
import { classifyUrlSegment } from './segment-classify.js';
|
|
21
22
|
import { DEFAULT_PAGE_EXTENSIONS, INTERCEPTION_MARKERS } from './types.js';
|
|
22
23
|
import { classifyMetadataRoute, isDynamicMetadataExtension } from '../server/metadata-routes.js';
|
|
23
24
|
|
|
@@ -165,22 +166,12 @@ export function classifySegment(dirName: string): {
|
|
|
165
166
|
return { type: 'group' };
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// Catch-all: [...name]
|
|
175
|
-
if (dirName.startsWith('[...') && dirName.endsWith(']')) {
|
|
176
|
-
const paramName = dirName.slice(4, -1);
|
|
177
|
-
return { type: 'catch-all', paramName };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Dynamic: [name]
|
|
181
|
-
if (dirName.startsWith('[') && dirName.endsWith(']')) {
|
|
182
|
-
const paramName = dirName.slice(1, -1);
|
|
183
|
-
return { type: 'dynamic', paramName };
|
|
169
|
+
// Bracket-syntax segments: [param], [...param], [[...param]]
|
|
170
|
+
// Delegated to the shared character-based classifier. If you change
|
|
171
|
+
// bracket syntax, update segment-classify.ts — not here.
|
|
172
|
+
const urlSeg = classifyUrlSegment(dirName);
|
|
173
|
+
if (urlSeg.kind !== 'static') {
|
|
174
|
+
return { type: urlSeg.kind, paramName: urlSeg.name };
|
|
184
175
|
}
|
|
185
176
|
|
|
186
177
|
return { type: 'static' };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared URL segment classifier.
|
|
3
|
+
*
|
|
4
|
+
* Single-pass character parser that classifies a route segment token
|
|
5
|
+
* (e.g. "dashboard", "[id]", "[...slug]", "[[...path]]") into a typed
|
|
6
|
+
* discriminated union. Used by both server-side routing and client-side
|
|
7
|
+
* Link interpolation.
|
|
8
|
+
*
|
|
9
|
+
* NO regex. NO Node.js-only APIs. Safe to import from browser code.
|
|
10
|
+
*
|
|
11
|
+
* Malformed input (unclosed brackets, empty names, etc.) falls through
|
|
12
|
+
* to { kind: 'static' } — the safe default.
|
|
13
|
+
*
|
|
14
|
+
* If you change the bracket syntax, update ONLY this file. Every
|
|
15
|
+
* consumer imports from here.
|
|
16
|
+
*
|
|
17
|
+
* See design/07-routing.md §"Route Segments"
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type UrlSegment =
|
|
21
|
+
| { kind: 'static'; value: string }
|
|
22
|
+
| { kind: 'dynamic'; name: string }
|
|
23
|
+
| { kind: 'catch-all'; name: string }
|
|
24
|
+
| { kind: 'optional-catch-all'; name: string };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Classify a URL path segment token.
|
|
28
|
+
*
|
|
29
|
+
* Walks the string left-to-right in one pass:
|
|
30
|
+
* 1. If it doesn't start with '[', it's static.
|
|
31
|
+
* 2. Count opening brackets (1 or 2) to detect optional.
|
|
32
|
+
* 3. Check for '...' to detect catch-all.
|
|
33
|
+
* 4. Read the param name up to the closing bracket.
|
|
34
|
+
* 5. Validate the expected closing sequence (']' or ']]').
|
|
35
|
+
* 6. Reject if there are leftover characters after the close.
|
|
36
|
+
*
|
|
37
|
+
* Any structural violation → static (safe default).
|
|
38
|
+
*/
|
|
39
|
+
export function classifyUrlSegment(token: string): UrlSegment {
|
|
40
|
+
const len = token.length;
|
|
41
|
+
|
|
42
|
+
// Must start with '[' to be dynamic
|
|
43
|
+
if (len === 0 || token[0] !== '[') {
|
|
44
|
+
return { kind: 'static', value: token };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let i = 1;
|
|
48
|
+
|
|
49
|
+
// Check for optional: '[[...'
|
|
50
|
+
const optional = token[i] === '[';
|
|
51
|
+
if (optional) i++;
|
|
52
|
+
|
|
53
|
+
// Check for catch-all: '...'
|
|
54
|
+
const catchAll = i + 2 < len && token[i] === '.' && token[i + 1] === '.' && token[i + 2] === '.';
|
|
55
|
+
if (catchAll) i += 3;
|
|
56
|
+
|
|
57
|
+
// Read param name — everything up to ']'
|
|
58
|
+
const nameStart = i;
|
|
59
|
+
while (i < len && token[i] !== ']') i++;
|
|
60
|
+
|
|
61
|
+
// Must have found a ']' and name must be non-empty
|
|
62
|
+
if (i >= len || i === nameStart) {
|
|
63
|
+
return { kind: 'static', value: token };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const name = token.slice(nameStart, i);
|
|
67
|
+
i++; // skip first ']'
|
|
68
|
+
|
|
69
|
+
// Optional requires a second ']'
|
|
70
|
+
if (optional) {
|
|
71
|
+
if (i >= len || token[i] !== ']') {
|
|
72
|
+
return { kind: 'static', value: token };
|
|
73
|
+
}
|
|
74
|
+
i++;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Must be at end of string — no trailing characters
|
|
78
|
+
if (i !== len) {
|
|
79
|
+
return { kind: 'static', value: token };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (optional && catchAll) return { kind: 'optional-catch-all', name };
|
|
83
|
+
if (catchAll) return { kind: 'catch-all', name };
|
|
84
|
+
if (optional) {
|
|
85
|
+
// '[[name]]' without '...' is malformed — not a valid segment syntax
|
|
86
|
+
return { kind: 'static', value: token };
|
|
87
|
+
}
|
|
88
|
+
return { kind: 'dynamic', name };
|
|
89
|
+
}
|
package/src/server/actions.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See design/08-forms-and-actions.md
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import
|
|
17
|
+
import { getCacheHandler } from '../cache/handler-store';
|
|
18
18
|
import { RedirectSignal } from './primitives';
|
|
19
19
|
import { withSpan } from './tracing';
|
|
20
20
|
import { revalidationAls, type RevalidationState } from './als-registry.js';
|
|
@@ -37,8 +37,6 @@ export type { RevalidationState } from './als-registry.js';
|
|
|
37
37
|
|
|
38
38
|
/** Options for creating the action handler. */
|
|
39
39
|
export interface ActionHandlerConfig {
|
|
40
|
-
/** Cache handler for tag invalidation. */
|
|
41
|
-
cacheHandler?: CacheHandler;
|
|
42
40
|
/** Renderer for producing RSC payloads during revalidation. */
|
|
43
41
|
renderer?: RevalidateRenderer;
|
|
44
42
|
}
|
|
@@ -172,9 +170,12 @@ export async function executeAction(
|
|
|
172
170
|
}
|
|
173
171
|
});
|
|
174
172
|
|
|
175
|
-
// Process tag invalidation
|
|
176
|
-
|
|
177
|
-
|
|
173
|
+
// Process tag invalidation via the module-level cache handler singleton.
|
|
174
|
+
// setCacheHandler() is called at boot from rsc-entry when timber.config.ts
|
|
175
|
+
// provides a cacheHandler; otherwise falls back to in-memory LRU (TIM-599).
|
|
176
|
+
if (state.tags.length > 0) {
|
|
177
|
+
const handler = getCacheHandler();
|
|
178
|
+
await Promise.all(state.tags.map((tag) => handler.invalidate({ tag })));
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
// Process path revalidation — build element tree (not yet serialized)
|
|
@@ -20,6 +20,7 @@ import { renderToReadableStream } from '../rsc-runtime/rsc.js';
|
|
|
20
20
|
|
|
21
21
|
import { DenySignal } from './primitives.js';
|
|
22
22
|
import { logRenderError } from './logger.js';
|
|
23
|
+
import { loadModule } from './safe-load.js';
|
|
23
24
|
import { isDebug } from './debug.js';
|
|
24
25
|
import { resolveMetadata, renderMetadataToElements } from './metadata.js';
|
|
25
26
|
import { resolveManifestStatusFile } from './manifest-status-resolver.js';
|
|
@@ -110,7 +111,7 @@ export async function renderDenyPage(
|
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
// Load the status-code page component
|
|
113
|
-
const mod =
|
|
114
|
+
const mod = await loadModule(resolution.file);
|
|
114
115
|
if (!mod.default) {
|
|
115
116
|
return new Response(null, { status: deny.status, headers: responseHeaders });
|
|
116
117
|
}
|
|
@@ -208,7 +209,7 @@ export async function renderDenyPageAsRsc(
|
|
|
208
209
|
return new Response(null, { status: deny.status, headers: responseHeaders });
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
const mod =
|
|
212
|
+
const mod = await loadModule(resolution.file);
|
|
212
213
|
if (!mod.default) {
|
|
213
214
|
responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
|
|
214
215
|
return new Response(null, { status: deny.status, headers: responseHeaders });
|
|
@@ -274,7 +275,7 @@ async function renderDenyPageJson(
|
|
|
274
275
|
// JSON status files are loaded as modules that export the JSON content.
|
|
275
276
|
// The manifest's load() imports the .json file, which Vite handles as a
|
|
276
277
|
// default export of the parsed JSON object.
|
|
277
|
-
const mod =
|
|
278
|
+
const mod = await loadModule(resolution.file);
|
|
278
279
|
const jsonContent = mod.default ?? mod;
|
|
279
280
|
|
|
280
281
|
responseHeaders.set('content-type', 'application/json; charset=utf-8');
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { TimberErrorBoundary } from '../client/error-boundary.js';
|
|
9
9
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
10
|
+
import { loadModule } from './safe-load.js';
|
|
10
11
|
|
|
11
12
|
/** MDX/markdown extensions — server components that cannot be passed as function props. */
|
|
12
13
|
const MDX_EXTENSIONS = new Set(['.mdx', '.md']);
|
|
@@ -45,8 +46,10 @@ export async function wrapSegmentWithErrorBoundaries(
|
|
|
45
46
|
if (key !== '4xx' && key !== '5xx') {
|
|
46
47
|
const status = parseInt(key, 10);
|
|
47
48
|
if (!isNaN(status)) {
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
// .catch: error boundary construction must not fail if the
|
|
50
|
+
// error page module has a syntax error — skip this boundary.
|
|
51
|
+
const mod = await loadModule(file).catch(() => null);
|
|
52
|
+
if (mod?.default) {
|
|
50
53
|
if (isMdxFilePath(file.filePath)) {
|
|
51
54
|
// MDX: pre-render as element (server component can't be a function prop)
|
|
52
55
|
element = h(TimberErrorBoundary, {
|
|
@@ -69,8 +72,8 @@ export async function wrapSegmentWithErrorBoundaries(
|
|
|
69
72
|
// Category catch-alls (4xx.tsx, 5xx.tsx)
|
|
70
73
|
for (const [key, file] of Object.entries(segment.statusFiles)) {
|
|
71
74
|
if (key === '4xx' || key === '5xx') {
|
|
72
|
-
const mod =
|
|
73
|
-
if (mod
|
|
75
|
+
const mod = await loadModule(file).catch(() => null);
|
|
76
|
+
if (mod?.default) {
|
|
74
77
|
const categoryStatus = key === '4xx' ? 400 : 500;
|
|
75
78
|
if (isMdxFilePath(file.filePath)) {
|
|
76
79
|
element = h(TimberErrorBoundary, {
|
|
@@ -92,8 +95,8 @@ export async function wrapSegmentWithErrorBoundaries(
|
|
|
92
95
|
|
|
93
96
|
// error.tsx (outermost — catches anything not matched by status files)
|
|
94
97
|
if (segment.error) {
|
|
95
|
-
const mod =
|
|
96
|
-
if (mod
|
|
98
|
+
const mod = await loadModule(segment.error).catch(() => null);
|
|
99
|
+
if (mod?.default) {
|
|
97
100
|
if (isMdxFilePath(segment.error.filePath)) {
|
|
98
101
|
element = h(TimberErrorBoundary, {
|
|
99
102
|
fallbackElement: h(mod.default as never, {}),
|
|
@@ -15,6 +15,8 @@ import type { ManifestSegmentNode } from './route-matcher.js';
|
|
|
15
15
|
import type { ClientBootstrapConfig } from './html-injectors.js';
|
|
16
16
|
import type { LayoutEntry } from './deny-renderer.js';
|
|
17
17
|
import type { GlobalErrorFile } from './rsc-entry/error-renderer.js';
|
|
18
|
+
import { logRenderError } from './logger.js';
|
|
19
|
+
import { loadModule } from './safe-load.js';
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Render a fallback error page when the render pipeline throws.
|
|
@@ -38,14 +40,25 @@ export async function renderFallbackError(
|
|
|
38
40
|
const { renderErrorPage } = await import('./rsc-entry/error-renderer.js');
|
|
39
41
|
const segments = [rootSegment];
|
|
40
42
|
const layoutComponents: LayoutEntry[] = [];
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
// Wrap layout loading in try/catch — if the root layout module itself
|
|
44
|
+
// crashes (evaluation failure, syntax error, etc.), we still want to
|
|
45
|
+
// reach renderErrorPage so it can fall through to global-error.tsx
|
|
46
|
+
// (Tier 2), which renders without any layout wrapping.
|
|
47
|
+
try {
|
|
48
|
+
if (rootSegment.layout) {
|
|
49
|
+
const mod = await loadModule(rootSegment.layout);
|
|
50
|
+
if (mod.default) {
|
|
51
|
+
layoutComponents.push({
|
|
52
|
+
component: mod.default as (...args: unknown[]) => unknown,
|
|
53
|
+
segment: rootSegment,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
48
56
|
}
|
|
57
|
+
} catch (layoutError) {
|
|
58
|
+
// Layout failed to load — proceed without it. renderErrorPage will
|
|
59
|
+
// attempt segment-level error pages (without layout wrapping) and
|
|
60
|
+
// then fall through to global-error.tsx if those also fail.
|
|
61
|
+
logRenderError({ method: req.method, path: new URL(req.url).pathname, error: layoutError });
|
|
49
62
|
}
|
|
50
63
|
const match: RouteMatch = { segments: segments as never, params: {} };
|
|
51
64
|
return renderErrorPage(
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* See design/07-routing.md §"Intercepting Routes"
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { classifyUrlSegment } from '../routing/segment-classify.js';
|
|
13
|
+
|
|
12
14
|
/** Result of a successful interception match. */
|
|
13
15
|
export interface InterceptionMatchResult {
|
|
14
16
|
/** The pathname to re-match (the source/intercepting route's parent). */
|
|
@@ -53,23 +55,22 @@ export function pathnameMatchesPattern(pathname: string, pattern: string): boole
|
|
|
53
55
|
|
|
54
56
|
let pi = 0;
|
|
55
57
|
for (let i = 0; i < patternParts.length; i++) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
// Catch-all: [...param] or [[...param]] — matches rest of URL
|
|
59
|
-
if (segment.startsWith('[...') || segment.startsWith('[[...')) {
|
|
60
|
-
return pi < pathParts.length || segment.startsWith('[[...');
|
|
61
|
-
}
|
|
58
|
+
const seg = classifyUrlSegment(patternParts[i]);
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
switch (seg.kind) {
|
|
61
|
+
case 'catch-all':
|
|
62
|
+
return pi < pathParts.length;
|
|
63
|
+
case 'optional-catch-all':
|
|
64
|
+
return true;
|
|
65
|
+
case 'dynamic':
|
|
66
|
+
if (pi >= pathParts.length) return false;
|
|
67
|
+
pi++;
|
|
68
|
+
continue;
|
|
69
|
+
case 'static':
|
|
70
|
+
if (pi >= pathParts.length || pathParts[pi] !== seg.value) return false;
|
|
71
|
+
pi++;
|
|
72
|
+
continue;
|
|
68
73
|
}
|
|
69
|
-
|
|
70
|
-
// Static — must match exactly
|
|
71
|
-
if (pi >= pathParts.length || pathParts[pi] !== segment) return false;
|
|
72
|
-
pi++;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
return pi === pathParts.length;
|
package/src/server/pipeline.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { RedirectSignal, DenySignal } from './primitives.js';
|
|
|
46
46
|
import { ParamCoercionError } from './route-element-builder.js';
|
|
47
47
|
import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
|
|
48
48
|
import { serveStaticMetadataFile, serializeSitemap } from './pipeline-metadata.js';
|
|
49
|
+
import { loadModule } from './safe-load.js';
|
|
49
50
|
import { findInterceptionMatch } from './pipeline-interception.js';
|
|
50
51
|
import type { MiddlewareContext } from './types.js';
|
|
51
52
|
import type { SegmentNode } from '../routing/types.js';
|
|
@@ -174,7 +175,15 @@ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
|
|
|
174
175
|
// Only process segments that have a params.ts convention file
|
|
175
176
|
if (!segment.params) continue;
|
|
176
177
|
|
|
177
|
-
|
|
178
|
+
let mod: Record<string, unknown>;
|
|
179
|
+
try {
|
|
180
|
+
mod = await loadModule(segment.params);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
throw new ParamCoercionError(
|
|
183
|
+
`Failed to load params module for segment "${segment.segmentName}": ${err instanceof Error ? err.message : String(err)}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
178
187
|
const segmentParamsDef = mod.segmentParams as
|
|
179
188
|
| { parse(raw: Record<string, string | string[]>): Record<string, unknown> }
|
|
180
189
|
| undefined;
|
|
@@ -366,7 +375,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
366
375
|
return await serveStaticMetadataFile(metaMatch);
|
|
367
376
|
}
|
|
368
377
|
|
|
369
|
-
const mod =
|
|
378
|
+
const mod = await loadModule<{ default?: Function }>(metaMatch.file);
|
|
370
379
|
if (typeof mod.default !== 'function') {
|
|
371
380
|
return new Response('Metadata route must export a default function', { status: 500 });
|
|
372
381
|
}
|
|
@@ -480,6 +489,15 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
480
489
|
await coerceSegmentParams(match);
|
|
481
490
|
} catch (error) {
|
|
482
491
|
if (error instanceof ParamCoercionError) {
|
|
492
|
+
// For API routes (route.ts), return a bare 404 — not an HTML page.
|
|
493
|
+
// API consumers expect JSON/empty responses, not rendered HTML.
|
|
494
|
+
const leafSegment = match.segments[match.segments.length - 1];
|
|
495
|
+
if (
|
|
496
|
+
(leafSegment as { route?: unknown }).route &&
|
|
497
|
+
!(leafSegment as { page?: unknown }).page
|
|
498
|
+
) {
|
|
499
|
+
return new Response(null, { status: 404 });
|
|
500
|
+
}
|
|
483
501
|
// Route through the app's 404 page (404.tsx in root layout) instead of
|
|
484
502
|
// returning a bare empty 404 Response. Falls back to bare 404 only if
|
|
485
503
|
// no renderNoMatch renderer is configured.
|
|
@@ -32,6 +32,7 @@ import { SegmentProvider } from '../client/segment-context.js';
|
|
|
32
32
|
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
33
33
|
import type { InterceptionContext } from './pipeline.js';
|
|
34
34
|
import { shouldSkipSegment } from './state-tree-diff.js';
|
|
35
|
+
import { loadModule } from './safe-load.js';
|
|
35
36
|
|
|
36
37
|
// ─── Param Coercion Error ─────────────────────────────────────────────────
|
|
37
38
|
|
|
@@ -186,7 +187,7 @@ export async function buildRouteElement(
|
|
|
186
187
|
|
|
187
188
|
// Load layout
|
|
188
189
|
if (segment.layout) {
|
|
189
|
-
const mod =
|
|
190
|
+
const mod = await loadModule(segment.layout);
|
|
190
191
|
if (mod.default) {
|
|
191
192
|
layoutComponents.push({
|
|
192
193
|
component: mod.default as (...args: unknown[]) => unknown,
|
|
@@ -207,7 +208,7 @@ export async function buildRouteElement(
|
|
|
207
208
|
|
|
208
209
|
// Load page (leaf segment only)
|
|
209
210
|
if (isLeaf && segment.page) {
|
|
210
|
-
const mod =
|
|
211
|
+
const mod = await loadModule(segment.page);
|
|
211
212
|
|
|
212
213
|
// Param coercion is handled in the pipeline (Stage 2c) before
|
|
213
214
|
// middleware and rendering. See coerceSegmentParams() in pipeline.ts.
|
|
@@ -239,7 +240,7 @@ export async function buildRouteElement(
|
|
|
239
240
|
for (let si = 0; si < segments.length; si++) {
|
|
240
241
|
const segment = segments[si];
|
|
241
242
|
if (segment.access) {
|
|
242
|
-
const accessMod =
|
|
243
|
+
const accessMod = await loadModule(segment.access);
|
|
243
244
|
const accessFn = accessMod.default as
|
|
244
245
|
| ((ctx: { params: Record<string, string | string[]> }) => unknown)
|
|
245
246
|
| undefined;
|
|
@@ -389,7 +390,7 @@ export async function buildRouteElement(
|
|
|
389
390
|
// Pass the pre-computed verdict so AccessGate replays it synchronously
|
|
390
391
|
// instead of re-calling accessFn (dedup + Suspense immunity).
|
|
391
392
|
if (segment.access) {
|
|
392
|
-
const accessMod =
|
|
393
|
+
const accessMod = await loadModule(segment.access);
|
|
393
394
|
const accessFn = accessMod.default as
|
|
394
395
|
| ((ctx: { params: Record<string, string | string[]> }) => unknown)
|
|
395
396
|
| undefined;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { withSpan, setSpanAttribute } from '../tracing.js';
|
|
10
10
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
11
|
+
import { loadModule } from '../safe-load.js';
|
|
11
12
|
import type { RouteMatch } from '../pipeline.js';
|
|
12
13
|
import { DenySignal, RedirectSignal } from '../primitives.js';
|
|
13
14
|
import { handleRouteRequest } from '../route-handler.js';
|
|
@@ -26,7 +27,7 @@ export async function handleApiRoute(
|
|
|
26
27
|
// Each access.ts is independent — deny()/redirect() throws a signal.
|
|
27
28
|
for (const segment of segments) {
|
|
28
29
|
if (segment.access) {
|
|
29
|
-
const accessMod =
|
|
30
|
+
const accessMod = await loadModule(segment.access);
|
|
30
31
|
const accessFn = accessMod.default as
|
|
31
32
|
| ((ctx: { params: Record<string, string | string[]>; searchParams: unknown }) => unknown)
|
|
32
33
|
| undefined;
|
|
@@ -68,7 +69,7 @@ export async function handleApiRoute(
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
// Load route.ts module and dispatch
|
|
71
|
-
const routeMod =
|
|
72
|
+
const routeMod = await loadModule<RouteModule>(leaf.route!);
|
|
72
73
|
const ctx: RouteContext = {
|
|
73
74
|
req,
|
|
74
75
|
params: match.params,
|
|
@@ -94,7 +95,7 @@ async function renderApiDeny(
|
|
|
94
95
|
|
|
95
96
|
const resolution = resolveManifestStatusFile(deny.status, segments, 'json');
|
|
96
97
|
if (resolution) {
|
|
97
|
-
const mod =
|
|
98
|
+
const mod = await loadModule(resolution.file);
|
|
98
99
|
const jsonContent = mod.default ?? mod;
|
|
99
100
|
responseHeaders.set('content-type', 'application/json; charset=utf-8');
|
|
100
101
|
return new Response(JSON.stringify(jsonContent), {
|