@timber-js/app 0.2.0-alpha.183 → 0.2.0-alpha.185

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 (74) hide show
  1. package/dist/_chunks/{build-output-helper-BC-Zg0_w.js → build-output-helper-DOGYFb_X.js} +11 -3
  2. package/dist/_chunks/build-output-helper-DOGYFb_X.js.map +1 -0
  3. package/dist/_chunks/{cli-schema-sync-D3naS3eH.js → cli-schema-sync-BTWEKJXo.js} +1 -21
  4. package/dist/_chunks/cli-schema-sync-BTWEKJXo.js.map +1 -0
  5. package/dist/_chunks/{cloudflare-nlD9KhDj.js → cloudflare-CnT5Lr7U.js} +2 -2
  6. package/dist/_chunks/{cloudflare-nlD9KhDj.js.map → cloudflare-CnT5Lr7U.js.map} +1 -1
  7. package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -1
  8. package/dist/_chunks/plugin-context---kTF5v8.js.map +1 -1
  9. package/dist/_chunks/segment-classify-Byy425ng.js.map +1 -1
  10. package/dist/_chunks/{walkers-BtTlKviE.js → walkers-_6zKFlch.js} +2 -2
  11. package/dist/_chunks/{walkers-BtTlKviE.js.map → walkers-_6zKFlch.js.map} +1 -1
  12. package/dist/adapters/build-output-helper.d.ts.map +1 -1
  13. package/dist/adapters/cloudflare-dev.js +1 -1
  14. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  15. package/dist/adapters/cloudflare.js +1 -1
  16. package/dist/adapters/nitro.js +1 -1
  17. package/dist/adapters/shared.d.ts.map +1 -1
  18. package/dist/cli.js +1 -1
  19. package/dist/client/internal.js +70 -22
  20. package/dist/client/internal.js.map +1 -1
  21. package/dist/client/router.d.ts.map +1 -1
  22. package/dist/client/rsc-fetch.d.ts +17 -0
  23. package/dist/client/rsc-fetch.d.ts.map +1 -1
  24. package/dist/index.js +128 -9
  25. package/dist/index.js.map +1 -1
  26. package/dist/plugin-context.d.ts +7 -0
  27. package/dist/plugin-context.d.ts.map +1 -1
  28. package/dist/plugins/static-build.d.ts +42 -0
  29. package/dist/plugins/static-build.d.ts.map +1 -1
  30. package/dist/routing/index.js +2 -2
  31. package/dist/routing/manifest-codegen.d.ts.map +1 -1
  32. package/dist/routing/scanner.d.ts.map +1 -1
  33. package/dist/routing/types.d.ts +1 -3
  34. package/dist/routing/types.d.ts.map +1 -1
  35. package/dist/server/deny-boundary.d.ts +4 -7
  36. package/dist/server/deny-boundary.d.ts.map +1 -1
  37. package/dist/server/internal.js +135 -161
  38. package/dist/server/internal.js.map +1 -1
  39. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  40. package/dist/server/primitives.d.ts +0 -12
  41. package/dist/server/primitives.d.ts.map +1 -1
  42. package/dist/server/sitemap-generator.d.ts.map +1 -1
  43. package/dist/server/static-generator.d.ts.map +1 -1
  44. package/dist/server/status-code-resolver.d.ts +3 -10
  45. package/dist/server/status-code-resolver.d.ts.map +1 -1
  46. package/dist/shims/navigation-client.d.ts +8 -19
  47. package/dist/shims/navigation-client.d.ts.map +1 -1
  48. package/dist/shims/navigation.d.ts +0 -1
  49. package/dist/shims/navigation.d.ts.map +1 -1
  50. package/docs/learn/11-error-handling.mdx +2 -3
  51. package/package.json +1 -1
  52. package/src/adapters/build-output-helper.ts +6 -1
  53. package/src/adapters/shared.ts +5 -2
  54. package/src/client/router.ts +24 -7
  55. package/src/client/rsc-fetch.ts +85 -39
  56. package/src/plugin-context.ts +7 -0
  57. package/src/plugins/routing.ts +1 -1
  58. package/src/plugins/static-build.ts +161 -6
  59. package/src/routing/manifest-codegen.ts +1 -6
  60. package/src/routing/scanner.ts +0 -24
  61. package/src/routing/types.ts +1 -3
  62. package/src/server/deny-boundary.ts +5 -31
  63. package/src/server/deny-renderer.ts +2 -2
  64. package/src/server/pipeline-outcome.ts +1 -1
  65. package/src/server/prebuilt-builder.ts +1 -7
  66. package/src/server/primitives.ts +0 -18
  67. package/src/server/rsc-entry/render-route.ts +3 -3
  68. package/src/server/sitemap-generator.ts +1 -7
  69. package/src/server/static-generator.ts +2 -18
  70. package/src/server/status-code-resolver.ts +4 -47
  71. package/src/shims/navigation-client.ts +8 -26
  72. package/src/shims/navigation.ts +0 -4
  73. package/dist/_chunks/build-output-helper-BC-Zg0_w.js.map +0 -1
  74. package/dist/_chunks/cli-schema-sync-D3naS3eH.js.map +0 -1
@@ -10,7 +10,8 @@
10
10
 
11
11
  import type { Plugin } from 'vite';
12
12
  import { existsSync } from 'node:fs';
13
- import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
13
+ import { mkdir, readdir, readFile, writeFile, rename, rm } from 'node:fs/promises';
14
+ import { createHash } from 'node:crypto';
14
15
  import { dirname, extname, join, resolve, relative } from 'node:path';
15
16
  import { pathToFileURL } from 'node:url';
16
17
  import type { PluginContext } from '../plugin-context.js';
@@ -21,11 +22,24 @@ import type {
21
22
  StaticRedirectEntry,
22
23
  } from '../server/static-generator.js';
23
24
  import { mergeRscAssetsManifest } from './adapter-build.js';
25
+ import { htmlEscapeJsonString } from '../server/flight-scripts.js';
24
26
 
25
27
  // ---------------------------------------------------------------------------
26
28
  // Types
27
29
  // ---------------------------------------------------------------------------
28
30
 
31
+ /**
32
+ * A single entry in the RSC manifest injected into static HTML.
33
+ * Maps an unhashed `/_rsc/*.rsc` path to its hashed URL, with
34
+ * optional route params inlined to avoid a sidecar fetch.
35
+ */
36
+ export interface RscManifestEntry {
37
+ url: string;
38
+ params?: Record<string, string | string[]>;
39
+ }
40
+
41
+ export type RscManifest = Record<string, RscManifestEntry>;
42
+
29
43
  export interface StaticValidationError {
30
44
  type: 'dynamic-api';
31
45
  file: string;
@@ -207,12 +221,20 @@ async function runStaticGeneration(ctx: PluginContext): Promise<StaticGeneration
207
221
  }
208
222
 
209
223
  const staticDir = join(ctx.buildDir, 'static-pages');
224
+ // Clean stale RSC artifacts from previous builds before generating
225
+ // new ones. Without this, already-hashed files from a prior build
226
+ // would get re-hashed and accumulate in the output.
227
+ await rm(join(staticDir, '_rsc'), { recursive: true, force: true }).catch(() => {});
210
228
  await mkdir(staticDir, { recursive: true });
211
229
 
212
230
  const sitemapConfig = ctx.config.sitemap;
213
231
  const autoSitemapEnabled = Boolean(sitemapConfig?.enabled && sitemapConfig?.baseUrl);
214
232
 
215
233
  const contentTypes: Record<string, string> = {};
234
+ // Collect route params from RSC entries so they can be inlined
235
+ // into the manifest instead of written as .params.json sidecars.
236
+ // Keyed by the unhashed /_rsc/*.rsc URL path. TIM-1255.
237
+ const routeParams: Record<string, Record<string, string | string[]>> = {};
216
238
 
217
239
  const summary = await generate({
218
240
  autoSitemapEnabled,
@@ -235,12 +257,15 @@ async function runStaticGeneration(ctx: PluginContext): Promise<StaticGeneration
235
257
  await mkdir(dirname(fullPath), { recursive: true });
236
258
  await writeFileAtomic(fullPath, entry.body);
237
259
 
238
- // Write params sidecar for RSC entries with route params.
239
- // Static .rsc files don't carry HTTP headers, so the client
240
- // fetches this sidecar to populate useSegmentParams(). TIM-1246.
260
+ // Capture route params for RSC entries. These are inlined into
261
+ // the RSC manifest (TIM-1255) instead of written as sidecars.
241
262
  if (entry.kind === 'rsc' && entry.headers['x-timber-params']) {
242
- const paramsPath = fullPath.replace(/\.rsc$/, '.params.json');
243
- await writeFileAtomic(paramsPath, entry.headers['x-timber-params']);
263
+ const rscUrlKey = entry.urlPath === '/' ? '/_rsc/index.rsc' : `/_rsc${entry.urlPath}.rsc`;
264
+ try {
265
+ routeParams[rscUrlKey] = JSON.parse(entry.headers['x-timber-params']);
266
+ } catch {
267
+ // Malformed params header — skip
268
+ }
244
269
  }
245
270
 
246
271
  // Track Content-Type for extensionless API routes so adapters
@@ -269,6 +294,22 @@ async function runStaticGeneration(ctx: PluginContext): Promise<StaticGeneration
269
294
  ctx.staticContentTypes = contentTypes;
270
295
  }
271
296
 
297
+ // Hash RSC flight payloads for immutable caching (TIM-1254).
298
+ // Renames _rsc/*.rsc → _rsc/*-<hash>.rsc and injects a manifest
299
+ // into each HTML file so the client can resolve hashed URLs.
300
+ // Skip when client JS is disabled — no SPA navigation, no RSC fetches,
301
+ // and injecting a <script> tag would violate the no-JS contract.
302
+ if (!ctx.clientJavascript.disabled) {
303
+ const rscManifest = await hashRscPayloads(staticDir, routeParams);
304
+ if (Object.keys(rscManifest).length > 0) {
305
+ await injectRscManifestIntoHtml(staticDir, rscManifest);
306
+ ctx.rscManifest = rscManifest;
307
+ console.log(
308
+ `[timber] Static generation: hashed ${Object.keys(rscManifest).length} RSC flight payloads`
309
+ );
310
+ }
311
+ }
312
+
272
313
  // Write _redirects file for platform redirect rules (Cloudflare Pages,
273
314
  // Netlify, etc.). Format: /from /to status. See TIM-1245.
274
315
  if (summary.redirects.length > 0) {
@@ -294,6 +335,120 @@ async function runStaticGeneration(ctx: PluginContext): Promise<StaticGeneration
294
335
  return summary;
295
336
  }
296
337
 
338
+ // ---------------------------------------------------------------------------
339
+ // RSC flight payload hashing (TIM-1254)
340
+ // ---------------------------------------------------------------------------
341
+
342
+ /**
343
+ * Compute a short content hash for immutable caching.
344
+ * Uses 8 chars of base64url-encoded SHA-256, matching Vite's asset hash style.
345
+ */
346
+ export function contentHash(data: Buffer | Uint8Array): string {
347
+ return createHash('sha256').update(data).digest('base64url').slice(0, 8);
348
+ }
349
+
350
+ /**
351
+ * Walk a directory recursively and return all file paths.
352
+ */
353
+ async function walkDir(dir: string): Promise<string[]> {
354
+ const results: string[] = [];
355
+ let entries;
356
+ try {
357
+ entries = await readdir(dir, { withFileTypes: true });
358
+ } catch {
359
+ return results;
360
+ }
361
+ for (const entry of entries) {
362
+ const full = join(dir, entry.name);
363
+ if (entry.isDirectory()) {
364
+ results.push(...(await walkDir(full)));
365
+ } else {
366
+ results.push(full);
367
+ }
368
+ }
369
+ return results;
370
+ }
371
+
372
+ /**
373
+ * Hash all RSC flight payloads in `_rsc/`, rename them with content
374
+ * hashes, and build a manifest with optional inlined route params.
375
+ *
376
+ * Before: `_rsc/index.rsc`, `_rsc/about.rsc`
377
+ * After: `_rsc/index-B7YxEKdN.rsc`, `_rsc/about-C8ZzFLfO.rsc`
378
+ *
379
+ * The manifest maps the original `_rsc/` URL to an entry object:
380
+ * `{"/_rsc/index.rsc":{"url":"/_rsc/index-B7YxEKdN.rsc"}, ...}`
381
+ *
382
+ * Route params (from `routeParams`) are inlined into the manifest
383
+ * entry, eliminating the need for .params.json sidecar files. TIM-1255.
384
+ *
385
+ * Injected into HTML as:
386
+ * `<script>window.__TIMBER_RSC_MANIFEST__={...}</script>`
387
+ *
388
+ * @internal Exported for testing.
389
+ */
390
+ export async function hashRscPayloads(
391
+ staticDir: string,
392
+ routeParams?: Record<string, Record<string, string | string[]>>
393
+ ): Promise<RscManifest> {
394
+ const rscDir = join(staticDir, '_rsc');
395
+ const files = await walkDir(rscDir);
396
+ const manifest: RscManifest = {};
397
+
398
+ for (const filePath of files) {
399
+ if (!filePath.endsWith('.rsc')) continue;
400
+
401
+ const content = await readFile(filePath);
402
+ const hash = contentHash(content);
403
+ const rel = relative(staticDir, filePath).replace(/\\/g, '/');
404
+
405
+ const base = filePath.slice(0, -'.rsc'.length);
406
+ const hashedPath = `${base}-${hash}.rsc`;
407
+
408
+ await rename(filePath, hashedPath);
409
+
410
+ const hashedRel = relative(staticDir, hashedPath).replace(/\\/g, '/');
411
+ const key = `/${rel}`;
412
+ const entry: RscManifestEntry = { url: `/${hashedRel}` };
413
+ if (routeParams?.[key]) {
414
+ entry.params = routeParams[key];
415
+ }
416
+ manifest[key] = entry;
417
+ }
418
+
419
+ return manifest;
420
+ }
421
+
422
+ /**
423
+ * Inject the RSC manifest into all HTML files as a `<script>` tag
424
+ * before `</head>`. This lets the client resolve hashed RSC URLs
425
+ * without an extra network request.
426
+ *
427
+ * @internal Exported for testing.
428
+ */
429
+ export async function injectRscManifestIntoHtml(
430
+ staticDir: string,
431
+ manifest: RscManifest
432
+ ): Promise<void> {
433
+ const allFiles = await walkDir(staticDir);
434
+ const htmlFiles = allFiles.filter((f) => f.endsWith('.html'));
435
+ const escapedJson = htmlEscapeJsonString(JSON.stringify(manifest));
436
+ const scriptTag = `<script>window.__TIMBER_RSC_MANIFEST__=${escapedJson}</script>`;
437
+
438
+ for (const htmlFile of htmlFiles) {
439
+ let html = await readFile(htmlFile, 'utf-8');
440
+ // Insert before </head> so it's available before any client JS runs
441
+ const headClose = html.indexOf('</head>');
442
+ if (headClose !== -1) {
443
+ html = html.slice(0, headClose) + scriptTag + html.slice(headClose);
444
+ } else {
445
+ // Fallback: prepend to body
446
+ html = scriptTag + html;
447
+ }
448
+ await writeFile(htmlFile, html);
449
+ }
450
+ }
451
+
297
452
  // ---------------------------------------------------------------------------
298
453
  // User-authored _redirects merge (TIM-1249)
299
454
  // ---------------------------------------------------------------------------
@@ -31,12 +31,7 @@ const SINGLE_FILE_KEYS = [
31
31
 
32
32
  // ── Record-shaped conventions ───────────────────────────────────────────
33
33
  // Each entry maps to a node property that holds Record<string, RouteFile>.
34
- const RECORD_FILE_KEYS = [
35
- 'statusFiles',
36
- 'jsonStatusFiles',
37
- 'legacyStatusFiles',
38
- 'metadataRoutes',
39
- ] as const;
34
+ const RECORD_FILE_KEYS = ['statusFiles', 'jsonStatusFiles', 'metadataRoutes'] as const;
40
35
 
41
36
  const SCHEMA_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
42
37
 
@@ -29,17 +29,6 @@ import { ENCODED_SEPARATOR_RE, NULL_BYTE_RE } from '../server/canonicalize.js';
29
29
  */
30
30
  const PAGE_EXT_CONVENTIONS = new Set(['page', 'layout', 'error', 'default', 'denied']);
31
31
 
32
- /**
33
- * Legacy compat status-code files.
34
- * Maps legacy file name → HTTP status code for the fallback chain.
35
- * See design/10-error-handling.md §"Fallback Chain".
36
- */
37
- const LEGACY_STATUS_FILES: Record<string, number> = {
38
- 'not-found': 404,
39
- 'forbidden': 403,
40
- 'unauthorized': 401,
41
- };
42
-
43
32
  /**
44
33
  * File convention names that are always .ts/.tsx (never .mdx etc.)
45
34
  */
@@ -219,19 +208,6 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
219
208
  continue;
220
209
  }
221
210
 
222
- // Legacy compat files (not-found.tsx, forbidden.tsx, unauthorized.tsx)
223
- if (name in LEGACY_STATUS_FILES && extSet.has(ext)) {
224
- if (!node.legacyStatusFiles) {
225
- node.legacyStatusFiles = {};
226
- }
227
- const existing = node.legacyStatusFiles[name];
228
- if (existing) {
229
- throwDuplicateConvention(name, existing.filePath, fullPath);
230
- }
231
- node.legacyStatusFiles[name] = { filePath: fullPath, extension: ext };
232
- continue;
233
- }
234
-
235
211
  // Metadata route files (sitemap.ts, robots.ts, icon.tsx, opengraph-image.tsx, etc.)
236
212
  // Both static (.xml, .txt, .png, .ico, etc.) and dynamic (.ts, .tsx) files are recognized.
237
213
  // When both exist for the same base name, dynamic takes precedence.
@@ -14,7 +14,7 @@
14
14
  * `SegmentNode` (Map-based) and `ManifestSegmentNode` (object-based).
15
15
  *
16
16
  * Keyed groups (`slots`, `statusFiles`, `jsonStatusFiles`,
17
- * `legacyStatusFiles`, `metadataRoutes`) are plain `Record<string, …>`
17
+ * `metadataRoutes`) are plain `Record<string, …>`
18
18
  * objects rather than `Map`s so that the build-time tree can be
19
19
  * serialized into the virtual route manifest with no shape transform.
20
20
  *
@@ -110,8 +110,6 @@ export interface SegmentNode<TFile = RouteFile> {
110
110
  jsonStatusFiles?: Record<string, TFile>;
111
111
  /** denied.tsx — slot-only denial rendering */
112
112
  denied?: TFile;
113
- /** Legacy compat: not-found.tsx (maps to 404), forbidden.tsx (403), unauthorized.tsx (401) */
114
- legacyStatusFiles?: Record<string, TFile>;
115
113
 
116
114
  /** Metadata route files (sitemap.ts, robots.ts, icon.tsx, etc.) keyed by base name */
117
115
  metadataRoutes?: Record<string, TFile>;
@@ -36,7 +36,6 @@ import type { SerializableError } from '../client/error-reconstituter.js';
36
36
  import { requestContextAls } from './als-registry.js';
37
37
  import { DenySignal } from './primitives.js';
38
38
  import { loadModule } from './safe-load.js';
39
- import { LEGACY_FILE_TO_STATUS } from './status-code-resolver.js';
40
39
  import { withSpan } from './tracing.js';
41
40
  import { isMdxFilePath } from './utils/mdx-file.js';
42
41
  import type { ManifestSegmentNode } from './route-matcher.js';
@@ -50,12 +49,11 @@ export interface DenyPageEntry {
50
49
  /** The component to render (server or client — both work). */
51
50
  component: (...args: unknown[]) => unknown;
52
51
  /**
53
- * How the entry matched: a status-code file (404.tsx/4xx.tsx), a legacy
54
- * compat file (not-found.tsx/forbidden.tsx/unauthorized.tsx), or the
52
+ * How the entry matched: a status-code file (404.tsx/4xx.tsx) or the
55
53
  * error.tsx catch-all. error.tsx entries render with their documented
56
54
  * { error, digest, reset } contract, not bare deny props. See TIM-1081.
57
55
  */
58
- kind: 'status' | 'legacy' | 'error';
56
+ kind: 'status' | 'error';
59
57
  /** MDX files are server components — rendered with plain props, never ErrorReconstituter. */
60
58
  isMdx: boolean;
61
59
  }
@@ -69,9 +67,7 @@ export interface DenyPageEntry {
69
67
  * status-code file components in fallback order:
70
68
  * 1. Specific status files (403.tsx, 404.tsx) — exact match
71
69
  * 2. Category catch-alls (4xx.tsx) — matches any 4xx
72
- * 3. Legacy compat files (not-found.tsx → 404, forbidden.tsx → 403,
73
- * unauthorized.tsx → 401) — exact match
74
- * 4. error.tsx — catches everything
70
+ * 3. error.tsx — catches everything
75
71
  *
76
72
  * Each segment is checked in this order. The chain is ordered so the
77
73
  * FIRST match wins at catch time. This mirrors resolveStatusFile's 4xx
@@ -127,29 +123,7 @@ export async function buildDenyPageChain(
127
123
  }
128
124
  }
129
125
 
130
- // Pass 2: legacy compat files (not-found.tsx → 404, forbidden.tsx → 403,
131
- // unauthorized.tsx → 401). Lower priority than status files (a root
132
- // 4xx.tsx beats a leaf not-found.tsx) but higher than error.tsx — the
133
- // same ordering as resolveStatusFile's resolve4xx. See TIM-1081.
134
- for (let i = startIndex; i >= 0; i--) {
135
- const segment = segments[i];
136
- if (!segment.legacyStatusFiles) continue;
137
- for (const [name, status] of Object.entries(LEGACY_FILE_TO_STATUS)) {
138
- const file = segment.legacyStatusFiles[name];
139
- if (!file) continue;
140
- const mod = await loadModule(file).catch(() => null);
141
- if (mod?.default) {
142
- chain.push({
143
- status,
144
- component: mod.default as (...args: unknown[]) => unknown,
145
- kind: 'legacy',
146
- isMdx: isMdxFilePath(file.filePath),
147
- });
148
- }
149
- }
150
- }
151
-
152
- // Pass 3: error.tsx files — lowest priority catch-all.
126
+ // Pass 2: error.tsx files — lowest priority catch-all.
153
127
  // Only added AFTER all status and legacy files so they never shadow a
154
128
  // more specific file from an ancestor segment.
155
129
  for (let i = startIndex; i >= 0; i--) {
@@ -202,7 +176,7 @@ export function renderMatchingDenyPage(
202
176
  * Build the element for a matched deny chain entry with the props contract
203
177
  * the file's convention documents:
204
178
  *
205
- * - Status-code and legacy files: { status, dangerouslyPassData }
179
+ * - Status-code files: { status, dangerouslyPassData }
206
180
  * - error.tsx (TSX): { error, digest, reset } via ErrorReconstituter, plus
207
181
  * { status, dangerouslyPassData } for dual-shape implementations. Without
208
182
  * the Error prop, any error.tsx written per the docs (reading
@@ -76,13 +76,13 @@ function isApiRoute(segments: ReadonlyArray<ManifestSegmentNode>): boolean {
76
76
  function buildDenyElement(
77
77
  deny: DenySignal,
78
78
  component: (...args: unknown[]) => unknown,
79
- resolutionKind: 'exact' | 'category' | 'legacy' | 'error',
79
+ resolutionKind: 'exact' | 'category' | 'error',
80
80
  filePath: string
81
81
  ): React.ReactElement {
82
82
  const entry: DenyPageEntry = {
83
83
  status: deny.status,
84
84
  component,
85
- kind: resolutionKind === 'error' ? 'error' : resolutionKind === 'legacy' ? 'legacy' : 'status',
85
+ kind: resolutionKind === 'error' ? 'error' : 'status',
86
86
  isMdx: isMdxFilePath(filePath),
87
87
  };
88
88
  return renderDenyEntry(entry, deny.status, deny.data);
@@ -132,7 +132,7 @@ export async function outcomeToResponse(
132
132
  `[timber] DenySignal(${outcome.signal.status}) from ${outcome.phase} phase — ` +
133
133
  `no renderDenyFallback configured, returning bare ${outcome.signal.status} response\n` +
134
134
  ` Request: ${ctx.method} ${ctx.path}\n` +
135
- ` Add a not-found.tsx or error.tsx to render a custom deny page.`
135
+ ` Add a status-code file (e.g. 404.tsx) or error.tsx to render a custom deny page.`
136
136
  );
137
137
  }
138
138
  return new Response(null, { status: outcome.signal.status, headers });
@@ -235,13 +235,7 @@ async function enumerateCombos(
235
235
  ): Promise<ParamCombo[] | null> {
236
236
  if (!hasDynamicSegments(route.urlPath)) return [{}];
237
237
 
238
- let generateFn = pageModule.generateStaticSegmentParams;
239
- if (typeof generateFn !== 'function' && typeof pageModule.generateStaticParams === 'function') {
240
- generateFn = pageModule.generateStaticParams;
241
- log(
242
- `[timber] ${route.page.filePath}: generateStaticParams is deprecated — rename to generateStaticSegmentParams`
243
- );
244
- }
238
+ const generateFn = pageModule.generateStaticSegmentParams;
245
239
  if (typeof generateFn !== 'function') {
246
240
  log(
247
241
  `[timber] cache.component: route ${route.urlPath} has prebuilt components but no ` +
@@ -201,15 +201,6 @@ export function deny(statusOrOptions?: number | DenyOptions, data?: JsonSerializ
201
201
  throw new DenySignal(status, resolvedData);
202
202
  }
203
203
 
204
- /**
205
- * @deprecated Use `deny(404)` instead.
206
- * Kept for internal use by the Next.js shim layer.
207
- * @internal
208
- */
209
- export function notFound(): never {
210
- deny(404);
211
- }
212
-
213
204
  // Single source of truth shared with the client next/navigation shim —
214
205
  // see shared/redirect-type.ts.
215
206
  export { RedirectType } from '../shared/redirect-type.js';
@@ -334,15 +325,6 @@ export function redirect(path: string, statusOrOptions?: number | RedirectOption
334
325
  throw new RedirectSignal(resolvedPath, status);
335
326
  }
336
327
 
337
- /**
338
- * @deprecated Use `redirect(path, { permanent: true })` instead.
339
- * Kept for internal use by the Next.js shim layer.
340
- * @internal
341
- */
342
- export function permanentRedirect(path: string, options?: Omit<RedirectOptions, 'status'>): never {
343
- redirect(path, { permanent: true, ...options });
344
- }
345
-
346
328
  /**
347
329
  * Redirect to an external URL. The origin must be in the provided allow-list.
348
330
  *
@@ -115,10 +115,10 @@ export async function renderRoute(
115
115
  buildManifest.metadataRouteHashes
116
116
  );
117
117
  } catch (error) {
118
- // Param coercion failed — render the custom 404 page (status files / not-found).
118
+ // Param coercion failed — render the custom 404 page via status files.
119
119
  // Previously returned a bare Response(null, { status: 404 }) which bypassed
120
- // custom not-found pages. Now routes through renderNoMatchPage so apps with
121
- // 404.tsx / not-found status files render their custom page.
120
+ // custom 404 pages. Now routes through renderNoMatchPage so apps with
121
+ // 404.tsx render their custom page.
122
122
  if (error instanceof ParamCoercionError) {
123
123
  return renderNoMatchPage(req, rootSegment, responseHeaders, clientBootstrap);
124
124
  }
@@ -187,13 +187,7 @@ export async function resolveSitemapUrls(
187
187
  // Dynamic route — try to call generateStaticSegmentParams
188
188
  try {
189
189
  const mod = await loadModule(route.pageLoader);
190
- let generateFn = mod.generateStaticSegmentParams;
191
- if (typeof generateFn !== 'function' && typeof mod.generateStaticParams === 'function') {
192
- generateFn = mod.generateStaticParams;
193
- console.warn(
194
- `[timber] ${route.pageLoader.filePath}: generateStaticParams is deprecated — rename to generateStaticSegmentParams`
195
- );
196
- }
190
+ const generateFn = mod.generateStaticSegmentParams;
197
191
  if (typeof generateFn !== 'function') {
198
192
  // No generateStaticSegmentParams export — skip this dynamic route
199
193
  continue;
@@ -227,15 +227,7 @@ async function expandDynamicRoute(
227
227
  ): Promise<string[] | null> {
228
228
  const mod = await loadModuleWithTimeout(page, timeoutMs);
229
229
 
230
- let generateFn = mod.generateStaticSegmentParams;
231
- if (typeof generateFn !== 'function' && typeof mod.generateStaticParams === 'function') {
232
- generateFn = mod.generateStaticParams;
233
- log(
234
- `[timber] ${page.filePath}: generateStaticParams is deprecated — ` +
235
- `rename to generateStaticSegmentParams`
236
- );
237
- }
238
-
230
+ const generateFn = mod.generateStaticSegmentParams;
239
231
  if (typeof generateFn !== 'function') return null;
240
232
 
241
233
  const combos = (await (generateFn as () => Promise<unknown>)()) as Record<
@@ -284,15 +276,7 @@ async function expandDynamicApiRoute(
284
276
  ): Promise<string[] | null> {
285
277
  const mod = await loadModuleWithTimeout(route, timeoutMs);
286
278
 
287
- let generateFn = mod.generateStaticSegmentParams;
288
- if (typeof generateFn !== 'function' && typeof mod.generateStaticParams === 'function') {
289
- generateFn = mod.generateStaticParams;
290
- log(
291
- `[timber] ${route.filePath}: generateStaticParams is deprecated — ` +
292
- `rename to generateStaticSegmentParams`
293
- );
294
- }
295
-
279
+ const generateFn = mod.generateStaticSegmentParams;
296
280
  if (typeof generateFn !== 'function') return null;
297
281
 
298
282
  const combos = (await (generateFn as () => Promise<unknown>)()) as Record<
@@ -19,9 +19,8 @@
19
19
  *
20
20
  * **Component chain (4xx):**
21
21
  * Pass 1 — status files (leaf → root): {status}.tsx → 4xx.tsx
22
- * Pass 2 — legacy compat (leaf → root): not-found.tsx / forbidden.tsx / unauthorized.tsx
23
- * Pass 3 — error.tsx (leaf → root)
24
- * Pass 4 — framework default (returns null)
22
+ * Pass 2 — error.tsx (leaf → root)
23
+ * Pass 3 — framework default (returns null)
25
24
  *
26
25
  * **JSON chain (4xx and 5xx):**
27
26
  * Pass 1 — json status files (leaf → root): {status}.json → {category}.json
@@ -40,7 +39,6 @@ import type { SegmentNode } from '../routing/types.js';
40
39
  export type StatusFileKind =
41
40
  | 'exact' // e.g. 403.tsx matched status 403
42
41
  | 'category' // e.g. 4xx.tsx matched status 403
43
- | 'legacy' // e.g. not-found.tsx matched status 404
44
42
  | 'error'; // error.tsx as last resort
45
43
 
46
44
  /** Response format family for status-code resolution. */
@@ -71,24 +69,6 @@ export interface SlotDeniedResolution<TFile> {
71
69
  kind: SlotDeniedKind;
72
70
  }
73
71
 
74
- // ─── Legacy Compat Mapping ───────────────────────────────────────────────────
75
-
76
- /**
77
- * Maps legacy file convention names to their corresponding HTTP status codes.
78
- * Only used in the 4xx component fallback chain. Exported so the in-tree
79
- * deny chain (deny-boundary.ts) uses the same mapping — see TIM-1081.
80
- */
81
- export const LEGACY_FILE_TO_STATUS: Record<string, number> = {
82
- 'not-found': 404,
83
- 'forbidden': 403,
84
- 'unauthorized': 401,
85
- };
86
-
87
- /** Reverse index: status code → legacy file name. Built once at module load. */
88
- const STATUS_TO_LEGACY_FILE: Record<number, string> = Object.fromEntries(
89
- Object.entries(LEGACY_FILE_TO_STATUS).map(([name, status]) => [status, name])
90
- );
91
-
92
72
  // ─── Lookup Helpers ──────────────────────────────────────────────────────
93
73
 
94
74
  /**
@@ -113,23 +93,6 @@ function lookupInGroup<TFile>(
113
93
  return null;
114
94
  }
115
95
 
116
- /**
117
- * Look up the legacy convention file (`not-found.tsx` / `forbidden.tsx` /
118
- * `unauthorized.tsx`) for `status` on a single segment. Returns null if
119
- * `status` has no legacy mapping or the file isn't present.
120
- */
121
- function lookupLegacy<TFile>(
122
- group: Record<string, TFile> | undefined,
123
- status: number,
124
- segmentIndex: number
125
- ): StatusFileResolution<TFile> | null {
126
- if (!group) return null;
127
- const name = STATUS_TO_LEGACY_FILE[status];
128
- if (!name) return null;
129
- const file = group[name];
130
- return file ? { file, status, kind: 'legacy', segmentIndex } : null;
131
- }
132
-
133
96
  // ─── Resolver ────────────────────────────────────────────────────────────────
134
97
 
135
98
  /**
@@ -155,7 +118,7 @@ export function resolveStatusFile<TFile>(
155
118
  }
156
119
 
157
120
  /**
158
- * 4xx component fallback chain — three separate full passes leaf→root.
121
+ * 4xx component fallback chain — two separate full passes leaf→root.
159
122
  *
160
123
  * The passes must be separate (not interleaved per-segment) so that a
161
124
  * root-level `404.tsx` beats a leaf-level `error.tsx`. The 5xx chain
@@ -164,8 +127,7 @@ export function resolveStatusFile<TFile>(
164
127
  * separately.
165
128
  *
166
129
  * Pass 1 — {status}.tsx → 4xx.tsx (statusFiles)
167
- * Pass 2 — not-found / forbidden / unauthorized (legacyStatusFiles)
168
- * Pass 3 — error.tsx (error)
130
+ * Pass 2 — error.tsx (error)
169
131
  */
170
132
  function resolve4xx<TFile>(
171
133
  status: number,
@@ -178,11 +140,6 @@ function resolve4xx<TFile>(
178
140
  if (r) return r;
179
141
  }
180
142
 
181
- for (let i = segments.length - 1; i >= 0; i--) {
182
- const r = lookupLegacy(segments[i].legacyStatusFiles, status, i);
183
- if (r) return r;
184
- }
185
-
186
143
  for (let i = segments.length - 1; i >= 0; i--) {
187
144
  const errorFile = segments[i].error;
188
145
  if (errorFile) {
@@ -2,15 +2,15 @@
2
2
  * Shim: next/navigation (client environment only)
3
3
  *
4
4
  * Re-exports the client-side hooks from timber's client API, and implements
5
- * client-render `redirect()` / `permanentRedirect()` / `notFound()` without
6
- * pulling server/primitives.ts into the browser bundle.
5
+ * client-render `redirect()` without pulling server/primitives.ts into the
6
+ * browser bundle.
7
7
  *
8
- * Next.js supports calling redirect()/notFound() during client component
9
- * render. timber matches that by throwing a plain Error carrying the same
10
- * JSON digest the server pipeline attaches to RedirectSignal/DenySignal.
11
- * TimberErrorBoundary recognizes the digest and converts it to an SPA
12
- * navigation (redirect) or a status-code fallback (notFound), and
13
- * browser-dev's isFrameworkSignalError() keeps it out of the dev overlay.
8
+ * Next.js supports calling redirect() during client component render.
9
+ * timber matches that by throwing a plain Error carrying the same JSON
10
+ * digest the server pipeline attaches to RedirectSignal. TimberErrorBoundary
11
+ * recognizes the digest and converts it to an SPA navigation (replace
12
+ * semantics), and browser-dev's isFrameworkSignalError() keeps it out of
13
+ * the dev overlay.
14
14
  *
15
15
  * The full shim (navigation.ts) is still used in the RSC and SSR environments
16
16
  * where the real server primitives produce proper HTTP responses.
@@ -59,21 +59,3 @@ export function redirect(path: string, _type?: RedirectTypeValue): never {
59
59
  assertRelativeRedirectPath(path);
60
60
  throwSignal(`Redirect to ${path}`, { type: 'redirect', location: path, status: 302 });
61
61
  }
62
-
63
- /**
64
- * Client-render permanent redirect. On the client there is no HTTP status —
65
- * this behaves identically to redirect() apart from the digest's status.
66
- */
67
- export function permanentRedirect(path: string, _type?: RedirectTypeValue): never {
68
- assertRelativeRedirectPath(path);
69
- throwSignal(`Redirect to ${path}`, { type: 'redirect', location: path, status: 308 });
70
- }
71
-
72
- /**
73
- * Client-render notFound. Throws a deny-digest error (status 404) that the
74
- * nearest matching TimberErrorBoundary renders as the 404 fallback. The
75
- * message matches DenySignal's so digest-less fallback parsing still works.
76
- */
77
- export function notFound(): never {
78
- throwSignal('Access denied with status 404', { type: 'deny', status: 404, data: null });
79
- }
@@ -21,7 +21,3 @@ export {
21
21
 
22
22
  // Functions (server-side)
23
23
  export { redirect, redirectExternal, RedirectType } from '@timber-js/app/server';
24
-
25
- // Next.js compat aliases — these are not part of timber's primary API.
26
- // They exist so that libraries importing from 'next/navigation' still work.
27
- export { permanentRedirect, notFound } from '../server/primitives.js';