@timber-js/app 0.2.0-alpha.182 → 0.2.0-alpha.183
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-DF5uScqS.js → build-output-helper-BC-Zg0_w.js} +38 -8
- package/dist/_chunks/build-output-helper-BC-Zg0_w.js.map +1 -0
- package/dist/_chunks/{cloudflare-AHoWYTYr.js → cloudflare-nlD9KhDj.js} +21 -3
- package/dist/_chunks/{cloudflare-AHoWYTYr.js.map → cloudflare-nlD9KhDj.js.map} +1 -1
- package/dist/_chunks/plugin-context---kTF5v8.js.map +1 -1
- package/dist/adapters/build-output-helper.d.ts +6 -0
- 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.d.ts +10 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +2 -2
- package/dist/adapters/nitro.d.ts +14 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +14 -1
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/adapters/shared.d.ts +1 -1
- package/dist/adapters/shared.d.ts.map +1 -1
- package/dist/adapters/types.d.ts +14 -0
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/client/internal.js +73 -11
- package/dist/client/internal.js.map +1 -1
- package/dist/client/rsc-fetch.d.ts +2 -0
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/index.js +324 -196
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts +6 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/build-report.d.ts +4 -3
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +26 -2
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +12 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/sitemap-generator.d.ts +1 -1
- package/dist/server/sitemap-generator.d.ts.map +1 -1
- package/dist/server/static-generator.d.ts +106 -0
- package/dist/server/static-generator.d.ts.map +1 -0
- package/docs/learn/13-configuration.mdx +5 -1
- package/package.json +1 -1
- package/src/adapters/build-output-helper.ts +34 -0
- package/src/adapters/cloudflare.ts +43 -1
- package/src/adapters/nitro.ts +58 -2
- package/src/adapters/shared.ts +38 -10
- package/src/adapters/types.ts +14 -0
- package/src/client/browser-entry/index.ts +7 -1
- package/src/client/rsc-fetch.ts +132 -19
- package/src/index.ts +1 -1
- package/src/plugin-context.ts +6 -0
- package/src/plugins/adapter-build.ts +5 -0
- package/src/plugins/build-report.ts +6 -12
- package/src/plugins/static-build.ts +264 -2
- package/src/server/rsc-entry/index.ts +24 -0
- package/src/server/sitemap-generator.ts +1 -1
- package/src/server/static-generator.ts +692 -0
- package/dist/_chunks/build-output-helper-DF5uScqS.js.map +0 -1
package/src/client/rsc-fetch.ts
CHANGED
|
@@ -88,6 +88,56 @@ export function getClientDeploymentId(): string | null {
|
|
|
88
88
|
return clientDeploymentId;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// ─── Static Mode ────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* When true, RSC fetches use _rsc/*.rsc file URLs instead of
|
|
95
|
+
* the route URL with Accept headers. Static hosts ignore Accept
|
|
96
|
+
* headers, so the client must fetch the pre-generated .rsc files
|
|
97
|
+
* directly. Set at bootstrap from virtual:timber-config output mode.
|
|
98
|
+
*/
|
|
99
|
+
let staticMode = false;
|
|
100
|
+
|
|
101
|
+
export function setStaticMode(enabled: boolean): void {
|
|
102
|
+
staticMode = enabled;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isStaticMode(): boolean {
|
|
106
|
+
return staticMode;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Convert a route URL to the corresponding _rsc/*.rsc file path.
|
|
111
|
+
* Mirrors the naming in plugins/static-build.ts staticOutputPath.
|
|
112
|
+
*
|
|
113
|
+
* / → /_rsc/index.rsc
|
|
114
|
+
* /about → /_rsc/about.rsc
|
|
115
|
+
* /blog/hello → /_rsc/blog/hello.rsc
|
|
116
|
+
*/
|
|
117
|
+
function toStaticRscUrl(url: string): string {
|
|
118
|
+
const hashIndex = url.indexOf('#');
|
|
119
|
+
const queryIndex = url.indexOf('?');
|
|
120
|
+
const hashEnd = hashIndex === -1 ? url.length : hashIndex;
|
|
121
|
+
const queryEnd = queryIndex === -1 ? url.length : queryIndex;
|
|
122
|
+
const end = Math.min(hashEnd, queryEnd);
|
|
123
|
+
let pathname = url.slice(0, end);
|
|
124
|
+
// Strip trailing slash (unless root) to match static build output naming
|
|
125
|
+
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
126
|
+
pathname = pathname.slice(0, -1);
|
|
127
|
+
}
|
|
128
|
+
const rscPath = pathname === '/' ? '/index' : pathname;
|
|
129
|
+
return `/_rsc${rscPath}.rsc`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Convert a route URL to the corresponding _rsc/*.params.json sidecar path.
|
|
134
|
+
* Used in static mode to fetch route params that are normally carried
|
|
135
|
+
* in the X-Timber-Params response header. See TIM-1246.
|
|
136
|
+
*/
|
|
137
|
+
function toStaticParamsUrl(url: string): string {
|
|
138
|
+
return toStaticRscUrl(url).replace(/\.rsc$/, '.params.json');
|
|
139
|
+
}
|
|
140
|
+
|
|
91
141
|
// ─── Reload Signal ───────────────────────────────────────────────
|
|
92
142
|
|
|
93
143
|
/** Header name used by the server to signal a version skew reload. */
|
|
@@ -326,8 +376,13 @@ export async function fetchRscPayload(
|
|
|
326
376
|
currentUrl?: string,
|
|
327
377
|
signal?: AbortSignal
|
|
328
378
|
): Promise<FetchResult> {
|
|
329
|
-
|
|
330
|
-
|
|
379
|
+
// In static mode, fetch the pre-generated _rsc/*.rsc file directly
|
|
380
|
+
// instead of the route URL with Accept headers. Static hosts ignore
|
|
381
|
+
// Accept headers, so the route URL would return HTML. The _rsc param
|
|
382
|
+
// is still appended for cache busting.
|
|
383
|
+
const fetchTarget = staticMode ? toStaticRscUrl(url) : url;
|
|
384
|
+
const rscUrl = appendRscParam(fetchTarget);
|
|
385
|
+
const headers = buildRscHeaders(staticMode ? undefined : stateTree, currentUrl);
|
|
331
386
|
if (deps.decodeRsc) {
|
|
332
387
|
// Production path: use createFromFetch for streaming RSC decoding.
|
|
333
388
|
// createFromFetch takes a Promise<Response> and progressively parses
|
|
@@ -336,6 +391,21 @@ export async function fetchRscPayload(
|
|
|
336
391
|
// Intercept the response to read segment metadata before createFromFetch
|
|
337
392
|
// consumes the body. Reading headers does NOT consume the body stream.
|
|
338
393
|
const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
|
|
394
|
+
// In static mode, fetch the params sidecar in parallel. The .rsc
|
|
395
|
+
// file has no HTTP headers, so params come from a .params.json
|
|
396
|
+
// sidecar written at build time. See TIM-1246.
|
|
397
|
+
const staticParamsFetch = staticMode
|
|
398
|
+
? deps
|
|
399
|
+
.fetch(toStaticParamsUrl(url), { signal })
|
|
400
|
+
.then((r) => (r.ok ? (r.json() as Promise<Record<string, string | string[]>>) : null))
|
|
401
|
+
.catch((e) => {
|
|
402
|
+
if (e instanceof DOMException && e.name === 'AbortError') throw e;
|
|
403
|
+
return null;
|
|
404
|
+
})
|
|
405
|
+
: null;
|
|
406
|
+
// Observe the sidecar promise so its rejection doesn't become
|
|
407
|
+
// unhandled if wrappedPromise rejects first (TIM-1248).
|
|
408
|
+
staticParamsFetch?.catch(() => {});
|
|
339
409
|
let segmentInfo: SegmentInfo[] | null = null;
|
|
340
410
|
let params: Record<string, string | string[]> | null = null;
|
|
341
411
|
let skippedSegments: string[] | null = null;
|
|
@@ -368,14 +438,27 @@ export async function fetchRscPayload(
|
|
|
368
438
|
if (response.headers.get('X-Timber-Error') === '1') {
|
|
369
439
|
throw new ServerErrorResponse(response.status, url);
|
|
370
440
|
}
|
|
371
|
-
// Content-Type guard: reject non-RSC responses
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
441
|
+
// Content-Type guard: reject non-RSC responses before createFromFetch
|
|
442
|
+
// tries to parse the body as Flight data.
|
|
443
|
+
// In static mode, accept octet-stream/text/plain/absent content-type
|
|
444
|
+
// (static hosts serve .rsc files with these), but still reject 404s
|
|
445
|
+
// and text/html (missing .rsc file → host returns 404 page or SPA
|
|
446
|
+
// HTML fallback). See TIM-1231, TIM-1243, TIM-1247.
|
|
447
|
+
if (staticMode) {
|
|
448
|
+
const contentType = response.headers.get('content-type');
|
|
449
|
+
if (
|
|
450
|
+
!response.ok ||
|
|
451
|
+
(contentType && contentType.split(';')[0].trim().toLowerCase() === 'text/html')
|
|
452
|
+
) {
|
|
453
|
+
response.body?.cancel();
|
|
454
|
+
throw new NonRscResponse(url);
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
const contentType = response.headers.get('content-type');
|
|
458
|
+
if (!contentType || !contentType.split(';')[0].trim().includes(RSC_CONTENT_TYPE)) {
|
|
459
|
+
response.body?.cancel();
|
|
460
|
+
throw new NonRscResponse(url);
|
|
461
|
+
}
|
|
379
462
|
}
|
|
380
463
|
// Metadata (<title>/<meta>/<link>) now rides the RSC Flight payload
|
|
381
464
|
// as React elements — React 19 Float handles them. See TIM-1151.
|
|
@@ -400,6 +483,11 @@ export async function fetchRscPayload(
|
|
|
400
483
|
});
|
|
401
484
|
// Await headers so segmentInfo/params are populated.
|
|
402
485
|
await wrappedPromise;
|
|
486
|
+
// In static mode, params come from the sidecar (header extraction
|
|
487
|
+
// returns null because .rsc files have no HTTP headers). TIM-1246.
|
|
488
|
+
if (staticParamsFetch && !params) {
|
|
489
|
+
params = await staticParamsFetch;
|
|
490
|
+
}
|
|
403
491
|
// Start decoding but do NOT await — return the in-progress thenable.
|
|
404
492
|
// React can render a Flight thenable directly: it suspends on unresolved
|
|
405
493
|
// parts and progressively renders as chunks arrive, spreading work across
|
|
@@ -436,20 +524,45 @@ export async function fetchRscPayload(
|
|
|
436
524
|
if (response.headers.get('X-Timber-Error') === '1') {
|
|
437
525
|
throw new ServerErrorResponse(response.status, url);
|
|
438
526
|
}
|
|
439
|
-
// Content-Type guard (same as production path above). See TIM-1231.
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
527
|
+
// Content-Type guard (same as production path above). See TIM-1231, TIM-1243, TIM-1247.
|
|
528
|
+
if (staticMode) {
|
|
529
|
+
const fallbackContentType = response.headers.get('content-type');
|
|
530
|
+
if (
|
|
531
|
+
!response.ok ||
|
|
532
|
+
(fallbackContentType &&
|
|
533
|
+
fallbackContentType.split(';')[0].trim().toLowerCase() === 'text/html')
|
|
534
|
+
) {
|
|
535
|
+
response.body?.cancel();
|
|
536
|
+
throw new NonRscResponse(url);
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
const fallbackContentType = response.headers.get('content-type');
|
|
540
|
+
if (
|
|
541
|
+
!fallbackContentType ||
|
|
542
|
+
!fallbackContentType.split(';')[0].trim().includes(RSC_CONTENT_TYPE)
|
|
543
|
+
) {
|
|
544
|
+
response.body?.cancel();
|
|
545
|
+
throw new NonRscResponse(url);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
let fallbackParams = extractParams(response);
|
|
549
|
+
// In static mode, params come from the sidecar. TIM-1246.
|
|
550
|
+
if (staticMode && !fallbackParams) {
|
|
551
|
+
try {
|
|
552
|
+
const paramsResponse = await deps.fetch(toStaticParamsUrl(url), { signal });
|
|
553
|
+
if (paramsResponse.ok) {
|
|
554
|
+
fallbackParams = (await paramsResponse.json()) as Record<string, string | string[]>;
|
|
555
|
+
}
|
|
556
|
+
} catch (e) {
|
|
557
|
+
if (e instanceof DOMException && e.name === 'AbortError') throw e;
|
|
558
|
+
// No params sidecar — non-dynamic route, params stay null
|
|
559
|
+
}
|
|
447
560
|
}
|
|
448
561
|
return {
|
|
449
562
|
payload: await response.text(),
|
|
450
563
|
decodePromise: null,
|
|
451
564
|
segmentInfo: extractSegmentInfo(response),
|
|
452
|
-
params:
|
|
565
|
+
params: fallbackParams,
|
|
453
566
|
skippedSegments: extractSkippedSegments(response),
|
|
454
567
|
};
|
|
455
568
|
}
|
package/src/index.ts
CHANGED
|
@@ -557,13 +557,13 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
557
557
|
timberRouting(ctx),
|
|
558
558
|
timberEntries(ctx),
|
|
559
559
|
timberBuildManifest(ctx),
|
|
560
|
-
timberStaticBuild(ctx),
|
|
561
560
|
timberFonts(ctx),
|
|
562
561
|
timberMdx(ctx),
|
|
563
562
|
timberRequestDep(ctx),
|
|
564
563
|
timberCacheTransform(ctx),
|
|
565
564
|
timberPrerenderSugar(ctx), // export const prerender → cache.component() desugaring
|
|
566
565
|
timberPrebuilt(ctx), // cache.component() → __prebuilt() callsite rewrite
|
|
566
|
+
timberStaticBuild(ctx), // Static mode: validate + post-build HTML generation
|
|
567
567
|
timberContent(ctx),
|
|
568
568
|
timberServerBundle(), // Bundle all deps in server environments for prod
|
|
569
569
|
timberCloudflareWasm(ctx), // Externalize WASM for Cloudflare Workers
|
package/src/plugin-context.ts
CHANGED
|
@@ -160,6 +160,12 @@ export interface PluginContext {
|
|
|
160
160
|
* ranges and rewrite CODE via MagicString; the AST itself is never mutated.
|
|
161
161
|
*/
|
|
162
162
|
parseCached?: ParseMemo;
|
|
163
|
+
/**
|
|
164
|
+
* Content-Type mappings for extensionless static API route outputs.
|
|
165
|
+
* Populated during static generation, consumed by adapter-build to
|
|
166
|
+
* write per-path Content-Type rules in _headers. See TIM-1241.
|
|
167
|
+
*/
|
|
168
|
+
staticContentTypes?: Record<string, string>;
|
|
163
169
|
}
|
|
164
170
|
|
|
165
171
|
// ── AST parse memo ───────────────────────────────────────────────────────
|
|
@@ -88,6 +88,11 @@ export function timberAdapterBuild(ctx: PluginContext): Plugin {
|
|
|
88
88
|
output: ctx.config.output ?? 'server',
|
|
89
89
|
clientJavascriptDisabled: ctx.clientJavascript.disabled,
|
|
90
90
|
manifestInit,
|
|
91
|
+
staticPagesDir:
|
|
92
|
+
(ctx.config.output ?? 'server') === 'static'
|
|
93
|
+
? join(buildDir, 'static-pages')
|
|
94
|
+
: undefined,
|
|
95
|
+
staticContentTypes: ctx.staticContentTypes,
|
|
91
96
|
};
|
|
92
97
|
|
|
93
98
|
await adapter.buildOutput(adapterConfig, buildDir);
|
|
@@ -48,25 +48,19 @@ const ROUTE_TYPE_ICONS: Record<RouteType, string> = {
|
|
|
48
48
|
/**
|
|
49
49
|
* Classify a route by its segment chain and output mode.
|
|
50
50
|
*
|
|
51
|
-
* In server mode (default), all pages are dynamic (rendered per-request)
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* In server mode (default), all pages are dynamic (rendered per-request)
|
|
52
|
+
* and API routes (route.ts) are classified as function.
|
|
53
|
+
* In static mode, everything is pre-rendered at build time — all routes
|
|
54
|
+
* are static regardless of dynamic segments or route.ts presence.
|
|
54
55
|
*/
|
|
55
56
|
export function classifyRoute(
|
|
56
57
|
segments: SegmentNode[],
|
|
57
58
|
outputMode: 'server' | 'static' = 'server'
|
|
58
59
|
): RouteType {
|
|
60
|
+
if (outputMode === 'static') return 'static';
|
|
59
61
|
const leaf = segments[segments.length - 1];
|
|
60
62
|
if (leaf?.route) return 'function';
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const isDynamic = segments.some(
|
|
64
|
-
(s) =>
|
|
65
|
-
s.segmentType === 'dynamic' ||
|
|
66
|
-
s.segmentType === 'catch-all' ||
|
|
67
|
-
s.segmentType === 'optional-catch-all'
|
|
68
|
-
);
|
|
69
|
-
return isDynamic ? 'dynamic' : 'static';
|
|
63
|
+
return 'dynamic';
|
|
70
64
|
}
|
|
71
65
|
|
|
72
66
|
// ─── Size helpers ─────────────────────────────────────────────────────────
|
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
*
|
|
4
4
|
* When `output: 'static'` is set in timber.config.ts, this plugin:
|
|
5
5
|
* 1. Validates that no dynamic APIs (getCookies(), getHeaders()) are used
|
|
6
|
-
* 2.
|
|
6
|
+
* 2. After the build, renders all routes to static HTML + RSC flight files
|
|
7
7
|
*
|
|
8
|
-
* Design doc: design/
|
|
8
|
+
* Design doc: design/11-platform.md §"static output mode"
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { Plugin } from 'vite';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
|
14
|
+
import { dirname, extname, join, resolve, relative } from 'node:path';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
12
16
|
import type { PluginContext } from '../plugin-context.js';
|
|
17
|
+
import type {
|
|
18
|
+
StaticGenerationOptions,
|
|
19
|
+
StaticGenerationSummary,
|
|
20
|
+
StaticPageEntry,
|
|
21
|
+
StaticRedirectEntry,
|
|
22
|
+
} from '../server/static-generator.js';
|
|
23
|
+
import { mergeRscAssetsManifest } from './adapter-build.js';
|
|
13
24
|
|
|
14
25
|
// ---------------------------------------------------------------------------
|
|
15
26
|
// Types
|
|
@@ -74,6 +85,237 @@ export function detectDynamicApis(code: string, fileId: string): StaticValidatio
|
|
|
74
85
|
return errors;
|
|
75
86
|
}
|
|
76
87
|
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Redirect rules
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generate a `_redirects` file from collected redirect entries.
|
|
94
|
+
* Uses the Cloudflare Pages / Netlify format: `/from /to statusCode`
|
|
95
|
+
*
|
|
96
|
+
* @internal Exported for testing.
|
|
97
|
+
*/
|
|
98
|
+
export function generateRedirectsFile(redirects: StaticRedirectEntry[]): string {
|
|
99
|
+
const lines = redirects.map((r) => `${r.from} ${r.to} ${r.status}`);
|
|
100
|
+
return lines.join('\n') + '\n';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// File output helpers
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** Write a file atomically: temp sibling + rename. */
|
|
108
|
+
async function writeFileAtomic(path: string, data: Uint8Array | string): Promise<void> {
|
|
109
|
+
const tmp = `${path}.tmp`;
|
|
110
|
+
await writeFile(tmp, data);
|
|
111
|
+
await rename(tmp, path);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Determine the output file path for a static page entry.
|
|
116
|
+
*
|
|
117
|
+
* Pages: /about → about/index.html, / → index.html
|
|
118
|
+
* RSC: /about → _rsc/about.rsc, / → _rsc/index.rsc
|
|
119
|
+
* API: /feed.xml → feed.xml, /api/data → api/data
|
|
120
|
+
*/
|
|
121
|
+
export function staticOutputPath(entry: StaticPageEntry): string {
|
|
122
|
+
const urlPath = entry.urlPath === '/' ? '' : entry.urlPath;
|
|
123
|
+
|
|
124
|
+
if (entry.kind === 'rsc') {
|
|
125
|
+
const rscPath = urlPath || '/index';
|
|
126
|
+
return `_rsc${rscPath}.rsc`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (entry.kind === 'api') {
|
|
130
|
+
// API routes use their URL path directly. If no extension, fall
|
|
131
|
+
// back to the path as-is (static host serves based on content type).
|
|
132
|
+
return urlPath.startsWith('/') ? urlPath.slice(1) : urlPath || 'index';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// HTML pages
|
|
136
|
+
if (!urlPath) return 'index.html';
|
|
137
|
+
// Synthetic 404 entry from static generation — must be 404.html at the
|
|
138
|
+
// root so static hosts (GitHub Pages, Cloudflare Pages, Netlify) serve
|
|
139
|
+
// it automatically for unmatched URLs. Uses an internal urlPath to avoid
|
|
140
|
+
// colliding with a real /404 page route.
|
|
141
|
+
if (urlPath === '/_timber_not_found') return '404.html';
|
|
142
|
+
return `${urlPath.startsWith('/') ? urlPath.slice(1) : urlPath}/index.html`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Static generation orchestration (runs after Vite build)
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
interface StaticGenerateModule {
|
|
150
|
+
__timber_generateStaticSite?: (
|
|
151
|
+
options: StaticGenerationOptions
|
|
152
|
+
) => Promise<StaticGenerationSummary>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Load the built RSC entry and run the static generation pass.
|
|
157
|
+
*
|
|
158
|
+
* Unlike prebuilt capture, this needs the FULL pipeline handler, so
|
|
159
|
+
* __TIMBER_CAPTURE_MODE__ must be false.
|
|
160
|
+
*/
|
|
161
|
+
async function runStaticGeneration(ctx: PluginContext): Promise<StaticGenerationSummary> {
|
|
162
|
+
const entryPath = join(ctx.buildDir, 'rsc', 'index.js');
|
|
163
|
+
if (!existsSync(entryPath)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`[timber] Static generation: built RSC entry not found at ${entryPath} — ` +
|
|
166
|
+
`the build output is missing`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const g = globalThis as Record<string, unknown>;
|
|
171
|
+
|
|
172
|
+
// Set up the build manifest (same as adapter-build and prebuilt-capture).
|
|
173
|
+
if (ctx.buildManifest) g.__TIMBER_BUILD_MANIFEST__ ??= ctx.buildManifest;
|
|
174
|
+
if (ctx.deploymentId) g.__TIMBER_DEPLOYMENT_ID__ ??= ctx.deploymentId;
|
|
175
|
+
|
|
176
|
+
// Ensure capture mode is off — we need the full handler.
|
|
177
|
+
g.__TIMBER_CAPTURE_MODE__ = false;
|
|
178
|
+
|
|
179
|
+
// Merge per-route CSS and modulepreload entries from the RSC assets
|
|
180
|
+
// manifest into the build manifest BEFORE rendering, so rendered HTML
|
|
181
|
+
// includes all <link> tags. This same merge normally runs in
|
|
182
|
+
// adapter-build, but static generation runs first.
|
|
183
|
+
if (ctx.buildManifest) {
|
|
184
|
+
await mergeRscAssetsManifest(ctx.buildDir, ctx.root, ctx.buildManifest, ctx.routeClientRefKeys);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
console.log('[timber] Static generation: building site…');
|
|
188
|
+
|
|
189
|
+
let mod: StaticGenerateModule;
|
|
190
|
+
try {
|
|
191
|
+
const entryUrl = `${pathToFileURL(entryPath).href}?t=${Date.now()}`;
|
|
192
|
+
mod = (await import(/* @vite-ignore */ entryUrl)) as StaticGenerateModule;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`[timber] Static generation: failed to load the built RSC entry (${entryPath}). ` +
|
|
196
|
+
`The full request handler must initialize for static rendering.`,
|
|
197
|
+
{ cause: error }
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const generate = mod.__timber_generateStaticSite;
|
|
202
|
+
if (typeof generate !== 'function') {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`[timber] Static generation: built RSC entry does not export ` +
|
|
205
|
+
`__timber_generateStaticSite — rebuild from a clean build directory`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const staticDir = join(ctx.buildDir, 'static-pages');
|
|
210
|
+
await mkdir(staticDir, { recursive: true });
|
|
211
|
+
|
|
212
|
+
const sitemapConfig = ctx.config.sitemap;
|
|
213
|
+
const autoSitemapEnabled = Boolean(sitemapConfig?.enabled && sitemapConfig?.baseUrl);
|
|
214
|
+
|
|
215
|
+
const contentTypes: Record<string, string> = {};
|
|
216
|
+
|
|
217
|
+
const summary = await generate({
|
|
218
|
+
autoSitemapEnabled,
|
|
219
|
+
renderTimeoutMs: ctx.config.renderTimeoutMs,
|
|
220
|
+
onEntry: async (entry: StaticPageEntry) => {
|
|
221
|
+
const relPath = staticOutputPath(entry);
|
|
222
|
+
const fullPath = resolve(staticDir, relPath);
|
|
223
|
+
|
|
224
|
+
// Reject path traversal: generated params with "../" could escape
|
|
225
|
+
// the output directory and overwrite build artifacts.
|
|
226
|
+
const rel = relative(staticDir, fullPath);
|
|
227
|
+
if (rel.startsWith('..') || resolve(staticDir, rel) !== fullPath) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`[timber] Static generation: output path "${relPath}" escapes the ` +
|
|
230
|
+
`static output directory — check generateStaticSegmentParams for ` +
|
|
231
|
+
`path traversal characters in ${entry.urlPath}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
236
|
+
await writeFileAtomic(fullPath, entry.body);
|
|
237
|
+
|
|
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.
|
|
241
|
+
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']);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Track Content-Type for extensionless API routes so adapters
|
|
247
|
+
// can write per-path Content-Type rules in _headers. TIM-1241.
|
|
248
|
+
if (entry.kind === 'api' && !extname(relPath) && entry.contentType) {
|
|
249
|
+
contentTypes[entry.urlPath] = entry.contentType;
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const totalUrls = summary.rendered + summary.errors.length;
|
|
255
|
+
console.log(
|
|
256
|
+
`[timber] Static generation: rendered ${summary.rendered}/${totalUrls} entries.` +
|
|
257
|
+
(summary.errors.length > 0 ? ` ${summary.errors.length} failed.` : '') +
|
|
258
|
+
(summary.redirects.length > 0 ? ` ${summary.redirects.length} redirect(s).` : '')
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (summary.errors.length > 0) {
|
|
262
|
+
const details = summary.errors.map((e) => ` - ${e.urlPath}: ${e.message}`).join('\n');
|
|
263
|
+
throw new Error(`[timber] Static generation failed:\n${details}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Store content-type mappings on the plugin context so adapters
|
|
267
|
+
// can include them in the _headers file. TIM-1241.
|
|
268
|
+
if (Object.keys(contentTypes).length > 0) {
|
|
269
|
+
ctx.staticContentTypes = contentTypes;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Write _redirects file for platform redirect rules (Cloudflare Pages,
|
|
273
|
+
// Netlify, etc.). Format: /from /to status. See TIM-1245.
|
|
274
|
+
if (summary.redirects.length > 0) {
|
|
275
|
+
const generated = generateRedirectsFile(summary.redirects);
|
|
276
|
+
const redirectsPath = join(staticDir, '_redirects');
|
|
277
|
+
|
|
278
|
+
// Merge with user-authored _redirects if present. TIM-1249.
|
|
279
|
+
const userRedirects = await readUserRedirects(ctx.root);
|
|
280
|
+
if (userRedirects !== null) {
|
|
281
|
+
await writeFileAtomic(redirectsPath, generated + '\n' + userRedirects);
|
|
282
|
+
console.log(
|
|
283
|
+
`[timber] Static generation: wrote _redirects with ${summary.redirects.length} ` +
|
|
284
|
+
`generated rule(s), merged with user-authored _redirects`
|
|
285
|
+
);
|
|
286
|
+
} else {
|
|
287
|
+
await writeFileAtomic(redirectsPath, generated);
|
|
288
|
+
console.log(
|
|
289
|
+
`[timber] Static generation: wrote _redirects with ${summary.redirects.length} rule(s)`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return summary;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// User-authored _redirects merge (TIM-1249)
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Read a user-authored `_redirects` file from the project's `public/`
|
|
303
|
+
* directory. Returns `null` if no file exists.
|
|
304
|
+
*
|
|
305
|
+
* @internal Exported for testing.
|
|
306
|
+
*/
|
|
307
|
+
export async function readUserRedirects(root: string): Promise<string | null> {
|
|
308
|
+
const candidates = [join(root, 'public', '_redirects')];
|
|
309
|
+
for (const path of candidates) {
|
|
310
|
+
try {
|
|
311
|
+
return await readFile(path, 'utf-8');
|
|
312
|
+
} catch {
|
|
313
|
+
// File doesn't exist — try next
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
77
319
|
// ---------------------------------------------------------------------------
|
|
78
320
|
// Vite Plugin
|
|
79
321
|
// ---------------------------------------------------------------------------
|
|
@@ -85,6 +327,7 @@ export function detectDynamicApis(code: string, fileId: string): StaticValidatio
|
|
|
85
327
|
*
|
|
86
328
|
* Hooks:
|
|
87
329
|
* - transform: Validates source files for static mode violations
|
|
330
|
+
* - buildApp (post): Renders all routes to static HTML after the build
|
|
88
331
|
*/
|
|
89
332
|
export function timberStaticBuild(ctx: PluginContext): Plugin {
|
|
90
333
|
return {
|
|
@@ -128,5 +371,24 @@ export function timberStaticBuild(ctx: PluginContext): Plugin {
|
|
|
128
371
|
|
|
129
372
|
return null;
|
|
130
373
|
},
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* After all environments are built, render every route to static HTML.
|
|
377
|
+
*
|
|
378
|
+
* Runs with order: 'post' so the RSC plugin's buildApp completes first,
|
|
379
|
+
* producing the built entry we import. Must run BEFORE the adapter
|
|
380
|
+
* build copies output to its platform-specific directory.
|
|
381
|
+
*/
|
|
382
|
+
buildApp: {
|
|
383
|
+
order: 'post' as const,
|
|
384
|
+
async handler() {
|
|
385
|
+
if (ctx.dev) return;
|
|
386
|
+
|
|
387
|
+
const isStatic = ctx.config.output === 'static';
|
|
388
|
+
if (!isStatic) return;
|
|
389
|
+
|
|
390
|
+
await runStaticGeneration(ctx);
|
|
391
|
+
},
|
|
392
|
+
},
|
|
131
393
|
};
|
|
132
394
|
}
|
|
@@ -69,6 +69,7 @@ import { capturePrebuiltPayloads } from '../prebuilt-builder.js';
|
|
|
69
69
|
import { setPrebuiltPayloadSource } from '../prebuilt/payload-source.js';
|
|
70
70
|
import { setRscFunctions, setComponentRefreshTimeout } from '../prebuilt-runtime.js';
|
|
71
71
|
import { createAutoSitemapHandler } from '../sitemap-handler.js';
|
|
72
|
+
import { generateStaticSite } from '../static-generator.js';
|
|
72
73
|
|
|
73
74
|
/**
|
|
74
75
|
* Resolve the Server-Timing mode from timber.config.ts.
|
|
@@ -461,6 +462,29 @@ export function __timber_capturePrebuilt(
|
|
|
461
462
|
return capturePrebuiltPayloads(routeManifest, options);
|
|
462
463
|
}
|
|
463
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Post-build static site generation entry point (TIM-1238). Renders every
|
|
467
|
+
* route in the manifest to HTML + RSC flight payloads. Must run inside the
|
|
468
|
+
* built RSC bundle — the handler, route manifest, and page module loaders
|
|
469
|
+
* are module instances in THIS bundle's graph.
|
|
470
|
+
*
|
|
471
|
+
* Unlike capture mode, this needs the full pipeline handler, so it must
|
|
472
|
+
* NOT run when __TIMBER_CAPTURE_MODE__ is set.
|
|
473
|
+
*
|
|
474
|
+
* See design/11-platform.md §"static output mode".
|
|
475
|
+
*/
|
|
476
|
+
export function __timber_generateStaticSite(
|
|
477
|
+
options: import('../static-generator.js').StaticGenerationOptions
|
|
478
|
+
): Promise<import('../static-generator.js').StaticGenerationSummary> {
|
|
479
|
+
if (!_handler) {
|
|
480
|
+
throw new Error(
|
|
481
|
+
'[timber] __timber_generateStaticSite requires the full request handler. ' +
|
|
482
|
+
'Do not call in capture mode (__TIMBER_CAPTURE_MODE__).'
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return generateStaticSite(routeManifest, _handler, options);
|
|
486
|
+
}
|
|
487
|
+
|
|
464
488
|
// Schema codecs are needed by both the capture path (param coercion) and
|
|
465
489
|
// the request handler, so register them unconditionally.
|
|
466
490
|
setGlobalSchemaCodecs(globalSchemaCodecs);
|
|
@@ -339,7 +339,7 @@ export async function generateSitemap(
|
|
|
339
339
|
* When a user-authored sitemap exists at the root, auto-generation is disabled
|
|
340
340
|
* to avoid conflicts — the user takes full control.
|
|
341
341
|
*/
|
|
342
|
-
export function hasUserSitemap(root: SegmentNode): boolean {
|
|
342
|
+
export function hasUserSitemap(root: SegmentNode<unknown>): boolean {
|
|
343
343
|
if (!root.metadataRoutes) return false;
|
|
344
344
|
return 'sitemap' in root.metadataRoutes;
|
|
345
345
|
}
|