@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.
- package/dist/_chunks/{build-output-helper-BC-Zg0_w.js → build-output-helper-DOGYFb_X.js} +11 -3
- package/dist/_chunks/build-output-helper-DOGYFb_X.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-D3naS3eH.js → cli-schema-sync-BTWEKJXo.js} +1 -21
- package/dist/_chunks/cli-schema-sync-BTWEKJXo.js.map +1 -0
- package/dist/_chunks/{cloudflare-nlD9KhDj.js → cloudflare-CnT5Lr7U.js} +2 -2
- package/dist/_chunks/{cloudflare-nlD9KhDj.js.map → cloudflare-CnT5Lr7U.js.map} +1 -1
- package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -1
- package/dist/_chunks/plugin-context---kTF5v8.js.map +1 -1
- package/dist/_chunks/segment-classify-Byy425ng.js.map +1 -1
- package/dist/_chunks/{walkers-BtTlKviE.js → walkers-_6zKFlch.js} +2 -2
- package/dist/_chunks/{walkers-BtTlKviE.js.map → walkers-_6zKFlch.js.map} +1 -1
- package/dist/adapters/build-output-helper.d.ts.map +1 -1
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/adapters/shared.d.ts.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/client/internal.js +70 -22
- package/dist/client/internal.js.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +17 -0
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/index.js +128 -9
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts +7 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +42 -0
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/routing/manifest-codegen.d.ts.map +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/types.d.ts +1 -3
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +4 -7
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/internal.js +135 -161
- package/dist/server/internal.js.map +1 -1
- package/dist/server/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +0 -12
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/sitemap-generator.d.ts.map +1 -1
- package/dist/server/static-generator.d.ts.map +1 -1
- package/dist/server/status-code-resolver.d.ts +3 -10
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/shims/navigation-client.d.ts +8 -19
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/dist/shims/navigation.d.ts +0 -1
- package/dist/shims/navigation.d.ts.map +1 -1
- package/docs/learn/11-error-handling.mdx +2 -3
- package/package.json +1 -1
- package/src/adapters/build-output-helper.ts +6 -1
- package/src/adapters/shared.ts +5 -2
- package/src/client/router.ts +24 -7
- package/src/client/rsc-fetch.ts +85 -39
- package/src/plugin-context.ts +7 -0
- package/src/plugins/routing.ts +1 -1
- package/src/plugins/static-build.ts +161 -6
- package/src/routing/manifest-codegen.ts +1 -6
- package/src/routing/scanner.ts +0 -24
- package/src/routing/types.ts +1 -3
- package/src/server/deny-boundary.ts +5 -31
- package/src/server/deny-renderer.ts +2 -2
- package/src/server/pipeline-outcome.ts +1 -1
- package/src/server/prebuilt-builder.ts +1 -7
- package/src/server/primitives.ts +0 -18
- package/src/server/rsc-entry/render-route.ts +3 -3
- package/src/server/sitemap-generator.ts +1 -7
- package/src/server/static-generator.ts +2 -18
- package/src/server/status-code-resolver.ts +4 -47
- package/src/shims/navigation-client.ts +8 -26
- package/src/shims/navigation.ts +0 -4
- package/dist/_chunks/build-output-helper-BC-Zg0_w.js.map +0 -1
- 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
|
-
//
|
|
239
|
-
//
|
|
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
|
|
243
|
-
|
|
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
|
|
package/src/routing/scanner.ts
CHANGED
|
@@ -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.
|
package/src/routing/types.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `SegmentNode` (Map-based) and `ManifestSegmentNode` (object-based).
|
|
15
15
|
*
|
|
16
16
|
* Keyed groups (`slots`, `statusFiles`, `jsonStatusFiles`,
|
|
17
|
-
* `
|
|
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)
|
|
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' | '
|
|
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.
|
|
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:
|
|
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
|
|
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' | '
|
|
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' :
|
|
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
|
|
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
|
-
|
|
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 ` +
|
package/src/server/primitives.ts
CHANGED
|
@@ -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
|
|
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
|
|
121
|
-
// 404.tsx
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
23
|
-
* Pass 3 —
|
|
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 —
|
|
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 —
|
|
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()`
|
|
6
|
-
*
|
|
5
|
+
* client-render `redirect()` without pulling server/primitives.ts into the
|
|
6
|
+
* browser bundle.
|
|
7
7
|
*
|
|
8
|
-
* Next.js supports calling redirect()
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
}
|
package/src/shims/navigation.ts
CHANGED
|
@@ -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';
|