@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.
Files changed (117) hide show
  1. package/dist/_chunks/{als-registry-Ba7URUIn.js → als-registry-BJARkOcu.js} +1 -1
  2. package/dist/_chunks/{als-registry-Ba7URUIn.js.map → als-registry-BJARkOcu.js.map} +1 -1
  3. package/dist/_chunks/{define-D5STJpIr.js → define-Djpqoe1K.js} +2 -2
  4. package/dist/_chunks/{define-D5STJpIr.js.map → define-Djpqoe1K.js.map} +1 -1
  5. package/dist/_chunks/{define-cookie-DtAavax4.js → define-cookie-B2djY9w0.js} +4 -4
  6. package/dist/_chunks/{define-cookie-DtAavax4.js.map → define-cookie-B2djY9w0.js.map} +1 -1
  7. package/dist/_chunks/{define-TK8C1M3x.js → define-hdajFTq7.js} +2 -2
  8. package/dist/_chunks/{define-TK8C1M3x.js.map → define-hdajFTq7.js.map} +1 -1
  9. package/dist/_chunks/{error-boundary-DpZJBCqh.js → error-boundary-A_sgyyUP.js} +1 -1
  10. package/dist/_chunks/{error-boundary-DpZJBCqh.js.map → error-boundary-A_sgyyUP.js.map} +1 -1
  11. package/dist/_chunks/{tracing-VYETCQsg.js → handler-store-CaE0ZgVG.js} +54 -3
  12. package/dist/_chunks/handler-store-CaE0ZgVG.js.map +1 -0
  13. package/dist/_chunks/{interception-Cey5DCGr.js → interception-BVm64Jr5.js} +7 -13
  14. package/dist/_chunks/interception-BVm64Jr5.js.map +1 -0
  15. package/dist/_chunks/{metadata-routes-BU684ls2.js → metadata-routes-DS3eKNmf.js} +1 -1
  16. package/dist/_chunks/{metadata-routes-BU684ls2.js.map → metadata-routes-DS3eKNmf.js.map} +1 -1
  17. package/dist/_chunks/{request-context-0wfZsnhh.js → request-context-B_u9dyhZ.js} +4 -4
  18. package/dist/_chunks/{request-context-0wfZsnhh.js.map → request-context-B_u9dyhZ.js.map} +1 -1
  19. package/dist/_chunks/segment-classify-BDNn6EzD.js +65 -0
  20. package/dist/_chunks/segment-classify-BDNn6EzD.js.map +1 -0
  21. package/dist/_chunks/{segment-context-CyaM1mrD.js → segment-context-CVRHlkkQ.js} +1 -1
  22. package/dist/_chunks/{segment-context-CyaM1mrD.js.map → segment-context-CVRHlkkQ.js.map} +1 -1
  23. package/dist/_chunks/{stale-reload-DKN3aXxR.js → stale-reload-BeyHXZ5B.js} +5 -2
  24. package/dist/_chunks/{stale-reload-DKN3aXxR.js.map → stale-reload-BeyHXZ5B.js.map} +1 -1
  25. package/dist/_chunks/{use-query-states-wEXY2JQB.js → use-query-states-DAhgj8Gx.js} +1 -1
  26. package/dist/_chunks/{use-query-states-wEXY2JQB.js.map → use-query-states-DAhgj8Gx.js.map} +1 -1
  27. package/dist/_chunks/{wrappers-BaG1bnM3.js → wrappers-CJQ3KwVr.js} +1 -1
  28. package/dist/_chunks/{wrappers-BaG1bnM3.js.map → wrappers-CJQ3KwVr.js.map} +1 -1
  29. package/dist/cache/cache-api.d.ts +24 -0
  30. package/dist/cache/cache-api.d.ts.map +1 -0
  31. package/dist/cache/handler-store.d.ts +31 -0
  32. package/dist/cache/handler-store.d.ts.map +1 -0
  33. package/dist/cache/index.d.ts +2 -1
  34. package/dist/cache/index.d.ts.map +1 -1
  35. package/dist/cache/index.js +33 -2
  36. package/dist/cache/index.js.map +1 -1
  37. package/dist/client/error-boundary.js +1 -1
  38. package/dist/client/index.d.ts +1 -1
  39. package/dist/client/index.d.ts.map +1 -1
  40. package/dist/client/index.js +50 -21
  41. package/dist/client/index.js.map +1 -1
  42. package/dist/client/link.d.ts +6 -0
  43. package/dist/client/link.d.ts.map +1 -1
  44. package/dist/client/rsc-fetch.d.ts.map +1 -1
  45. package/dist/client/stale-reload.d.ts.map +1 -1
  46. package/dist/client/transition-root.d.ts +12 -0
  47. package/dist/client/transition-root.d.ts.map +1 -1
  48. package/dist/cookies/index.js +1 -1
  49. package/dist/index.js +55 -3
  50. package/dist/index.js.map +1 -1
  51. package/dist/params/define.d.ts.map +1 -1
  52. package/dist/params/index.js +3 -3
  53. package/dist/plugins/dev-browser-logs.d.ts.map +1 -1
  54. package/dist/plugins/entries.d.ts.map +1 -1
  55. package/dist/routing/index.d.ts +2 -0
  56. package/dist/routing/index.d.ts.map +1 -1
  57. package/dist/routing/index.js +3 -2
  58. package/dist/routing/scanner.d.ts.map +1 -1
  59. package/dist/routing/segment-classify.d.ts +46 -0
  60. package/dist/routing/segment-classify.d.ts.map +1 -0
  61. package/dist/search-params/index.js +3 -3
  62. package/dist/server/actions.d.ts +0 -3
  63. package/dist/server/actions.d.ts.map +1 -1
  64. package/dist/server/deny-renderer.d.ts.map +1 -1
  65. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  66. package/dist/server/fallback-error.d.ts.map +1 -1
  67. package/dist/server/index.js +100 -17
  68. package/dist/server/index.js.map +1 -1
  69. package/dist/server/pipeline-interception.d.ts.map +1 -1
  70. package/dist/server/pipeline.d.ts.map +1 -1
  71. package/dist/server/route-element-builder.d.ts.map +1 -1
  72. package/dist/server/rsc-entry/api-handler.d.ts.map +1 -1
  73. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  74. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  75. package/dist/server/rsc-entry/ssr-bridge.d.ts.map +1 -1
  76. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  77. package/dist/server/safe-load.d.ts +46 -0
  78. package/dist/server/safe-load.d.ts.map +1 -0
  79. package/dist/server/slot-resolver.d.ts.map +1 -1
  80. package/dist/server/stream-utils.d.ts.map +1 -1
  81. package/dist/server/tracing.d.ts.map +1 -1
  82. package/package.json +1 -1
  83. package/src/cache/cache-api.ts +38 -0
  84. package/src/cache/handler-store.ts +68 -0
  85. package/src/cache/index.ts +2 -1
  86. package/src/client/browser-entry.ts +90 -31
  87. package/src/client/index.ts +1 -1
  88. package/src/client/link.tsx +81 -46
  89. package/src/client/rsc-fetch.ts +3 -1
  90. package/src/client/stale-reload.ts +19 -2
  91. package/src/client/transition-root.tsx +27 -0
  92. package/src/params/define.ts +11 -4
  93. package/src/plugins/dev-browser-logs.ts +10 -0
  94. package/src/plugins/entries.ts +61 -0
  95. package/src/plugins/routing.ts +1 -1
  96. package/src/routing/index.ts +2 -0
  97. package/src/routing/scanner.ts +7 -16
  98. package/src/routing/segment-classify.ts +89 -0
  99. package/src/server/actions.ts +7 -6
  100. package/src/server/deny-renderer.ts +4 -3
  101. package/src/server/error-boundary-wrapper.ts +9 -6
  102. package/src/server/fallback-error.ts +20 -7
  103. package/src/server/pipeline-interception.ts +16 -15
  104. package/src/server/pipeline.ts +20 -2
  105. package/src/server/route-element-builder.ts +5 -4
  106. package/src/server/rsc-entry/api-handler.ts +4 -3
  107. package/src/server/rsc-entry/error-renderer.ts +25 -10
  108. package/src/server/rsc-entry/index.ts +24 -0
  109. package/src/server/rsc-entry/rsc-payload.ts +1 -1
  110. package/src/server/rsc-entry/ssr-bridge.ts +13 -4
  111. package/src/server/rsc-entry/ssr-renderer.ts +12 -1
  112. package/src/server/safe-load.ts +60 -0
  113. package/src/server/slot-resolver.ts +3 -1
  114. package/src/server/stream-utils.ts +10 -6
  115. package/src/server/tracing.ts +14 -3
  116. package/dist/_chunks/interception-Cey5DCGr.js.map +0 -1
  117. 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
+ }
@@ -297,16 +297,23 @@ export function defineSegmentParams<C extends Record<string, ParamField>>(
297
297
  }
298
298
 
299
299
  // ---- load ----
300
- // ALS-backed: reads rawSegmentParams() from the current request context
301
- // and parses through codecs. Server-only — throws on client.
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 raw = await getRawSegmentParams();
309
- return parse(raw);
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 {
@@ -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
 
@@ -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.
@@ -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';
@@ -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
- // Optional catch-all: [[...name]]
169
- if (dirName.startsWith('[[...') && dirName.endsWith(']]')) {
170
- const paramName = dirName.slice(5, -2);
171
- return { type: 'optional-catch-all', paramName };
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
+ }
@@ -14,7 +14,7 @@
14
14
  * See design/08-forms-and-actions.md
15
15
  */
16
16
 
17
- import type { CacheHandler } from '../cache/index';
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
- if (state.tags.length > 0 && config.cacheHandler) {
177
- await Promise.all(state.tags.map((tag) => config.cacheHandler!.invalidate({ tag })));
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 = (await resolution.file.load()) as Record<string, unknown>;
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 = (await resolution.file.load()) as Record<string, unknown>;
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 = (await resolution.file.load()) as Record<string, unknown>;
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
- const mod = (await file.load()) as Record<string, unknown>;
49
- if (mod.default) {
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 = (await file.load()) as Record<string, unknown>;
73
- if (mod.default) {
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 = (await segment.error.load()) as Record<string, unknown>;
96
- if (mod.default) {
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 (rootSegment.layout) {
42
- const mod = (await rootSegment.layout.load()) as Record<string, unknown>;
43
- if (mod.default) {
44
- layoutComponents.push({
45
- component: mod.default as (...args: unknown[]) => unknown,
46
- segment: rootSegment,
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 segment = patternParts[i];
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
- // Dynamic: [param] — matches any single segment
64
- if (segment.startsWith('[') && segment.endsWith(']')) {
65
- if (pi >= pathParts.length) return false;
66
- pi++;
67
- continue;
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;
@@ -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
- const mod = (await segment.params.load()) as Record<string, unknown>;
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 = (await metaMatch.file.load()) as { default?: Function };
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 = (await segment.layout.load()) as Record<string, unknown>;
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 = (await segment.page.load()) as Record<string, unknown>;
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 = (await segment.access.load()) as Record<string, unknown>;
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 = (await segment.access.load()) as Record<string, unknown>;
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 = (await segment.access.load()) as Record<string, unknown>;
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 = (await leaf.route!.load()) as RouteModule;
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 = (await resolution.file.load()) as Record<string, unknown>;
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), {