@timber-js/app 0.2.0-alpha.151 → 0.2.0-alpha.153

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 (58) hide show
  1. package/dist/_chunks/actions-8lJZYYGq.js +298 -0
  2. package/dist/_chunks/actions-8lJZYYGq.js.map +1 -0
  3. package/dist/_chunks/{cache-api-Co-yI-gP.js → cache-api-e1E4i3Vy.js} +3 -26
  4. package/dist/_chunks/cache-api-e1E4i3Vy.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-DJDdyEae.js → cli-schema-sync-ChsRzw8o.js} +2 -2
  6. package/dist/_chunks/{cli-schema-sync-DJDdyEae.js.map → cli-schema-sync-ChsRzw8o.js.map} +1 -1
  7. package/dist/_chunks/{cli-schema-sync-D_IhHEUW.js → cli-schema-sync-D4urWkeV.js} +1 -1
  8. package/dist/_chunks/{actions-a0RczDnB.js → logger-DvmCWhN9.js} +665 -274
  9. package/dist/_chunks/logger-DvmCWhN9.js.map +1 -0
  10. package/dist/_chunks/plugin-context-GfDWsdd2.js.map +1 -1
  11. package/dist/_chunks/{walkers-DvWDj453.js → walkers-DZuFKqFX.js} +2 -2
  12. package/dist/_chunks/{walkers-DvWDj453.js.map → walkers-DZuFKqFX.js.map} +1 -1
  13. package/dist/cache/index.js +1 -1
  14. package/dist/cli.js +1 -1
  15. package/dist/client/browser-dev.d.ts +3 -0
  16. package/dist/client/browser-dev.d.ts.map +1 -1
  17. package/dist/config-types.d.ts +8 -0
  18. package/dist/config-types.d.ts.map +1 -1
  19. package/dist/config-validation.d.ts.map +1 -1
  20. package/dist/index.js +193 -48
  21. package/dist/index.js.map +1 -1
  22. package/dist/plugin-context.d.ts +9 -0
  23. package/dist/plugin-context.d.ts.map +1 -1
  24. package/dist/plugins/build-report.d.ts +13 -0
  25. package/dist/plugins/build-report.d.ts.map +1 -1
  26. package/dist/plugins/chunks.d.ts +11 -4
  27. package/dist/plugins/chunks.d.ts.map +1 -1
  28. package/dist/plugins/client-chunks.d.ts +23 -22
  29. package/dist/plugins/client-chunks.d.ts.map +1 -1
  30. package/dist/routing/index.js +2 -2
  31. package/dist/server/index.js +2 -2
  32. package/dist/server/internal.js +65 -2
  33. package/dist/server/internal.js.map +1 -1
  34. package/dist/server/logger.d.ts.map +1 -1
  35. package/dist/server/pipeline.d.ts.map +1 -1
  36. package/dist/server/primitives.d.ts +8 -0
  37. package/dist/server/primitives.d.ts.map +1 -1
  38. package/dist/server/route-element-builder.d.ts.map +1 -1
  39. package/dist/server/stream-utils.d.ts +11 -0
  40. package/dist/server/stream-utils.d.ts.map +1 -1
  41. package/package.json +4 -4
  42. package/src/client/browser-dev.ts +3 -0
  43. package/src/config-types.ts +8 -0
  44. package/src/config-validation.ts +16 -0
  45. package/src/index.ts +2 -2
  46. package/src/plugin-context.ts +9 -0
  47. package/src/plugins/build-report.ts +105 -10
  48. package/src/plugins/chunks.ts +74 -5
  49. package/src/plugins/client-chunks.ts +73 -42
  50. package/src/server/logger.ts +5 -0
  51. package/src/server/pipeline.ts +13 -0
  52. package/src/server/primitives.ts +29 -0
  53. package/src/server/route-element-builder.ts +4 -1
  54. package/src/server/stream-utils.ts +73 -0
  55. package/dist/_chunks/actions-a0RczDnB.js.map +0 -1
  56. package/dist/_chunks/cache-api-Co-yI-gP.js.map +0 -1
  57. package/dist/_chunks/logger-CM0clugc.js +0 -645
  58. package/dist/_chunks/logger-CM0clugc.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { c as isDynamicMetadataExtension, l as isMetadataRouteServePath, r as canonicalize } from "./_chunks/canonicalize-P41GR6tY.js";
2
2
  import { t as matchUrlParts } from "./_chunks/tree-match-D2l830j2.js";
3
- import { h as swallow } from "./_chunks/logger-CM0clugc.js";
4
- import { s as scanRoutes } from "./_chunks/cli-schema-sync-DJDdyEae.js";
5
- import { a as generateRouteMap, i as validateSchemaAgainstRoutes, n as collectInterceptionRewrites, o as readFileCached, t as collectLeafRoutes } from "./_chunks/walkers-DvWDj453.js";
3
+ import { h as swallow } from "./_chunks/logger-DvmCWhN9.js";
4
+ import { s as scanRoutes } from "./_chunks/cli-schema-sync-ChsRzw8o.js";
5
+ import { a as generateRouteMap, i as validateSchemaAgainstRoutes, n as collectInterceptionRewrites, o as readFileCached, t as collectLeafRoutes } from "./_chunks/walkers-DZuFKqFX.js";
6
6
  import { a as mergeFileConfig, c as resolveClientJavascript, i as loadTimberConfigFile, o as resolveAppDir, r as createPluginContext, s as resolveBuildDir, u as createNoopTimer } from "./_chunks/plugin-context-GfDWsdd2.js";
7
7
  import { t as fnv1aHash } from "./_chunks/fast-hash-D6hIVt1Y.js";
8
8
  import { t as formatSize } from "./_chunks/format-CfwjgPz9.js";
@@ -1153,6 +1153,14 @@ function validateConfig(config) {
1153
1153
  value: config.sitemap.defaultPriority
1154
1154
  });
1155
1155
  }
1156
+ if (config.budget != null && typeof config.budget === "object") {
1157
+ if (config.budget.firstLoadJs !== void 0 && (typeof config.budget.firstLoadJs !== "number" || config.budget.firstLoadJs <= 0)) errors.push({
1158
+ field: "budget.firstLoadJs",
1159
+ message: `budget.firstLoadJs must be a positive number in bytes (got ${JSON.stringify(config.budget.firstLoadJs)}).`,
1160
+ value: config.budget.firstLoadJs,
1161
+ suggestion: "Example: budget: { firstLoadJs: 150_000 } for a ~150 kB limit."
1162
+ });
1163
+ }
1156
1164
  for (const key of Object.keys(config)) if (!knownKeys.has(key)) errors.push({
1157
1165
  field: key,
1158
1166
  message: `Unknown config option: "${key}".`,
@@ -1248,7 +1256,8 @@ var init_config_validation = __esmMin((() => {
1248
1256
  reactCompiler: true,
1249
1257
  sitemap: true,
1250
1258
  buildDir: true,
1251
- topLoader: true
1259
+ topLoader: true,
1260
+ budget: true
1252
1261
  }));
1253
1262
  RED$1 = "\x1B[31m";
1254
1263
  BOLD$1 = "\x1B[1m";
@@ -6734,11 +6743,52 @@ function timberReactProd() {
6734
6743
  //#endregion
6735
6744
  //#region src/plugins/chunks.ts
6736
6745
  /**
6746
+ * Build a map from absolute module IDs in non-facade RSC chunks to the
6747
+ * absolute facade module paths that transitively import them.
6748
+ *
6749
+ * Used by `clientChunkGroup` to reclassify `shared:` client references
6750
+ * that are only consumed by a single route boundary (TIM-1107).
6751
+ */
6752
+ function buildFacadeAncestryMap(bundle) {
6753
+ const chunks = /* @__PURE__ */ new Map();
6754
+ for (const [file, asset] of Object.entries(bundle)) if (asset.type === "chunk") chunks.set(file, asset);
6755
+ const importedBy = /* @__PURE__ */ new Map();
6756
+ for (const [file, chunk] of chunks) for (const imp of chunk.imports) {
6757
+ let parents = importedBy.get(imp);
6758
+ if (!parents) {
6759
+ parents = [];
6760
+ importedBy.set(imp, parents);
6761
+ }
6762
+ parents.push(file);
6763
+ }
6764
+ function facadeAncestors(file, visited) {
6765
+ if (visited.has(file)) return [];
6766
+ visited.add(file);
6767
+ const chunk = chunks.get(file);
6768
+ if (!chunk) return [];
6769
+ if (chunk.facadeModuleId) return [chunk.facadeModuleId];
6770
+ const parents = importedBy.get(file) ?? [];
6771
+ const result = [];
6772
+ for (const p of parents) for (const f of facadeAncestors(p, visited)) result.push(f);
6773
+ return result;
6774
+ }
6775
+ const moduleToFacades = /* @__PURE__ */ new Map();
6776
+ for (const [file, chunk] of chunks) {
6777
+ if (chunk.facadeModuleId) continue;
6778
+ const facades = facadeAncestors(file, /* @__PURE__ */ new Set());
6779
+ if (facades.length === 0) continue;
6780
+ const unique = [...new Set(facades)];
6781
+ for (const modId of chunk.moduleIds) moduleToFacades.set(modId, unique);
6782
+ }
6783
+ return moduleToFacades;
6784
+ }
6785
+ /**
6737
6786
  * Create the timber-chunks Vite plugin.
6738
6787
  *
6739
- * Currently a no-op — Rolldown's default splitting produces the desired
6740
- * output (one main bundle + per-route chunks). The plugin is retained as
6741
- * a hook point for future chunking adjustments.
6788
+ * Rolldown's default splitting produces the desired output (one main
6789
+ * bundle + per-route chunks). This plugin's main job is building the
6790
+ * facade ancestry map during the RSC build so `clientChunkGroup` can
6791
+ * reclassify shared client references to their correct route boundary.
6742
6792
  *
6743
6793
  * NOTE: Ideally we'd use Rolldown's `format: 'app'` (module registry format)
6744
6794
  * which guarantees single-copy module semantics like webpack/Turbopack.
@@ -6748,21 +6798,31 @@ function timberReactProd() {
6748
6798
  *
6749
6799
  * Design docs: 27-chunking-strategy.md
6750
6800
  */
6751
- function timberChunks() {
6752
- return { name: "timber-chunks" };
6801
+ function timberChunks(ctx) {
6802
+ return {
6803
+ name: "timber-chunks",
6804
+ generateBundle(_options, bundle) {
6805
+ if (this.environment?.name !== "rsc") return;
6806
+ ctx.sharedModuleFacades = buildFacadeAncestryMap(bundle);
6807
+ }
6808
+ };
6753
6809
  }
6754
6810
  //#endregion
6755
6811
  //#region src/plugins/client-chunks.ts
6756
6812
  /**
6757
6813
  * Client chunk grouping strategy for @vitejs/plugin-rsc.
6758
6814
  *
6759
- * Groups client reference modules by layout boundary to balance route-scoped
6760
- * code splitting with HTTP request count. A constant group name would collapse
6761
- * all routes into one chunk (every page downloads every client component).
6762
- * Per-serverChunk grouping creates many sub-500B files. Layout-boundary
6763
- * grouping is the middle ground.
6815
+ * Groups client reference modules by the **consuming route's** layout boundary
6816
+ * (derived from `meta.serverChunk`), not by the module's own file location.
6817
+ * This ensures components in shared directories (e.g. `components/`) land in
6818
+ * the chunk of the route that uses them, not in a single catch-all chunk.
6819
+ *
6820
+ * For `shared:` modules (client references in non-facade RSC chunks), the
6821
+ * function consults a facade ancestry map built during the RSC build. If all
6822
+ * consuming facades belong to the same route boundary, the module is
6823
+ * reclassified to that boundary instead of falling through to `client-shared`.
6764
6824
  *
6765
- * See design/27-chunking-strategy.md, TIM-440, TIM-499.
6825
+ * See design/27-chunking-strategy.md, TIM-440, TIM-499, TIM-1104, TIM-1107.
6766
6826
  */
6767
6827
  /**
6768
6828
  * Check whether a root-relative path lives under the app directory,
@@ -6795,34 +6855,53 @@ function findAppBoundary(path, appPrefix) {
6795
6855
  };
6796
6856
  }
6797
6857
  /**
6858
+ * For a `shared:` client reference, check the facade ancestry map to
6859
+ * see if all consuming facades belong to the same route boundary.
6860
+ * Returns the boundary segment name, or null if the module is
6861
+ * genuinely shared across multiple boundaries.
6862
+ */
6863
+ function reclassifySharedModule(meta, appPrefix, root, sharedModuleFacades) {
6864
+ const facades = sharedModuleFacades.get(meta.id);
6865
+ if (!facades || facades.length === 0) return null;
6866
+ const boundaries = /* @__PURE__ */ new Set();
6867
+ for (const facade of facades) {
6868
+ const result = findAppBoundary(relative(root, facade).replace(/\\/g, "/"), appPrefix);
6869
+ if (result.inApp && result.segment) boundaries.add(result.segment);
6870
+ else if (result.inApp) return null;
6871
+ else return null;
6872
+ }
6873
+ if (boundaries.size === 1) return [...boundaries][0];
6874
+ return null;
6875
+ }
6876
+ /**
6798
6877
  * Derive a chunk group name for a client reference module.
6799
6878
  *
6800
- * Groups by the first non-group route segment under appDir so that all
6801
- * client components belonging to the same layout boundary land in one
6802
- * chunk. For example:
6879
+ * Always derives the group from the **consuming route** (`serverChunk`'s
6880
+ * facade path), not from the module's own file location. This prevents
6881
+ * components in shared directories (e.g. `app/components/`, `src/lib/`)
6882
+ * from collapsing into a single chunk when they're only used by one route.
6883
+ *
6884
+ * For `shared:` modules (non-facade RSC chunks), consults the facade
6885
+ * ancestry map to reclassify modules that are only consumed by a single
6886
+ * route boundary. This prevents large dependencies from polluting
6887
+ * `client-shared` when they're route-scoped (TIM-1107).
6888
+ *
6889
+ * Examples:
6803
6890
  * - `facade:app/dashboard/settings/page.tsx` → `"client-dashboard"`
6804
6891
  * - `facade:app/(group-a)/group-page-a/page.tsx` → `"client-group-page-a"`
6805
6892
  * - `facade:app/layout.tsx` (root layout) → `"client-shared"`
6806
- * - `shared:...` (shared across chunks) → `"client-shared"`
6807
- *
6808
- * When a 'use client' module lives outside the app directory (e.g. in
6809
- * src/posts/ or src/components/), the function falls back to deriving
6810
- * the layout boundary from the serverChunk's facade path. This prevents
6811
- * lazily-loaded client dependencies (e.g. via import.meta.glob) from
6812
- * being hoisted into the shared chunk when they're only used by a
6813
- * specific route.
6814
- *
6815
- * This balances route-scoped code splitting with HTTP request count:
6816
- * fewer chunks than per-serverChunk, but still avoids downloading every
6817
- * client component on every page.
6818
- */
6819
- function clientChunkGroup(meta, appDir, root) {
6820
- const { normalizedId, serverChunk } = meta;
6821
- if (serverChunk.startsWith("shared:")) return "client-shared";
6893
+ * - `shared:...` (shared across chunks) → reclassified or `"client-shared"`
6894
+ */
6895
+ function clientChunkGroup(meta, appDir, root, sharedModuleFacades) {
6896
+ const { serverChunk } = meta;
6822
6897
  const appPrefix = relative(root, appDir).replace(/\\/g, "/") + "/";
6823
- const result = findAppBoundary(normalizedId, appPrefix);
6824
- if (result.inApp && result.segment) return `client-${result.segment}`;
6825
- if (result.inApp) return "client-shared";
6898
+ if (serverChunk.startsWith("shared:")) {
6899
+ if (sharedModuleFacades) {
6900
+ const boundary = reclassifySharedModule(meta, appPrefix, root, sharedModuleFacades);
6901
+ if (boundary) return `client-${boundary}`;
6902
+ }
6903
+ return "client-shared";
6904
+ }
6826
6905
  if (serverChunk.startsWith("facade:")) {
6827
6906
  const facadePath = serverChunk.slice(7).replace(/\\/g, "/");
6828
6907
  const facadeResult = findAppBoundary(facadePath, appPrefix);
@@ -7397,6 +7476,14 @@ function buildRouteReport(entries, sharedSize) {
7397
7476
  lines.push("○ (Static) λ (Dynamic) ƒ (Function)");
7398
7477
  return lines;
7399
7478
  }
7479
+ /** Check route entries against a First-Load-JS budget and return warning lines. */
7480
+ function buildWarnings(entries, budget) {
7481
+ if (!budget?.firstLoadJs) return [];
7482
+ const limit = budget.firstLoadJs;
7483
+ const warnings = [];
7484
+ for (const entry of entries) if (entry.firstLoadSize > limit) warnings.push(`⚠ ${entry.path} first-load JS (${formatSize(entry.firstLoadSize)}) exceeds budget (${formatSize(limit)})`);
7485
+ return warnings;
7486
+ }
7400
7487
  function pad(str, width, align = "right") {
7401
7488
  const gap = Math.max(0, width - stripAnsi(str).length);
7402
7489
  return align === "left" ? " ".repeat(gap) + str : str + " ".repeat(gap);
@@ -7430,7 +7517,42 @@ function findChunkForFile(filePath, bundle) {
7430
7517
  }
7431
7518
  return null;
7432
7519
  }
7433
- function buildEntries(routeTree, chunkSizes, bundle, outputMode) {
7520
+ /**
7521
+ * Derive the expected client chunk group prefix for a route.
7522
+ *
7523
+ * Mirrors the `clientChunkGroup` logic: extract the first non-group segment
7524
+ * from the route's file path under appDir. Routes at the root level or
7525
+ * without a matching segment return null (their JS is in client-shared).
7526
+ */
7527
+ function routeChunkGroupPrefix(entryFilePath, appDir, root) {
7528
+ const appPrefix = relative(root, appDir).replace(/\\/g, "/") + "/";
7529
+ const relPath = relative(root, entryFilePath).replace(/\\/g, "/");
7530
+ if (!relPath.startsWith(appPrefix)) return null;
7531
+ const segments = relPath.slice(appPrefix.length).split("/");
7532
+ for (const seg of segments) {
7533
+ if (seg.includes(".")) break;
7534
+ if (seg.startsWith("(") && seg.endsWith(")")) continue;
7535
+ return `client-${seg}`;
7536
+ }
7537
+ return null;
7538
+ }
7539
+ /**
7540
+ * Find all client bundle chunks belonging to a chunk group.
7541
+ *
7542
+ * Uses the chunk's `name` property (set by Rolldown from the `clientChunks`
7543
+ * callback's return value) for exact matching. The `name` is the group name
7544
+ * itself (e.g. `client-docs`), not the hashed filename.
7545
+ */
7546
+ function findChunksForGroup(groupName, chunkSizes, bundle) {
7547
+ if (!bundle) return [];
7548
+ const matched = [];
7549
+ for (const [fileName, item] of Object.entries(bundle)) {
7550
+ if (item.type !== "chunk") continue;
7551
+ if (item.name === groupName && chunkSizes.has(fileName)) matched.push(fileName);
7552
+ }
7553
+ return matched;
7554
+ }
7555
+ function buildEntries(routeTree, chunkSizes, bundle, outputMode, appDir, root) {
7434
7556
  const routeInfos = collectRoutes(routeTree);
7435
7557
  let totalGzip = 0;
7436
7558
  for (const s of chunkSizes.values()) totalGzip += s.gzip;
@@ -7440,14 +7562,32 @@ function buildEntries(routeTree, chunkSizes, bundle, outputMode) {
7440
7562
  let raw = 0;
7441
7563
  let gzip = 0;
7442
7564
  if (info.entryFilePath) {
7443
- const chunk = findChunkForFile(info.entryFilePath, bundle);
7444
- if (chunk) {
7445
- const s = chunkSizes.get(chunk);
7446
- if (s) {
7447
- raw = s.raw;
7448
- gzip = s.gzip;
7565
+ let matched = false;
7566
+ if (appDir && root) {
7567
+ const groupPrefix = routeChunkGroupPrefix(info.entryFilePath, appDir, root);
7568
+ if (groupPrefix) {
7569
+ const chunks = findChunksForGroup(groupPrefix, chunkSizes, bundle);
7570
+ for (const chunk of chunks) {
7571
+ const s = chunkSizes.get(chunk);
7572
+ if (s) {
7573
+ raw += s.raw;
7574
+ gzip += s.gzip;
7575
+ }
7576
+ routeChunkFiles.add(chunk);
7577
+ }
7578
+ if (chunks.length > 0) matched = true;
7579
+ }
7580
+ }
7581
+ if (!matched) {
7582
+ const chunk = findChunkForFile(info.entryFilePath, bundle);
7583
+ if (chunk) {
7584
+ const s = chunkSizes.get(chunk);
7585
+ if (s) {
7586
+ raw = s.raw;
7587
+ gzip = s.gzip;
7588
+ }
7589
+ routeChunkFiles.add(chunk);
7449
7590
  }
7450
- routeChunkFiles.add(chunk);
7451
7591
  }
7452
7592
  }
7453
7593
  entries.push({
@@ -7525,11 +7665,16 @@ function timberBuildReport(ctx) {
7525
7665
  firstLoadSize: 0
7526
7666
  })),
7527
7667
  sharedGzip: 0
7528
- } : buildEntries(ctx.routeTree, chunkSizes, clientBundle, outputMode);
7668
+ } : buildEntries(ctx.routeTree, chunkSizes, clientBundle, outputMode, ctx.appDir, ctx.root);
7529
7669
  const elapsed = ((performance.now() - buildStart) / 1e3).toFixed(2);
7530
7670
  const lines = buildRouteReport(entries, sharedGzip);
7531
7671
  logger.info("");
7532
7672
  for (const line of lines) logger.info(line);
7673
+ const warnings = buildWarnings(entries, ctx.config.budget);
7674
+ if (warnings.length > 0) {
7675
+ logger.info("");
7676
+ for (const w of warnings) logger.warn(w);
7677
+ }
7533
7678
  logger.info("");
7534
7679
  logger.info(`✓ built ${entries.length} routes for all three environments (rsc, ssr, client) in ${elapsed}s`);
7535
7680
  logger.info("");
@@ -7931,7 +8076,7 @@ function createRscOptions(ctx, encryptionKeyExpr) {
7931
8076
  ssr: "virtual:timber-ssr-entry",
7932
8077
  client: "virtual:timber-browser-entry"
7933
8078
  },
7934
- clientChunks: (meta) => clientChunkGroup(meta, ctx.appDir, ctx.root)
8079
+ clientChunks: (meta) => clientChunkGroup(meta, ctx.appDir, ctx.root, ctx.sharedModuleFacades)
7935
8080
  };
7936
8081
  Object.defineProperty(options, "enableActionEncryption", {
7937
8082
  get() {
@@ -8092,7 +8237,7 @@ function timber(config) {
8092
8237
  timberContent(ctx),
8093
8238
  timberServerBundle(),
8094
8239
  timberCloudflareWasm(ctx),
8095
- timberChunks(),
8240
+ timberChunks(ctx),
8096
8241
  timberBuildReport(ctx),
8097
8242
  timberAdapterBuild(ctx),
8098
8243
  timberDevLogs(ctx),