@timber-js/app 0.2.0-alpha.182 → 0.2.0-alpha.184

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/_chunks/{build-output-helper-DF5uScqS.js → build-output-helper-DOGYFb_X.js} +47 -9
  2. package/dist/_chunks/build-output-helper-DOGYFb_X.js.map +1 -0
  3. package/dist/_chunks/{cloudflare-AHoWYTYr.js → cloudflare-CnT5Lr7U.js} +21 -3
  4. package/dist/_chunks/{cloudflare-AHoWYTYr.js.map → cloudflare-CnT5Lr7U.js.map} +1 -1
  5. package/dist/_chunks/plugin-context---kTF5v8.js.map +1 -1
  6. package/dist/adapters/build-output-helper.d.ts +6 -0
  7. package/dist/adapters/build-output-helper.d.ts.map +1 -1
  8. package/dist/adapters/cloudflare-dev.js +1 -1
  9. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  10. package/dist/adapters/cloudflare.d.ts +10 -0
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +2 -2
  13. package/dist/adapters/nitro.d.ts +14 -0
  14. package/dist/adapters/nitro.d.ts.map +1 -1
  15. package/dist/adapters/nitro.js +14 -1
  16. package/dist/adapters/nitro.js.map +1 -1
  17. package/dist/adapters/shared.d.ts +1 -1
  18. package/dist/adapters/shared.d.ts.map +1 -1
  19. package/dist/adapters/types.d.ts +14 -0
  20. package/dist/adapters/types.d.ts.map +1 -1
  21. package/dist/client/internal.js +113 -11
  22. package/dist/client/internal.js.map +1 -1
  23. package/dist/client/rsc-fetch.d.ts +9 -0
  24. package/dist/client/rsc-fetch.d.ts.map +1 -1
  25. package/dist/index.js +437 -200
  26. package/dist/index.js.map +1 -1
  27. package/dist/plugin-context.d.ts +12 -0
  28. package/dist/plugin-context.d.ts.map +1 -1
  29. package/dist/plugins/adapter-build.d.ts.map +1 -1
  30. package/dist/plugins/build-report.d.ts +4 -3
  31. package/dist/plugins/build-report.d.ts.map +1 -1
  32. package/dist/plugins/static-build.d.ts +55 -2
  33. package/dist/plugins/static-build.d.ts.map +1 -1
  34. package/dist/server/rsc-entry/index.d.ts +12 -0
  35. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  36. package/dist/server/sitemap-generator.d.ts +1 -1
  37. package/dist/server/sitemap-generator.d.ts.map +1 -1
  38. package/dist/server/static-generator.d.ts +106 -0
  39. package/dist/server/static-generator.d.ts.map +1 -0
  40. package/docs/learn/13-configuration.mdx +5 -1
  41. package/package.json +1 -1
  42. package/src/adapters/build-output-helper.ts +40 -1
  43. package/src/adapters/cloudflare.ts +43 -1
  44. package/src/adapters/nitro.ts +58 -2
  45. package/src/adapters/shared.ts +42 -11
  46. package/src/adapters/types.ts +14 -0
  47. package/src/client/browser-entry/index.ts +7 -1
  48. package/src/client/rsc-fetch.ts +187 -19
  49. package/src/index.ts +1 -1
  50. package/src/plugin-context.ts +12 -0
  51. package/src/plugins/adapter-build.ts +5 -0
  52. package/src/plugins/build-report.ts +6 -12
  53. package/src/plugins/static-build.ts +391 -2
  54. package/src/server/rsc-entry/index.ts +24 -0
  55. package/src/server/sitemap-generator.ts +1 -1
  56. package/src/server/static-generator.ts +692 -0
  57. package/dist/_chunks/build-output-helper-DF5uScqS.js.map +0 -1
@@ -19,6 +19,20 @@ export interface TimberConfig {
19
19
  * Undefined when no build manifest was produced (e.g., dev mode or no client assets).
20
20
  */
21
21
  manifestInit?: string;
22
+ /**
23
+ * Absolute path to the directory containing pre-rendered static HTML,
24
+ * RSC flight files, and API route outputs. Present only when
25
+ * output: 'static' and the static generation pass completed.
26
+ * Layout: path/index.html, _rsc/path.rsc, api-route-path
27
+ */
28
+ staticPagesDir?: string;
29
+ /**
30
+ * Content-Type mappings for extensionless API route outputs.
31
+ * Map from URL path (e.g. "/api/data") to Content-Type value.
32
+ * Written during static generation, consumed by adapters when
33
+ * writing _headers. See TIM-1241.
34
+ */
35
+ staticContentTypes?: Record<string, string>;
22
36
  }
23
37
 
24
38
  /**
@@ -40,7 +40,7 @@ import schemaCodecs from 'virtual:timber-schema';
40
40
  import { setLinkCodecs } from '../../params/codec-registry.js';
41
41
  setLinkCodecs(schemaCodecs);
42
42
 
43
- import { setClientDeploymentId } from '../rsc-fetch.js';
43
+ import { setClientDeploymentId, setStaticMode } from '../rsc-fetch.js';
44
44
 
45
45
  import { setupServerActions } from './action-dispatch.js';
46
46
  import { createRscPayloadStream } from './rsc-stream.js';
@@ -63,6 +63,12 @@ function bootstrap(runtimeConfig: typeof config): void {
63
63
  setClientDeploymentId(deploymentId);
64
64
  }
65
65
 
66
+ // Static mode: fetch _rsc/*.rsc files instead of route URLs with
67
+ // Accept headers. Static hosts ignore Accept headers (TIM-1243).
68
+ if ((runtimeConfig as Record<string, unknown>).output === 'static') {
69
+ setStaticMode(true);
70
+ }
71
+
66
72
  // Step 2: Decode inlined RSC payload (may be null for JS-only clients)
67
73
  const rscResult = createRscPayloadStream();
68
74
 
@@ -88,6 +88,108 @@ 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
+ * RSC manifest mapping unhashed → hashed URLs. Populated from
111
+ * `window.__TIMBER_RSC_MANIFEST__` (injected into HTML during
112
+ * static generation). See TIM-1254.
113
+ */
114
+ let rscManifest: Record<string, string> | null = null;
115
+
116
+ function getRscManifest(): Record<string, string> | null {
117
+ if (rscManifest) return rscManifest;
118
+ if (typeof window !== 'undefined' && (window as any).__TIMBER_RSC_MANIFEST__) {
119
+ rscManifest = (window as any).__TIMBER_RSC_MANIFEST__;
120
+ }
121
+ return rscManifest;
122
+ }
123
+
124
+ /** @internal Exported for testing. */
125
+ export function setRscManifest(manifest: Record<string, string> | null): void {
126
+ rscManifest = manifest;
127
+ }
128
+
129
+ /**
130
+ * Convert a route URL to the corresponding _rsc/*.rsc file path.
131
+ * Mirrors the naming in plugins/static-build.ts staticOutputPath.
132
+ *
133
+ * When an RSC manifest is available (hashed filenames from TIM-1254),
134
+ * the manifest is consulted to resolve to the hashed path.
135
+ *
136
+ * / → /_rsc/index.rsc (or /_rsc/index-B7YxEKdN.rsc with manifest)
137
+ * /about → /_rsc/about.rsc (or /_rsc/about-C8ZzFLfO.rsc with manifest)
138
+ * /blog/hello → /_rsc/blog/hello.rsc
139
+ */
140
+ function toStaticRscUrl(url: string): string {
141
+ const unhashed = toUnhashedRscUrl(url);
142
+ return manifestLookup(unhashed) ?? unhashed;
143
+ }
144
+
145
+ /**
146
+ * Compute the unhashed _rsc/*.rsc URL for a route path.
147
+ * @internal Exported for testing.
148
+ */
149
+ export function toUnhashedRscUrl(url: string): string {
150
+ const hashIndex = url.indexOf('#');
151
+ const queryIndex = url.indexOf('?');
152
+ const hashEnd = hashIndex === -1 ? url.length : hashIndex;
153
+ const queryEnd = queryIndex === -1 ? url.length : queryIndex;
154
+ const end = Math.min(hashEnd, queryEnd);
155
+ let pathname = url.slice(0, end);
156
+ // Strip trailing slash (unless root) to match static build output naming
157
+ if (pathname.length > 1 && pathname.endsWith('/')) {
158
+ pathname = pathname.slice(0, -1);
159
+ }
160
+ const rscPath = pathname === '/' ? '/index' : pathname;
161
+ return `/_rsc${rscPath}.rsc`;
162
+ }
163
+
164
+ /**
165
+ * Convert a route URL to the corresponding _rsc/*.params.json sidecar path.
166
+ * Used in static mode to fetch route params that are normally carried
167
+ * in the X-Timber-Params response header. See TIM-1246.
168
+ *
169
+ * Consults the RSC manifest for hashed filenames (TIM-1254).
170
+ */
171
+ /**
172
+ * Look up a key in the RSC manifest, falling back to percent-decoded
173
+ * lookup for encoded browser URLs. Returns null if not found.
174
+ */
175
+ function manifestLookup(key: string): string | null {
176
+ const manifest = getRscManifest();
177
+ if (!manifest) return null;
178
+ if (manifest[key]) return manifest[key];
179
+ try {
180
+ const decoded = decodeURIComponent(key);
181
+ if (decoded !== key && manifest[decoded]) return manifest[decoded];
182
+ } catch {
183
+ // Malformed percent sequence (e.g. /100%.rsc) — skip decoded lookup
184
+ }
185
+ return null;
186
+ }
187
+
188
+ function toStaticParamsUrl(url: string): string {
189
+ const unhashed = toUnhashedRscUrl(url).replace(/\.rsc$/, '.params.json');
190
+ return manifestLookup(unhashed) ?? unhashed;
191
+ }
192
+
91
193
  // ─── Reload Signal ───────────────────────────────────────────────
92
194
 
93
195
  /** Header name used by the server to signal a version skew reload. */
@@ -326,8 +428,16 @@ export async function fetchRscPayload(
326
428
  currentUrl?: string,
327
429
  signal?: AbortSignal
328
430
  ): Promise<FetchResult> {
329
- const rscUrl = appendRscParam(url);
330
- const headers = buildRscHeaders(stateTree, currentUrl);
431
+ // In static mode, fetch the pre-generated _rsc/*.rsc file directly
432
+ // instead of the route URL with Accept headers. Static hosts ignore
433
+ // Accept headers, so the route URL would return HTML.
434
+ const fetchTarget = staticMode ? toStaticRscUrl(url) : url;
435
+ // Skip the cache-bust param when the manifest supplies a content-hashed
436
+ // URL — the hash itself guarantees freshness, and the param would defeat
437
+ // the immutable cache headers on /_rsc/* (TIM-1254).
438
+ const isHashedFromManifest = staticMode && fetchTarget !== toUnhashedRscUrl(url);
439
+ const rscUrl = isHashedFromManifest ? fetchTarget : appendRscParam(fetchTarget);
440
+ const headers = buildRscHeaders(staticMode ? undefined : stateTree, currentUrl);
331
441
  if (deps.decodeRsc) {
332
442
  // Production path: use createFromFetch for streaming RSC decoding.
333
443
  // createFromFetch takes a Promise<Response> and progressively parses
@@ -336,6 +446,21 @@ export async function fetchRscPayload(
336
446
  // Intercept the response to read segment metadata before createFromFetch
337
447
  // consumes the body. Reading headers does NOT consume the body stream.
338
448
  const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
449
+ // In static mode, fetch the params sidecar in parallel. The .rsc
450
+ // file has no HTTP headers, so params come from a .params.json
451
+ // sidecar written at build time. See TIM-1246.
452
+ const staticParamsFetch = staticMode
453
+ ? deps
454
+ .fetch(toStaticParamsUrl(url), { signal })
455
+ .then((r) => (r.ok ? (r.json() as Promise<Record<string, string | string[]>>) : null))
456
+ .catch((e) => {
457
+ if (e instanceof DOMException && e.name === 'AbortError') throw e;
458
+ return null;
459
+ })
460
+ : null;
461
+ // Observe the sidecar promise so its rejection doesn't become
462
+ // unhandled if wrappedPromise rejects first (TIM-1248).
463
+ staticParamsFetch?.catch(() => {});
339
464
  let segmentInfo: SegmentInfo[] | null = null;
340
465
  let params: Record<string, string | string[]> | null = null;
341
466
  let skippedSegments: string[] | null = null;
@@ -368,14 +493,27 @@ export async function fetchRscPayload(
368
493
  if (response.headers.get('X-Timber-Error') === '1') {
369
494
  throw new ServerErrorResponse(response.status, url);
370
495
  }
371
- // Content-Type guard: reject non-RSC responses (static assets, proxied
372
- // files, etc.) before createFromFetch tries to parse the body as Flight
373
- // data. Cancel the body immediately so the asset is never downloaded.
374
- // See TIM-1231.
375
- const contentType = response.headers.get('content-type');
376
- if (!contentType || !contentType.split(';')[0].trim().includes(RSC_CONTENT_TYPE)) {
377
- response.body?.cancel();
378
- throw new NonRscResponse(url);
496
+ // Content-Type guard: reject non-RSC responses before createFromFetch
497
+ // tries to parse the body as Flight data.
498
+ // In static mode, accept octet-stream/text/plain/absent content-type
499
+ // (static hosts serve .rsc files with these), but still reject 404s
500
+ // and text/html (missing .rsc file → host returns 404 page or SPA
501
+ // HTML fallback). See TIM-1231, TIM-1243, TIM-1247.
502
+ if (staticMode) {
503
+ const contentType = response.headers.get('content-type');
504
+ if (
505
+ !response.ok ||
506
+ (contentType && contentType.split(';')[0].trim().toLowerCase() === 'text/html')
507
+ ) {
508
+ response.body?.cancel();
509
+ throw new NonRscResponse(url);
510
+ }
511
+ } else {
512
+ const contentType = response.headers.get('content-type');
513
+ if (!contentType || !contentType.split(';')[0].trim().includes(RSC_CONTENT_TYPE)) {
514
+ response.body?.cancel();
515
+ throw new NonRscResponse(url);
516
+ }
379
517
  }
380
518
  // Metadata (<title>/<meta>/<link>) now rides the RSC Flight payload
381
519
  // as React elements — React 19 Float handles them. See TIM-1151.
@@ -400,6 +538,11 @@ export async function fetchRscPayload(
400
538
  });
401
539
  // Await headers so segmentInfo/params are populated.
402
540
  await wrappedPromise;
541
+ // In static mode, params come from the sidecar (header extraction
542
+ // returns null because .rsc files have no HTTP headers). TIM-1246.
543
+ if (staticParamsFetch && !params) {
544
+ params = await staticParamsFetch;
545
+ }
403
546
  // Start decoding but do NOT await — return the in-progress thenable.
404
547
  // React can render a Flight thenable directly: it suspends on unresolved
405
548
  // parts and progressively renders as chunks arrive, spreading work across
@@ -436,20 +579,45 @@ export async function fetchRscPayload(
436
579
  if (response.headers.get('X-Timber-Error') === '1') {
437
580
  throw new ServerErrorResponse(response.status, url);
438
581
  }
439
- // Content-Type guard (same as production path above). See TIM-1231.
440
- const fallbackContentType = response.headers.get('content-type');
441
- if (
442
- !fallbackContentType ||
443
- !fallbackContentType.split(';')[0].trim().includes(RSC_CONTENT_TYPE)
444
- ) {
445
- response.body?.cancel();
446
- throw new NonRscResponse(url);
582
+ // Content-Type guard (same as production path above). See TIM-1231, TIM-1243, TIM-1247.
583
+ if (staticMode) {
584
+ const fallbackContentType = response.headers.get('content-type');
585
+ if (
586
+ !response.ok ||
587
+ (fallbackContentType &&
588
+ fallbackContentType.split(';')[0].trim().toLowerCase() === 'text/html')
589
+ ) {
590
+ response.body?.cancel();
591
+ throw new NonRscResponse(url);
592
+ }
593
+ } else {
594
+ const fallbackContentType = response.headers.get('content-type');
595
+ if (
596
+ !fallbackContentType ||
597
+ !fallbackContentType.split(';')[0].trim().includes(RSC_CONTENT_TYPE)
598
+ ) {
599
+ response.body?.cancel();
600
+ throw new NonRscResponse(url);
601
+ }
602
+ }
603
+ let fallbackParams = extractParams(response);
604
+ // In static mode, params come from the sidecar. TIM-1246.
605
+ if (staticMode && !fallbackParams) {
606
+ try {
607
+ const paramsResponse = await deps.fetch(toStaticParamsUrl(url), { signal });
608
+ if (paramsResponse.ok) {
609
+ fallbackParams = (await paramsResponse.json()) as Record<string, string | string[]>;
610
+ }
611
+ } catch (e) {
612
+ if (e instanceof DOMException && e.name === 'AbortError') throw e;
613
+ // No params sidecar — non-dynamic route, params stay null
614
+ }
447
615
  }
448
616
  return {
449
617
  payload: await response.text(),
450
618
  decodePromise: null,
451
619
  segmentInfo: extractSegmentInfo(response),
452
- params: extractParams(response),
620
+ params: fallbackParams,
453
621
  skippedSegments: extractSkippedSegments(response),
454
622
  };
455
623
  }
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
@@ -160,6 +160,18 @@ 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>;
169
+ /**
170
+ * RSC flight payload manifest mapping unhashed → hashed URLs.
171
+ * Populated during static generation, consumed by adapters to set
172
+ * immutable cache headers for `_rsc/` files. See TIM-1254.
173
+ */
174
+ rscManifest?: Record<string, string>;
163
175
  }
164
176
 
165
177
  // ── 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
- * In static mode, only pages with dynamic/catch-all segments are dynamic.
53
- * API routes (route.ts) are always classified as function.
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
- if (outputMode === 'server') return 'dynamic';
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 ─────────────────────────────────────────────────────────