@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
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static site generator — enumerates routes and renders them to
|
|
3
|
+
* HTML + RSC flight payloads for `output: 'static'` mode.
|
|
4
|
+
*
|
|
5
|
+
* Runs INSIDE the built RSC bundle (same pattern as prebuilt-builder.ts)
|
|
6
|
+
* so it has access to the route manifest, page module loaders, and the
|
|
7
|
+
* request pipeline handler.
|
|
8
|
+
*
|
|
9
|
+
* For each page route:
|
|
10
|
+
* - Renders HTML (Accept: text/html) for initial load
|
|
11
|
+
* - Renders RSC flight (Accept: text/x-component) for client SPA nav
|
|
12
|
+
*
|
|
13
|
+
* For each API route (route.ts):
|
|
14
|
+
* - Calls the GET handler and captures the response
|
|
15
|
+
*
|
|
16
|
+
* The caller (plugins/static-build.ts) handles file I/O — keeping
|
|
17
|
+
* node:fs out of the server bundle so it runs on Cloudflare Workers.
|
|
18
|
+
*
|
|
19
|
+
* Design docs: design/11-platform.md §"static output mode"
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ManifestRoot, ManifestSegmentNode, ManifestFile } from './route-matcher.js';
|
|
23
|
+
import { hasDynamicSegments, hasUserSitemap, resolvePathWithParams } from './sitemap-generator.js';
|
|
24
|
+
import { loadModule } from './safe-load.js';
|
|
25
|
+
import { METADATA_ROUTE_CONVENTIONS, resolveServePathForFile } from './metadata-routes.js';
|
|
26
|
+
|
|
27
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** A single rendered static page ready for the caller to write to disk. */
|
|
30
|
+
export interface StaticPageEntry {
|
|
31
|
+
/** The URL path (e.g., "/about", "/blog/first-post"). */
|
|
32
|
+
urlPath: string;
|
|
33
|
+
/** HTTP status code from the response. */
|
|
34
|
+
status: number;
|
|
35
|
+
/** Response headers. */
|
|
36
|
+
headers: Record<string, string>;
|
|
37
|
+
/** The response body as bytes. */
|
|
38
|
+
body: Uint8Array;
|
|
39
|
+
/** Content type for choosing file extension. */
|
|
40
|
+
contentType: string;
|
|
41
|
+
/**
|
|
42
|
+
* Whether this is an RSC flight payload (for client navigation)
|
|
43
|
+
* vs an HTML page (for initial load).
|
|
44
|
+
*/
|
|
45
|
+
kind: 'html' | 'rsc' | 'api';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An error encountered during static generation. */
|
|
49
|
+
export interface StaticGenerationError {
|
|
50
|
+
urlPath: string;
|
|
51
|
+
message: string;
|
|
52
|
+
status?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A redirect discovered during static generation. */
|
|
56
|
+
export interface StaticRedirectEntry {
|
|
57
|
+
/** The source URL path (e.g., "/old-page"). */
|
|
58
|
+
from: string;
|
|
59
|
+
/** The redirect target (Location header value). */
|
|
60
|
+
to: string;
|
|
61
|
+
/** HTTP status code (301, 302, 307, 308). */
|
|
62
|
+
status: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Result of the static generation pass. */
|
|
66
|
+
export interface StaticGenerationSummary {
|
|
67
|
+
/** Total pages rendered (HTML + RSC pairs count as 2). */
|
|
68
|
+
rendered: number;
|
|
69
|
+
/** Pages that failed to render. */
|
|
70
|
+
errors: StaticGenerationError[];
|
|
71
|
+
/** Redirects from middleware/proxy that need platform redirect rules. */
|
|
72
|
+
redirects: StaticRedirectEntry[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface StaticGenerationOptions {
|
|
76
|
+
/** Called for each rendered page/asset. File I/O is the caller's job. */
|
|
77
|
+
onEntry: (entry: StaticPageEntry) => Promise<void>;
|
|
78
|
+
/** Progress/warning logger. */
|
|
79
|
+
log?: (message: string) => void;
|
|
80
|
+
/** Timeout per page render in milliseconds. Default: 30000. */
|
|
81
|
+
renderTimeoutMs?: number;
|
|
82
|
+
/**
|
|
83
|
+
* When true and no user-authored sitemap exists, the generator will
|
|
84
|
+
* render `/sitemap.xml` (and any paginated sub-sitemaps) through the
|
|
85
|
+
* handler to capture auto-generated sitemaps in the static output.
|
|
86
|
+
*/
|
|
87
|
+
autoSitemapEnabled?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Route enumeration ───────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
interface StaticUrl {
|
|
93
|
+
urlPath: string;
|
|
94
|
+
kind: 'page' | 'api';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Walk the manifest tree and collect all concrete URLs to render.
|
|
99
|
+
*
|
|
100
|
+
* Static routes produce their URL directly. Dynamic routes call
|
|
101
|
+
* `generateStaticSegmentParams()` from the page module. Dynamic routes
|
|
102
|
+
* without the export are a build error in static mode.
|
|
103
|
+
*/
|
|
104
|
+
export async function enumerateStaticUrls(
|
|
105
|
+
root: ManifestSegmentNode,
|
|
106
|
+
log: (message: string) => void,
|
|
107
|
+
timeoutMs: number = DEFAULT_RENDER_TIMEOUT_MS
|
|
108
|
+
): Promise<{ urls: StaticUrl[]; errors: StaticGenerationError[] }> {
|
|
109
|
+
const urls: StaticUrl[] = [];
|
|
110
|
+
const errors: StaticGenerationError[] = [];
|
|
111
|
+
|
|
112
|
+
async function walk(node: ManifestSegmentNode): Promise<void> {
|
|
113
|
+
if (node.segmentType === 'intercepting' || node.segmentType === 'private') return;
|
|
114
|
+
|
|
115
|
+
// Page routes
|
|
116
|
+
if (node.page) {
|
|
117
|
+
const isDynamic = hasDynamicSegments(node.urlPath);
|
|
118
|
+
if (isDynamic) {
|
|
119
|
+
const expanded = await expandDynamicRoute(node.urlPath, node.page, log, timeoutMs);
|
|
120
|
+
if (expanded === null) {
|
|
121
|
+
errors.push({
|
|
122
|
+
urlPath: node.urlPath,
|
|
123
|
+
message:
|
|
124
|
+
`Dynamic route ${node.urlPath} must export generateStaticSegmentParams ` +
|
|
125
|
+
`in static mode — there is no server to render it at request time.`,
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
for (const path of expanded) {
|
|
129
|
+
urls.push({ urlPath: path, kind: 'page' });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
urls.push({ urlPath: node.urlPath || '/', kind: 'page' });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// API routes (route.ts)
|
|
138
|
+
if (node.route) {
|
|
139
|
+
// Validate that the route exports GET — static mode only sends GET
|
|
140
|
+
// requests, so POST/PUT/DELETE-only routes would return 405.
|
|
141
|
+
const hasGet = await routeExportsGet(node.route, timeoutMs);
|
|
142
|
+
if (!hasGet) {
|
|
143
|
+
errors.push({
|
|
144
|
+
urlPath: node.urlPath || '/',
|
|
145
|
+
message:
|
|
146
|
+
`API route ${node.urlPath || '/'} does not export a GET handler. ` +
|
|
147
|
+
`Static mode can only render GET routes — add a GET export or ` +
|
|
148
|
+
`remove this route from the static build. If this route relies ` +
|
|
149
|
+
`on middleware redirects, use a _redirects file instead.`,
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
const isDynamic = hasDynamicSegments(node.urlPath);
|
|
153
|
+
if (isDynamic) {
|
|
154
|
+
const expanded = await expandDynamicApiRoute(node.urlPath, node.route, log, timeoutMs);
|
|
155
|
+
if (expanded === null) {
|
|
156
|
+
errors.push({
|
|
157
|
+
urlPath: node.urlPath,
|
|
158
|
+
message:
|
|
159
|
+
`Dynamic API route ${node.urlPath} must export generateStaticSegmentParams ` +
|
|
160
|
+
`in static mode.`,
|
|
161
|
+
});
|
|
162
|
+
} else {
|
|
163
|
+
for (const path of expanded) {
|
|
164
|
+
urls.push({ urlPath: path, kind: 'api' });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
urls.push({ urlPath: node.urlPath || '/', kind: 'api' });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Metadata routes (robots.ts, sitemap.ts, icon.png, etc.)
|
|
174
|
+
if (node.metadataRoutes) {
|
|
175
|
+
for (const [baseName, file] of Object.entries(node.metadataRoutes)) {
|
|
176
|
+
const convention = METADATA_ROUTE_CONVENTIONS[baseName];
|
|
177
|
+
if (!convention) continue;
|
|
178
|
+
if (!convention.nestable && node.urlPath !== '/') continue;
|
|
179
|
+
const servePath = resolveServePathForFile(baseName, file.filePath);
|
|
180
|
+
const segmentPrefix = node.urlPath === '/' ? '' : node.urlPath;
|
|
181
|
+
|
|
182
|
+
if (hasDynamicSegments(node.urlPath)) {
|
|
183
|
+
// Metadata route under a dynamic segment — expand using params
|
|
184
|
+
// from the page, route, or the metadata file itself (metadata-only
|
|
185
|
+
// dynamic segments are valid per design/16-metadata.md).
|
|
186
|
+
const paramSource = node.page ?? node.route ?? file;
|
|
187
|
+
const expanded = await expandDynamicRoute(node.urlPath, paramSource, log, timeoutMs);
|
|
188
|
+
if (expanded === null) {
|
|
189
|
+
errors.push({
|
|
190
|
+
urlPath: `${segmentPrefix}/${servePath}`,
|
|
191
|
+
message:
|
|
192
|
+
`Metadata route ${baseName} in dynamic segment ${node.urlPath} ` +
|
|
193
|
+
`requires generateStaticSegmentParams in static mode.`,
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
for (const expandedPath of expanded) {
|
|
197
|
+
const prefix = expandedPath === '/' ? '' : expandedPath;
|
|
198
|
+
urls.push({ urlPath: `${prefix}/${servePath}`, kind: 'api' });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
urls.push({ urlPath: `${segmentPrefix}/${servePath}`, kind: 'api' });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const child of node.children) {
|
|
208
|
+
await walk(child);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await walk(root);
|
|
213
|
+
urls.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
|
|
214
|
+
return { urls, errors };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Load a page module and call generateStaticSegmentParams to get
|
|
219
|
+
* all param combos for a dynamic route. Returns null if the export
|
|
220
|
+
* is missing (build error in static mode).
|
|
221
|
+
*/
|
|
222
|
+
async function expandDynamicRoute(
|
|
223
|
+
urlPath: string,
|
|
224
|
+
page: ManifestFile,
|
|
225
|
+
log: (message: string) => void,
|
|
226
|
+
timeoutMs: number = DEFAULT_RENDER_TIMEOUT_MS
|
|
227
|
+
): Promise<string[] | null> {
|
|
228
|
+
const mod = await loadModuleWithTimeout(page, timeoutMs);
|
|
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
|
+
|
|
239
|
+
if (typeof generateFn !== 'function') return null;
|
|
240
|
+
|
|
241
|
+
const combos = (await (generateFn as () => Promise<unknown>)()) as Record<
|
|
242
|
+
string,
|
|
243
|
+
string | string[]
|
|
244
|
+
>[];
|
|
245
|
+
if (!Array.isArray(combos)) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`generateStaticSegmentParams for ${urlPath} must return an array, got ${typeof combos}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const paths: string[] = [];
|
|
252
|
+
for (const combo of combos) {
|
|
253
|
+
const resolved = resolvePathWithParams(urlPath, combo);
|
|
254
|
+
if (resolved !== null) paths.push(resolved);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (paths.length === 0) {
|
|
258
|
+
log(
|
|
259
|
+
`[timber] Static generation: generateStaticSegmentParams for ${urlPath} ` +
|
|
260
|
+
`returned an empty array — this route will have no pages in the static output`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return paths;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Check whether a route module exports a GET handler.
|
|
269
|
+
* Static mode sends GET requests — routes without GET return 405.
|
|
270
|
+
*/
|
|
271
|
+
async function routeExportsGet(
|
|
272
|
+
route: ManifestFile,
|
|
273
|
+
timeoutMs: number = DEFAULT_RENDER_TIMEOUT_MS
|
|
274
|
+
): Promise<boolean> {
|
|
275
|
+
const mod = await loadModuleWithTimeout(route, timeoutMs);
|
|
276
|
+
return typeof mod.GET === 'function';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function expandDynamicApiRoute(
|
|
280
|
+
urlPath: string,
|
|
281
|
+
route: ManifestFile,
|
|
282
|
+
log: (message: string) => void,
|
|
283
|
+
timeoutMs: number = DEFAULT_RENDER_TIMEOUT_MS
|
|
284
|
+
): Promise<string[] | null> {
|
|
285
|
+
const mod = await loadModuleWithTimeout(route, timeoutMs);
|
|
286
|
+
|
|
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
|
+
|
|
296
|
+
if (typeof generateFn !== 'function') return null;
|
|
297
|
+
|
|
298
|
+
const combos = (await (generateFn as () => Promise<unknown>)()) as Record<
|
|
299
|
+
string,
|
|
300
|
+
string | string[]
|
|
301
|
+
>[];
|
|
302
|
+
if (!Array.isArray(combos)) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`generateStaticSegmentParams for ${urlPath} must return an array, got ${typeof combos}`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const paths: string[] = [];
|
|
309
|
+
for (const combo of combos) {
|
|
310
|
+
const resolved = resolvePathWithParams(urlPath, combo);
|
|
311
|
+
if (resolved !== null) paths.push(resolved);
|
|
312
|
+
}
|
|
313
|
+
return paths;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Redirect normalization ─────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Normalize a Location header value for _redirects output.
|
|
320
|
+
* Strips the synthetic http://localhost origin from same-origin
|
|
321
|
+
* redirects so _redirects contains pathname-only targets.
|
|
322
|
+
*/
|
|
323
|
+
function normalizeRedirectTarget(rawLocation: string): string {
|
|
324
|
+
try {
|
|
325
|
+
const parsed = new URL(rawLocation);
|
|
326
|
+
if (parsed.hostname === 'localhost') {
|
|
327
|
+
return parsed.pathname + parsed.search + parsed.hash;
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// Relative URL — use as-is
|
|
331
|
+
}
|
|
332
|
+
return rawLocation;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ─── Rendering ───────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
const DEFAULT_RENDER_TIMEOUT_MS = 30_000;
|
|
338
|
+
|
|
339
|
+
async function loadModuleWithTimeout(
|
|
340
|
+
file: ManifestFile,
|
|
341
|
+
timeoutMs: number
|
|
342
|
+
): Promise<Record<string, unknown>> {
|
|
343
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
344
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
345
|
+
timer = setTimeout(() => {
|
|
346
|
+
reject(new Error(`module load timed out after ${timeoutMs}ms for ${file.filePath}`));
|
|
347
|
+
}, timeoutMs);
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
return (await Promise.race([loadModule(file), deadline])) as Record<string, unknown>;
|
|
351
|
+
} finally {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function drainResponse(response: Response, timeoutMs: number): Promise<Uint8Array> {
|
|
357
|
+
if (!response.body) return new Uint8Array(0);
|
|
358
|
+
|
|
359
|
+
const reader = response.body.getReader();
|
|
360
|
+
const chunks: Uint8Array[] = [];
|
|
361
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
362
|
+
let timedOut = false;
|
|
363
|
+
|
|
364
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
365
|
+
timer = setTimeout(() => {
|
|
366
|
+
timedOut = true;
|
|
367
|
+
reject(new Error(`response did not complete within ${timeoutMs}ms`));
|
|
368
|
+
}, timeoutMs);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
for (;;) {
|
|
373
|
+
const { done, value } = await Promise.race([reader.read(), deadline]);
|
|
374
|
+
if (done) break;
|
|
375
|
+
chunks.push(value as Uint8Array);
|
|
376
|
+
}
|
|
377
|
+
} finally {
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
// Always cancel — design/13-security.md A3: never leave a reader open.
|
|
380
|
+
await reader.cancel().catch(() => {});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let total = 0;
|
|
384
|
+
for (const c of chunks) total += c.byteLength;
|
|
385
|
+
const out = new Uint8Array(total);
|
|
386
|
+
let off = 0;
|
|
387
|
+
for (const c of chunks) {
|
|
388
|
+
out.set(c, off);
|
|
389
|
+
off += c.byteLength;
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function headersToRecord(headers: Headers): Record<string, string> {
|
|
395
|
+
const record: Record<string, string> = {};
|
|
396
|
+
headers.forEach((value, key) => {
|
|
397
|
+
record[key] = value;
|
|
398
|
+
});
|
|
399
|
+
return record;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ─── Static generation orchestration ─────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Generate all static pages from the route manifest.
|
|
406
|
+
*
|
|
407
|
+
* For each page: renders HTML (for initial load) and RSC flight (for SPA nav).
|
|
408
|
+
* For each API route: calls the GET handler.
|
|
409
|
+
*
|
|
410
|
+
* Must run inside the built RSC bundle — the handler and manifest are
|
|
411
|
+
* module instances from this bundle's graph.
|
|
412
|
+
*/
|
|
413
|
+
export async function generateStaticSite(
|
|
414
|
+
manifest: ManifestRoot,
|
|
415
|
+
handler: (req: Request) => Promise<Response>,
|
|
416
|
+
options: StaticGenerationOptions
|
|
417
|
+
): Promise<StaticGenerationSummary> {
|
|
418
|
+
const log = options.log ?? ((msg: string) => console.log(msg));
|
|
419
|
+
const timeoutMs = options.renderTimeoutMs ?? DEFAULT_RENDER_TIMEOUT_MS;
|
|
420
|
+
const summary: StaticGenerationSummary = { rendered: 0, errors: [], redirects: [] };
|
|
421
|
+
|
|
422
|
+
log('[timber] Static generation: enumerating routes…');
|
|
423
|
+
|
|
424
|
+
const { urls, errors: enumErrors } = await enumerateStaticUrls(manifest.root, log, timeoutMs);
|
|
425
|
+
summary.errors.push(...enumErrors);
|
|
426
|
+
|
|
427
|
+
if (enumErrors.length > 0) {
|
|
428
|
+
const details = enumErrors.map((e) => ` - ${e.urlPath}: ${e.message}`).join('\n');
|
|
429
|
+
throw new Error(
|
|
430
|
+
`[timber] Static generation failed: ${enumErrors.length} route(s) cannot be rendered:\n${details}`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Auto-sitemap: if enabled and no user sitemap exists, render the
|
|
435
|
+
// auto-generated /sitemap.xml (and paginated sub-sitemaps) through
|
|
436
|
+
// the handler. Must come after enumeration so user-authored sitemaps
|
|
437
|
+
// are discovered first.
|
|
438
|
+
if (options.autoSitemapEnabled && !hasUserSitemap(manifest.root)) {
|
|
439
|
+
urls.push({ urlPath: '/sitemap.xml', kind: 'api' });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
log(`[timber] Static generation: rendering ${urls.length} routes…`);
|
|
443
|
+
|
|
444
|
+
// Track rendered API bodies so we can discover sub-sitemaps from the index.
|
|
445
|
+
const renderedEntries = new Map<string, Uint8Array>();
|
|
446
|
+
|
|
447
|
+
for (const { urlPath, kind } of urls) {
|
|
448
|
+
if (kind === 'page') {
|
|
449
|
+
// Render HTML for initial load
|
|
450
|
+
let htmlOk = false;
|
|
451
|
+
try {
|
|
452
|
+
const htmlResponse = await renderUrl(handler, urlPath, 'text/html', timeoutMs);
|
|
453
|
+
|
|
454
|
+
if (htmlResponse.status >= 300 && htmlResponse.status < 400) {
|
|
455
|
+
const rawLocation = htmlResponse.headers.get('location');
|
|
456
|
+
if (rawLocation) {
|
|
457
|
+
const location = normalizeRedirectTarget(rawLocation);
|
|
458
|
+
summary.redirects.push({ from: urlPath, to: location, status: htmlResponse.status });
|
|
459
|
+
log(`[timber] Static generation: ${urlPath} → ${location} (${htmlResponse.status})`);
|
|
460
|
+
} else {
|
|
461
|
+
summary.errors.push({
|
|
462
|
+
urlPath,
|
|
463
|
+
message: `HTML render returned ${htmlResponse.status} with no Location header`,
|
|
464
|
+
status: htmlResponse.status,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
} else if (htmlResponse.status >= 400) {
|
|
468
|
+
summary.errors.push({
|
|
469
|
+
urlPath,
|
|
470
|
+
message: `HTML render returned status ${htmlResponse.status}`,
|
|
471
|
+
status: htmlResponse.status,
|
|
472
|
+
});
|
|
473
|
+
} else {
|
|
474
|
+
await options.onEntry({
|
|
475
|
+
urlPath,
|
|
476
|
+
status: htmlResponse.status,
|
|
477
|
+
headers: headersToRecord(htmlResponse.headers),
|
|
478
|
+
body: htmlResponse.body,
|
|
479
|
+
contentType: htmlResponse.headers.get('content-type') ?? 'text/html',
|
|
480
|
+
kind: 'html',
|
|
481
|
+
});
|
|
482
|
+
summary.rendered++;
|
|
483
|
+
htmlOk = true;
|
|
484
|
+
}
|
|
485
|
+
} catch (error) {
|
|
486
|
+
summary.errors.push({
|
|
487
|
+
urlPath,
|
|
488
|
+
message: `HTML render failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Render RSC flight for client navigation — independent of HTML
|
|
493
|
+
// success so a transient write error doesn't orphan the flight file.
|
|
494
|
+
if (htmlOk) {
|
|
495
|
+
try {
|
|
496
|
+
const rscResponse = await renderUrl(handler, urlPath, 'text/x-component', timeoutMs);
|
|
497
|
+
|
|
498
|
+
if (rscResponse.status < 400) {
|
|
499
|
+
await options.onEntry({
|
|
500
|
+
urlPath,
|
|
501
|
+
status: rscResponse.status,
|
|
502
|
+
headers: headersToRecord(rscResponse.headers),
|
|
503
|
+
body: rscResponse.body,
|
|
504
|
+
contentType: rscResponse.headers.get('content-type') ?? 'text/x-component',
|
|
505
|
+
kind: 'rsc',
|
|
506
|
+
});
|
|
507
|
+
summary.rendered++;
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
// RSC flight failure is non-fatal — the page will work with full
|
|
511
|
+
// page loads instead of SPA navigation for this route.
|
|
512
|
+
log(
|
|
513
|
+
`[timber] Static generation: RSC flight for ${urlPath} failed, SPA nav will fallback`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
// API route — call the handler and capture the response
|
|
519
|
+
try {
|
|
520
|
+
const apiResponse = await renderUrl(handler, urlPath, '*/*', timeoutMs);
|
|
521
|
+
|
|
522
|
+
if (apiResponse.status >= 300 && apiResponse.status < 400) {
|
|
523
|
+
const rawLocation = apiResponse.headers.get('location');
|
|
524
|
+
if (rawLocation) {
|
|
525
|
+
const location = normalizeRedirectTarget(rawLocation);
|
|
526
|
+
summary.redirects.push({ from: urlPath, to: location, status: apiResponse.status });
|
|
527
|
+
log(`[timber] Static generation: ${urlPath} → ${location} (${apiResponse.status})`);
|
|
528
|
+
} else {
|
|
529
|
+
summary.errors.push({
|
|
530
|
+
urlPath,
|
|
531
|
+
message: `API route returned ${apiResponse.status} with no Location header`,
|
|
532
|
+
status: apiResponse.status,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (apiResponse.status >= 400) {
|
|
539
|
+
summary.errors.push({
|
|
540
|
+
urlPath,
|
|
541
|
+
message: `API route render returned status ${apiResponse.status}`,
|
|
542
|
+
status: apiResponse.status,
|
|
543
|
+
});
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const apiBody = apiResponse.body;
|
|
548
|
+
if (urlPath === '/sitemap.xml') renderedEntries.set(urlPath, apiBody);
|
|
549
|
+
await options.onEntry({
|
|
550
|
+
urlPath,
|
|
551
|
+
status: apiResponse.status,
|
|
552
|
+
headers: headersToRecord(apiResponse.headers),
|
|
553
|
+
body: apiBody,
|
|
554
|
+
contentType: apiResponse.headers.get('content-type') ?? 'application/octet-stream',
|
|
555
|
+
kind: 'api',
|
|
556
|
+
});
|
|
557
|
+
summary.rendered++;
|
|
558
|
+
} catch (error) {
|
|
559
|
+
summary.errors.push({
|
|
560
|
+
urlPath,
|
|
561
|
+
message: `API route failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Render 404.html — the app's not-found page for unmatched URLs.
|
|
568
|
+
// Static hosts (GitHub Pages, Cloudflare Pages, Netlify) serve this
|
|
569
|
+
// automatically when no matching file exists.
|
|
570
|
+
// Uses /_timber/__not_found as the probe path: _timber/ is the framework's
|
|
571
|
+
// reserved prefix and __not_found is a private segment (double underscore),
|
|
572
|
+
// so user routes (including catch-all [..slug]) cannot claim this path.
|
|
573
|
+
try {
|
|
574
|
+
const notFoundResponse = await renderUrl(
|
|
575
|
+
handler,
|
|
576
|
+
'/_timber/__not_found',
|
|
577
|
+
'text/html',
|
|
578
|
+
timeoutMs
|
|
579
|
+
);
|
|
580
|
+
if (notFoundResponse.status === 404 && notFoundResponse.body.byteLength > 0) {
|
|
581
|
+
await options.onEntry({
|
|
582
|
+
urlPath: '/_timber_not_found',
|
|
583
|
+
status: notFoundResponse.status,
|
|
584
|
+
headers: headersToRecord(notFoundResponse.headers),
|
|
585
|
+
body: notFoundResponse.body,
|
|
586
|
+
contentType: notFoundResponse.headers.get('content-type') ?? 'text/html',
|
|
587
|
+
kind: 'html',
|
|
588
|
+
});
|
|
589
|
+
log('[timber] Static generation: rendered 404.html');
|
|
590
|
+
}
|
|
591
|
+
} catch {
|
|
592
|
+
log('[timber] Static generation: 404 page render failed — skipping 404.html');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Discover and render paginated sub-sitemaps. If /sitemap.xml was an
|
|
596
|
+
// index (contains /sitemap/N.xml references), render each sub-sitemap.
|
|
597
|
+
if (options.autoSitemapEnabled && !hasUserSitemap(manifest.root)) {
|
|
598
|
+
const sitemapEntry = renderedEntries.get('/sitemap.xml');
|
|
599
|
+
if (sitemapEntry) {
|
|
600
|
+
const xml = new TextDecoder().decode(sitemapEntry);
|
|
601
|
+
const subUrls = discoverSubSitemapUrls(xml);
|
|
602
|
+
for (const subUrl of subUrls) {
|
|
603
|
+
try {
|
|
604
|
+
const resp = await renderUrl(handler, subUrl, '*/*', timeoutMs);
|
|
605
|
+
if (resp.status >= 400) {
|
|
606
|
+
summary.errors.push({
|
|
607
|
+
urlPath: subUrl,
|
|
608
|
+
message: `Sub-sitemap render returned status ${resp.status}`,
|
|
609
|
+
status: resp.status,
|
|
610
|
+
});
|
|
611
|
+
} else {
|
|
612
|
+
await options.onEntry({
|
|
613
|
+
urlPath: subUrl,
|
|
614
|
+
status: resp.status,
|
|
615
|
+
headers: headersToRecord(resp.headers),
|
|
616
|
+
body: resp.body,
|
|
617
|
+
contentType: resp.headers.get('content-type') ?? 'application/xml',
|
|
618
|
+
kind: 'api',
|
|
619
|
+
});
|
|
620
|
+
summary.rendered++;
|
|
621
|
+
}
|
|
622
|
+
} catch (error) {
|
|
623
|
+
summary.errors.push({
|
|
624
|
+
urlPath: subUrl,
|
|
625
|
+
message: `Sub-sitemap failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return summary;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Extract /sitemap/N.xml URLs from a sitemap index XML. */
|
|
636
|
+
export function discoverSubSitemapUrls(xml: string): string[] {
|
|
637
|
+
const urls: string[] = [];
|
|
638
|
+
const locPattern = /<loc>([^<]*\/sitemap\/\d+\.xml)<\/loc>/g;
|
|
639
|
+
let match;
|
|
640
|
+
while ((match = locPattern.exec(xml)) !== null) {
|
|
641
|
+
try {
|
|
642
|
+
const url = new URL(match[1]);
|
|
643
|
+
urls.push(url.pathname);
|
|
644
|
+
} catch {
|
|
645
|
+
// If it's already a pathname (no origin), use it directly
|
|
646
|
+
const loc = match[1];
|
|
647
|
+
if (loc.startsWith('/')) urls.push(loc);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return urls;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function renderUrl(
|
|
654
|
+
handler: (req: Request) => Promise<Response>,
|
|
655
|
+
urlPath: string,
|
|
656
|
+
accept: string,
|
|
657
|
+
timeoutMs: number
|
|
658
|
+
): Promise<{ status: number; headers: Headers; body: Uint8Array }> {
|
|
659
|
+
const url = `http://localhost${urlPath}`;
|
|
660
|
+
const controller = new AbortController();
|
|
661
|
+
const request = new Request(url, {
|
|
662
|
+
method: 'GET',
|
|
663
|
+
headers: { Accept: accept },
|
|
664
|
+
signal: controller.signal,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Race the handler call itself against the timeout — a stalled
|
|
668
|
+
// middleware or proxy could hang the build if only the body drain
|
|
669
|
+
// is guarded. See design/13-security.md A2.
|
|
670
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
671
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
672
|
+
timer = setTimeout(() => {
|
|
673
|
+
controller.abort();
|
|
674
|
+
reject(new Error(`handler did not respond within ${timeoutMs}ms for ${urlPath}`));
|
|
675
|
+
}, timeoutMs);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
let response: Response;
|
|
679
|
+
try {
|
|
680
|
+
response = await Promise.race([handler(request), deadline]);
|
|
681
|
+
} finally {
|
|
682
|
+
clearTimeout(timer);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const body = await drainResponse(response, timeoutMs);
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
status: response.status,
|
|
689
|
+
headers: response.headers,
|
|
690
|
+
body,
|
|
691
|
+
};
|
|
692
|
+
}
|