@timber-js/app 0.2.0-alpha.96 → 0.2.0-alpha.98

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 (104) hide show
  1. package/dist/_chunks/{metadata-routes-DS3eKNmf.js → metadata-routes-BU684ls2.js} +1 -1
  2. package/dist/_chunks/{metadata-routes-DS3eKNmf.js.map → metadata-routes-BU684ls2.js.map} +1 -1
  3. package/dist/_chunks/segment-classify-BjfuctV2.js +137 -0
  4. package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -0
  5. package/dist/_chunks/{interception-BsLCA9gk.js → walkers-VOXgavMF.js} +66 -92
  6. package/dist/_chunks/walkers-VOXgavMF.js.map +1 -0
  7. package/dist/adapters/nitro.d.ts.map +1 -1
  8. package/dist/adapters/nitro.js +55 -5
  9. package/dist/adapters/nitro.js.map +1 -1
  10. package/dist/client/index.js +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +189 -62
  13. package/dist/index.js.map +1 -1
  14. package/dist/plugins/build-report.d.ts +6 -4
  15. package/dist/plugins/build-report.d.ts.map +1 -1
  16. package/dist/plugins/dev-404-page.d.ts +8 -18
  17. package/dist/plugins/dev-404-page.d.ts.map +1 -1
  18. package/dist/routing/index.d.ts +5 -3
  19. package/dist/routing/index.d.ts.map +1 -1
  20. package/dist/routing/index.js +3 -3
  21. package/dist/routing/link-codegen.d.ts.map +1 -1
  22. package/dist/routing/scanner.d.ts +1 -10
  23. package/dist/routing/scanner.d.ts.map +1 -1
  24. package/dist/routing/segment-classify.d.ts +37 -8
  25. package/dist/routing/segment-classify.d.ts.map +1 -1
  26. package/dist/routing/types.d.ts +63 -23
  27. package/dist/routing/types.d.ts.map +1 -1
  28. package/dist/routing/walkers.d.ts +51 -0
  29. package/dist/routing/walkers.d.ts.map +1 -0
  30. package/dist/server/action-handler.d.ts.map +1 -1
  31. package/dist/server/dev-holding-server.d.ts +4 -2
  32. package/dist/server/dev-holding-server.d.ts.map +1 -1
  33. package/dist/server/html-injector-core.d.ts +212 -0
  34. package/dist/server/html-injector-core.d.ts.map +1 -0
  35. package/dist/server/html-injectors.d.ts +59 -59
  36. package/dist/server/html-injectors.d.ts.map +1 -1
  37. package/dist/server/internal.js +710 -563
  38. package/dist/server/internal.js.map +1 -1
  39. package/dist/server/node-stream-transforms.d.ts +46 -49
  40. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  41. package/dist/server/pipeline-helpers.d.ts +88 -0
  42. package/dist/server/pipeline-helpers.d.ts.map +1 -0
  43. package/dist/server/pipeline-phases.d.ts +97 -0
  44. package/dist/server/pipeline-phases.d.ts.map +1 -0
  45. package/dist/server/pipeline.d.ts +53 -32
  46. package/dist/server/pipeline.d.ts.map +1 -1
  47. package/dist/server/port-resolution.d.ts +117 -0
  48. package/dist/server/port-resolution.d.ts.map +1 -0
  49. package/dist/server/route-matcher.d.ts +20 -47
  50. package/dist/server/route-matcher.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  52. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +74 -0
  53. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
  54. package/dist/server/status-code-resolver.d.ts +16 -11
  55. package/dist/server/status-code-resolver.d.ts.map +1 -1
  56. package/dist/server/tree-builder.d.ts.map +1 -1
  57. package/dist/utils/directive-parser.d.ts +0 -45
  58. package/dist/utils/directive-parser.d.ts.map +1 -1
  59. package/package.json +7 -6
  60. package/src/adapters/nitro.ts +55 -5
  61. package/src/cli.ts +0 -0
  62. package/src/index.ts +84 -31
  63. package/src/plugins/build-report.ts +13 -22
  64. package/src/plugins/dev-404-page.ts +15 -41
  65. package/src/plugins/routing.ts +14 -12
  66. package/src/routing/codegen.ts +1 -1
  67. package/src/routing/convention-lint.ts +4 -4
  68. package/src/routing/index.ts +5 -3
  69. package/src/routing/interception.ts +1 -1
  70. package/src/routing/link-codegen.ts +25 -13
  71. package/src/routing/scanner.ts +17 -93
  72. package/src/routing/segment-classify.ts +107 -8
  73. package/src/routing/status-file-lint.ts +3 -3
  74. package/src/routing/types.ts +63 -23
  75. package/src/routing/walkers.ts +90 -0
  76. package/src/server/action-handler.ts +6 -0
  77. package/src/server/deny-renderer.ts +5 -5
  78. package/src/server/dev-holding-server.ts +4 -2
  79. package/src/server/fallback-error.ts +1 -1
  80. package/src/server/html-injector-core.ts +403 -0
  81. package/src/server/html-injectors.ts +158 -297
  82. package/src/server/node-stream-transforms.ts +108 -248
  83. package/src/server/pipeline-helpers.ts +180 -0
  84. package/src/server/pipeline-phases.ts +591 -0
  85. package/src/server/pipeline.ts +76 -539
  86. package/src/server/port-resolution.ts +215 -0
  87. package/src/server/route-element-builder.ts +1 -1
  88. package/src/server/route-matcher.ts +28 -60
  89. package/src/server/rsc-entry/api-handler.ts +2 -2
  90. package/src/server/rsc-entry/error-renderer.ts +1 -1
  91. package/src/server/rsc-entry/index.ts +52 -98
  92. package/src/server/rsc-entry/wrap-action-dispatch.ts +156 -0
  93. package/src/server/sitemap-generator.ts +1 -1
  94. package/src/server/slot-resolver.ts +1 -1
  95. package/src/server/status-code-resolver.ts +112 -128
  96. package/src/server/tree-builder.ts +6 -4
  97. package/src/utils/directive-parser.ts +0 -392
  98. package/LICENSE +0 -8
  99. package/dist/_chunks/interception-BsLCA9gk.js.map +0 -1
  100. package/dist/_chunks/segment-classify-BDNn6EzD.js +0 -65
  101. package/dist/_chunks/segment-classify-BDNn6EzD.js.map +0 -1
  102. package/dist/server/manifest-status-resolver.d.ts +0 -58
  103. package/dist/server/manifest-status-resolver.d.ts.map +0 -1
  104. package/src/server/manifest-status-resolver.ts +0 -215
@@ -48,14 +48,14 @@ function walkNode(node: SegmentNode, warnings: StatusFileLintWarning[]): void {
48
48
 
49
49
  // Check status-code files (404.tsx, 4xx.tsx, 5xx.tsx, etc.)
50
50
  if (node.statusFiles) {
51
- for (const [code, file] of node.statusFiles) {
51
+ for (const [code, file] of Object.entries(node.statusFiles)) {
52
52
  checkFile(file.filePath, file.extension, code, warnings);
53
53
  }
54
54
  }
55
55
 
56
56
  // Check legacy compat files (not-found.tsx, forbidden.tsx, unauthorized.tsx)
57
57
  if (node.legacyStatusFiles) {
58
- for (const [name, file] of node.legacyStatusFiles) {
58
+ for (const [name, file] of Object.entries(node.legacyStatusFiles)) {
59
59
  checkFile(file.filePath, file.extension, name, warnings);
60
60
  }
61
61
  }
@@ -64,7 +64,7 @@ function walkNode(node: SegmentNode, warnings: StatusFileLintWarning[]): void {
64
64
  for (const child of node.children) {
65
65
  walkNode(child, warnings);
66
66
  }
67
- for (const [, slotNode] of node.slots) {
67
+ for (const slotNode of Object.values(node.slots)) {
68
68
  walkNode(slotNode, warnings);
69
69
  }
70
70
  }
@@ -3,6 +3,23 @@
3
3
  *
4
4
  * The route tree is built by scanning the app/ directory and recognizing
5
5
  * file conventions (page.*, layout.*, middleware.ts, access.ts, route.ts, etc.).
6
+ *
7
+ * **Single shape, two specializations** (TIM-848):
8
+ *
9
+ * `SegmentNode<TFile>` is the one canonical in-memory shape for the
10
+ * timber route tree. The same interface is used at build time (with
11
+ * `TFile = RouteFile`) and at request time (with `TFile = ManifestFile`,
12
+ * see `server/route-matcher.ts`). Walkers parameterized over `TFile`
13
+ * work on either, eliminating the previous duplication between
14
+ * `SegmentNode` (Map-based) and `ManifestSegmentNode` (object-based).
15
+ *
16
+ * Keyed groups (`slots`, `statusFiles`, `jsonStatusFiles`,
17
+ * `legacyStatusFiles`, `metadataRoutes`) are plain `Record<string, …>`
18
+ * objects rather than `Map`s so that the build-time tree can be
19
+ * serialized into the virtual route manifest with no shape transform.
20
+ *
21
+ * See design/07-routing.md §"Route Tree Shape" and design/18-build-system.md
22
+ * §"Route Manifest Shape".
6
23
  */
7
24
 
8
25
  /** Segment type classification */
@@ -27,7 +44,14 @@ export type InterceptionMarker = '(.)' | '(..)' | '(...)' | '(..)(..)';
27
44
  /** All recognized interception markers, ordered longest-first for parsing. */
28
45
  export const INTERCEPTION_MARKERS: InterceptionMarker[] = ['(..)(..)', '(.)', '(..)', '(...)'];
29
46
 
30
- /** A single file discovered in a route segment */
47
+ /**
48
+ * A single file discovered in a route segment at build time.
49
+ *
50
+ * The runtime equivalent (`ManifestFile`, defined in
51
+ * `server/route-matcher.ts`) replaces `extension` with a lazy `load`
52
+ * function. Walkers that only need `filePath` are parameterized over
53
+ * `TFile` and accept either.
54
+ */
31
55
  export interface RouteFile {
32
56
  /** Absolute path to the file */
33
57
  filePath: string;
@@ -35,8 +59,17 @@ export interface RouteFile {
35
59
  extension: string;
36
60
  }
37
61
 
38
- /** A node in the segment tree */
39
- export interface SegmentNode {
62
+ /**
63
+ * A node in the segment tree.
64
+ *
65
+ * Generic over `TFile` so the same interface describes both the
66
+ * build-time tree (`SegmentNode<RouteFile>`, the default) and the
67
+ * runtime manifest tree (`SegmentNode<ManifestFile>`, aliased as
68
+ * `ManifestSegmentNode`). All keyed groups use `Record` (not `Map`)
69
+ * so the build-time tree serializes to the virtual route manifest
70
+ * with no shape transform.
71
+ */
72
+ export interface SegmentNode<TFile = RouteFile> {
40
73
  /** The raw directory name (e.g. "dashboard", "[id]", "(auth)", "@sidebar") */
41
74
  segmentName: string;
42
75
  /** Classified segment type */
@@ -54,43 +87,50 @@ export interface SegmentNode {
54
87
  interceptedSegmentName?: string;
55
88
 
56
89
  // --- File conventions ---
57
- page?: RouteFile;
58
- layout?: RouteFile;
59
- middleware?: RouteFile;
60
- access?: RouteFile;
61
- route?: RouteFile;
90
+ page?: TFile;
91
+ layout?: TFile;
92
+ middleware?: TFile;
93
+ access?: TFile;
94
+ route?: TFile;
62
95
  /**
63
96
  * params.ts — isomorphic convention file exporting segmentParams and/or searchParams.
64
97
  * Discovered by the scanner like middleware.ts and access.ts.
65
98
  * See design/07-routing.md §"params.ts Convention File"
66
99
  */
67
- params?: RouteFile;
68
- error?: RouteFile;
69
- default?: RouteFile;
100
+ params?: TFile;
101
+ error?: TFile;
102
+ default?: TFile;
70
103
  /** Status-code files: 4xx.tsx, 5xx.tsx, {status}.tsx (component format) */
71
- statusFiles?: Map<string, RouteFile>;
104
+ statusFiles?: Record<string, TFile>;
72
105
  /** JSON status-code files: 4xx.json, 5xx.json, {status}.json */
73
- jsonStatusFiles?: Map<string, RouteFile>;
106
+ jsonStatusFiles?: Record<string, TFile>;
74
107
  /** denied.tsx — slot-only denial rendering */
75
- denied?: RouteFile;
108
+ denied?: TFile;
76
109
  /** Legacy compat: not-found.tsx (maps to 404), forbidden.tsx (403), unauthorized.tsx (401) */
77
- legacyStatusFiles?: Map<string, RouteFile>;
110
+ legacyStatusFiles?: Record<string, TFile>;
78
111
 
79
112
  /** Metadata route files (sitemap.ts, robots.ts, icon.tsx, etc.) keyed by base name */
80
- metadataRoutes?: Map<string, RouteFile>;
113
+ metadataRoutes?: Record<string, TFile>;
81
114
 
82
115
  // --- Children ---
83
- children: SegmentNode[];
116
+ children: SegmentNode<TFile>[];
84
117
  /** Parallel route slots (keyed by slot name without @) */
85
- slots: Map<string, SegmentNode>;
118
+ slots: Record<string, SegmentNode<TFile>>;
86
119
  }
87
120
 
88
- /** The full route tree output from the scanner */
89
- export interface RouteTree {
121
+ /**
122
+ * The full route tree output from the scanner (or the root of the
123
+ * runtime route manifest, when `TFile = ManifestFile`).
124
+ *
125
+ * Generic so the same wrapper carries app-root metadata for both
126
+ * shapes. The runtime manifest extends this with `viteRoot` (see
127
+ * `ManifestRoot` in `server/route-matcher.ts`).
128
+ */
129
+ export interface RouteTree<TFile = RouteFile> {
90
130
  /** The root segment node (representing app/) */
91
- root: SegmentNode;
131
+ root: SegmentNode<TFile>;
92
132
  /** All discovered proxy.ts files (should be at most one, in app/) */
93
- proxy?: RouteFile;
133
+ proxy?: TFile;
94
134
  /**
95
135
  * Global error page: app/global-error.{tsx,ts,jsx,js}
96
136
  *
@@ -100,7 +140,7 @@ export interface RouteTree {
100
140
  *
101
141
  * See design/10-error-handling.md §"Tier 2 — Global Error Page"
102
142
  */
103
- globalError?: RouteFile;
143
+ globalError?: TFile;
104
144
  }
105
145
 
106
146
  /** Configuration passed to the scanner */
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared route-tree walkers (TIM-848).
3
+ *
4
+ * Tiny helpers that walk a `SegmentNode<TFile>` tree generically. Both the
5
+ * build-time tree (`SegmentNode<RouteFile>`) and the runtime manifest
6
+ * tree (`SegmentNode<ManifestFile>`) flow through these helpers because
7
+ * the walker only reads the structural fields shared by both shapes
8
+ * (`children`, `slots`, `page`, `route`, `urlPath`).
9
+ *
10
+ * Before this module, three near-identical `collectRoutes` functions
11
+ * lived in `plugins/dev-404-page.ts`, `plugins/build-report.ts`, and
12
+ * `routing/codegen.ts`. The codegen one is special-purpose (it
13
+ * accumulates `ParamEntry[]` and resolves codec chains) and stays
14
+ * local; the other two now share `collectLeafRoutes` from this file.
15
+ */
16
+
17
+ import type { SegmentNode } from './types.js';
18
+
19
+ /** A leaf route discovered while walking the segment tree. */
20
+ export interface LeafRoute<TFile> {
21
+ /** URL path of the leaf (root is "/"). */
22
+ urlPath: string;
23
+ /** Segment chain from root to this leaf, inclusive. */
24
+ segments: SegmentNode<TFile>[];
25
+ /** The page file at this leaf, if any. */
26
+ page?: TFile;
27
+ /** The route handler file at this leaf, if any. */
28
+ route?: TFile;
29
+ }
30
+
31
+ /** Options for `collectLeafRoutes`. */
32
+ export interface CollectLeafRoutesOptions {
33
+ /**
34
+ * If true, recurse into parallel slots and emit slot leaves alongside
35
+ * the main route tree. Defaults to `false` because slots render
36
+ * alongside their parent at the same URL and are not separately
37
+ * URL-addressable. The build report excludes slots; route-listing
38
+ * UIs that want to show "all leaves with a page handler" can opt in.
39
+ */
40
+ includeSlots?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Walk a segment tree and collect every leaf with a `page` or `route`
45
+ * handler. Generic over `TFile` so it works on both the build-time
46
+ * scanner output and the runtime manifest tree.
47
+ *
48
+ * - Pages and route handlers at the same URL produce two distinct
49
+ * entries (the build report deduplicates by URL afterward).
50
+ * - Parallel slots are skipped unless `includeSlots: true` (slots
51
+ * share their parent's URL and are not addressable on their own).
52
+ * - Result is sorted by `urlPath` for deterministic output.
53
+ */
54
+ export function collectLeafRoutes<TFile>(
55
+ root: SegmentNode<TFile>,
56
+ options: CollectLeafRoutesOptions = {}
57
+ ): LeafRoute<TFile>[] {
58
+ const { includeSlots = false } = options;
59
+ const result: LeafRoute<TFile>[] = [];
60
+ walk(root, [], result, includeSlots);
61
+ result.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
62
+ return result;
63
+ }
64
+
65
+ function walk<TFile>(
66
+ node: SegmentNode<TFile>,
67
+ chain: SegmentNode<TFile>[],
68
+ result: LeafRoute<TFile>[],
69
+ includeSlots: boolean
70
+ ): void {
71
+ const currentChain = [...chain, node];
72
+ const path = node.urlPath || '/';
73
+
74
+ if (node.page) {
75
+ result.push({ urlPath: path, segments: currentChain, page: node.page });
76
+ }
77
+ if (node.route) {
78
+ result.push({ urlPath: path, segments: currentChain, route: node.route });
79
+ }
80
+
81
+ for (const child of node.children) {
82
+ walk(child, currentChain, result, includeSlots);
83
+ }
84
+
85
+ if (includeSlots) {
86
+ for (const slotNode of Object.values(node.slots)) {
87
+ walk(slotNode, currentChain, result, includeSlots);
88
+ }
89
+ }
90
+ }
@@ -158,6 +158,12 @@ export async function handleActionRequest(
158
158
  }
159
159
 
160
160
  // CSRF validation — reject cross-origin mutation requests.
161
+ //
162
+ // Defense-in-depth: the pipeline boundary in rsc-entry/index.ts already
163
+ // validates Origin on every unsafe-method request before dispatch reaches
164
+ // here, so this call is a no-op on the happy path. It is intentionally
165
+ // retained so that handleActionRequest remains safe to call from any
166
+ // future entry point that bypasses the wrapper. See LOCAL-773.
161
167
  const csrfResult = validateCsrf(req, config.csrf);
162
168
  if (!csrfResult.ok) {
163
169
  return new Response(null, { status: csrfResult.status });
@@ -23,7 +23,7 @@ import { logRenderError } from './logger.js';
23
23
  import { loadModule } from './safe-load.js';
24
24
  import { isDebug } from './debug.js';
25
25
  import { resolveMetadata, renderMetadataToElements } from './metadata.js';
26
- import { resolveManifestStatusFile } from './manifest-status-resolver.js';
26
+ import { resolveStatusFile } from './status-code-resolver.js';
27
27
  import type { ManifestSegmentNode } from './route-matcher.js';
28
28
  import type { RouteMatch } from './pipeline.js';
29
29
  import type { NavContext } from './ssr-entry.js';
@@ -87,7 +87,7 @@ export async function renderDenyPage(
87
87
  }
88
88
 
89
89
  // Page routes → component chain first, JSON fallback only if no component found.
90
- const resolution = resolveManifestStatusFile(deny.status, segments, 'component');
90
+ const resolution = resolveStatusFile(deny.status, segments, 'component');
91
91
 
92
92
  // No component status file — try JSON chain before bare fallback
93
93
  if (!resolution) {
@@ -99,7 +99,7 @@ export async function renderDenyPage(
99
99
  // Dev warning: JSON status file exists but is shadowed by the component chain.
100
100
  // This helps developers understand why their .json file isn't being served.
101
101
  if (isDebug()) {
102
- const jsonResolution = resolveManifestStatusFile(deny.status, segments, 'json');
102
+ const jsonResolution = resolveStatusFile(deny.status, segments, 'json');
103
103
  if (jsonResolution) {
104
104
  console.warn(
105
105
  `[timber] ${jsonResolution.file.filePath} exists but is shadowed by ` +
@@ -190,7 +190,7 @@ export async function renderDenyPageAsRsc(
190
190
  responseHeaders: Headers,
191
191
  createDebugChannelSink: DebugChannelFactory
192
192
  ): Promise<Response> {
193
- const resolution = resolveManifestStatusFile(deny.status, segments, 'component');
193
+ const resolution = resolveStatusFile(deny.status, segments, 'component');
194
194
 
195
195
  if (!resolution) {
196
196
  responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
@@ -249,7 +249,7 @@ async function renderDenyPageJson(
249
249
  segments: ManifestSegmentNode[],
250
250
  responseHeaders: Headers
251
251
  ): Promise<Response | null> {
252
- const resolution = resolveManifestStatusFile(deny.status, segments, 'json');
252
+ const resolution = resolveStatusFile(deny.status, segments, 'json');
253
253
 
254
254
  if (!resolution) {
255
255
  return null;
@@ -119,9 +119,11 @@ const HOLDING_PAGE_HTML = [
119
119
  *
120
120
  * Usage (inside Vite plugin):
121
121
  * ```ts
122
- * // In config() hook — earliest point where port is known
122
+ * // In config() hook — earliest point where port is known.
123
+ * // Use bindWithBump() to honor the default-3000 + auto-bump policy
124
+ * // (see ../server/port-resolution.ts and TIM-842).
123
125
  * const holding = createHoldingServer();
124
- * holding.listen(config.server?.port ?? 5173);
126
+ * await bindWithBump((p) => holding.listen(p), { startPort: 3000, autoBump: true });
125
127
  *
126
128
  * // In last plugin's configureServer() — wrap listen for seamless handoff
127
129
  * const originalListen = server.listen.bind(server);
@@ -66,7 +66,7 @@ export async function renderFallbackError(
66
66
  // then fall through to global-error.tsx if those also fail.
67
67
  logRenderError({ method: req.method, path: new URL(req.url).pathname, error: layoutError });
68
68
  }
69
- const match: RouteMatch = { segments: segments as never, segmentParams: {}, middlewareChain: [] };
69
+ const match: RouteMatch = { segments, segmentParams: {}, middlewareChain: [] };
70
70
  return renderErrorPage(
71
71
  error,
72
72
  500,