@pylonsync/functions 0.22.50 → 0.22.59

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.
@@ -42,6 +42,9 @@ import {
42
42
  type ManifestBuildConfig,
43
43
  } from "./build-compat";
44
44
  import { serverBundle } from "./server-bundle";
45
+ import { declaresClientNavigation } from "./ssr-client-nav";
46
+ import { precompressBuild } from "./ssr-precompress";
47
+ import { parseNamespaceBarrel, rewriteBarrelImports } from "./ssr-barrel-imports";
45
48
  import { buildOfflineWorker, readManifestOffline, type BundleOffline } from "./ssr-offline";
46
49
 
47
50
  type Send = (msg: Record<string, unknown>) => void;
@@ -353,7 +356,7 @@ ${entries}
353
356
  * exposes `window.__pylon` for the `<Link>` component to call
354
357
  * directly (prefetch, navigate).
355
358
  */
356
- const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime).
359
+ export const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime).
357
360
  // DO NOT EDIT — overwritten on every pylon dev / build.
358
361
 
359
362
  import { createElement, Fragment, Suspense } from "react";
@@ -363,6 +366,7 @@ import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary
363
366
  import { LOADING_MODULES } from "./loading-registry";
364
367
  import { createNavPayloadCache } from "./nav-cache";
365
368
  import { matchRoute, prefetchTargets } from "./route-match";
369
+ import { clientRouteFor, isOfflinePath } from "./client-nav";
366
370
  import { withMetaSlot } from "./page-tree";
367
371
 
368
372
  const routeCache = Object.create(null);
@@ -648,6 +652,26 @@ function whenLoaded(fn) {
648
652
  window.addEventListener("load", fn, { once: true });
649
653
  }
650
654
 
655
+ // Run fn when the browser is idle after the page loaded, so warming the next
656
+ // route never competes with this one.
657
+ function whenIdle(fn) {
658
+ whenLoaded(() => {
659
+ if (typeof window.requestIdleCallback === "function") {
660
+ window.requestIdleCallback(fn, { timeout: 2000 });
661
+ } else {
662
+ setTimeout(fn, 200);
663
+ }
664
+ });
665
+ }
666
+
667
+ // Import a client-navigable route's entry ahead of the click, so navigate()
668
+ // finds it in routeCache and renders in the same frame. Only after the first
669
+ // hydration: an entry that loads before it would be taken for the page.
670
+ function warmRouteEntry(component) {
671
+ if (!activeRoot || routeCache[component]) return;
672
+ loadRouteEntry(component).catch(() => {});
673
+ }
674
+
651
675
  // Marks a request as a client-side NAVIGATION rather than a document load.
652
676
  // The server answers with the head metadata + __PYLON_DATA__ and skips the
653
677
  // page markup, which this runtime re-renders from that data anyway. The host
@@ -675,25 +699,43 @@ const navPayloads = createNavPayloadCache({
675
699
  async function prefetch(href, opts) {
676
700
  const url = new URL(href, location.href);
677
701
  if (url.origin !== location.origin) return;
702
+ const manifest = await loadManifest();
703
+ // A route that renders from the client never fetches its page, so there is
704
+ // no payload to warm. Its entry is warmed instead, below.
705
+ const clientRoute = clientRouteFor(manifest, url.pathname, matchRoute);
678
706
  // The page payload, but ONLY on a real intent signal (hover / touch). It
679
707
  // costs a full SSR render, so warming a screenful of links on sight would
680
708
  // spend a dozen renders per page load to save one.
681
- if (opts && opts.document) {
709
+ if (opts && opts.document && !clientRoute) {
682
710
  navPayloads.prefetch(url.pathname + url.search);
683
711
  }
684
- const manifest = await loadManifest();
685
712
  if (!manifest) return;
686
713
  // Chunk prefetch. The page payload above is the half that was already
687
714
  // cheap: nothing can render until the destination's own entry chunk lands,
688
715
  // so warming only the HTML leaves a full round-trip on the click.
689
716
  const targets = prefetchTargets(manifest, url.pathname);
690
- whenLoaded(() =>
717
+ const warm = () => {
691
718
  preloadChunks({
692
719
  public_prefix: manifest.public_prefix,
693
720
  file: targets.file,
694
721
  imports: targets.imports,
695
- }),
696
- );
722
+ });
723
+ if (clientRoute) warmRouteEntry(clientRoute.component);
724
+ };
725
+ // Intent (hover, touch, pointerdown, focus): the tap is coming, fetch now.
726
+ // Otherwise (a link on screen) wait until the page has painted and the
727
+ // browser is idle, so warming routes never counts against this page's
728
+ // first paint, and skip it on a connection the user asked to save.
729
+ if (opts && opts.document) warm();
730
+ else if (!saveData()) whenIdle(warm);
731
+ }
732
+
733
+ // The user asked for less data (Save-Data), or the connection is 2G.
734
+ // Prefetch on sight costs bytes the user may never use.
735
+ function saveData() {
736
+ const c = typeof navigator !== "undefined" ? navigator.connection : undefined;
737
+ if (!c) return false;
738
+ return c.saveData === true || /(^|-)2g$/.test(c.effectiveType || "");
697
739
  }
698
740
 
699
741
  async function loadRouteEntry(component) {
@@ -713,9 +755,10 @@ async function loadRouteEntry(component) {
713
755
  return routeCache[component];
714
756
  }
715
757
 
716
- export function hydrate(component, Page, Layouts) {
717
- // Always cache the route for nav.
718
- routeCache[component] = { Page, Layouts };
758
+ export function hydrate(component, Page, Layouts, metadata) {
759
+ // Always cache the route for nav. \`metadata\` is the page's static
760
+ // \`metadata\` export: a navigation with no page fetch takes its title.
761
+ routeCache[component] = { Page, Layouts, metadata };
719
762
  // Start the manifest fetch NOW rather than on first use. Nothing can resolve
720
763
  // a route without it — the first <Link> reaching the viewport warms its
721
764
  // chunks only after it lands — so leaving it lazy put a round trip in front
@@ -774,10 +817,17 @@ export function hydrate(component, Page, Layouts) {
774
817
  installNavHandlers();
775
818
  // Registers the offline service worker for an app that opted in, and
776
819
  // removes one left on this origin by an earlier build otherwise.
820
+ // \`build\` is the build this page runs, for useAppUpdate. An option
821
+ // rather than its own import, so an app on an older @pylonsync/react
822
+ // still builds.
777
823
  registerOfflineWorker({
778
824
  optedIn: !!data.offline,
779
825
  production: !(data.offline && data.offline.dev),
780
826
  signedIn: !!(data.props && data.props.session && data.props.session.exists),
827
+ build:
828
+ data.offline && data.offline.version
829
+ ? { version: data.offline.version, builtAt: data.offline.builtAt || null }
830
+ : undefined,
781
831
  });
782
832
  if (data.offline) {
783
833
  // The service worker answered this load with the page it kept for
@@ -885,6 +935,32 @@ async function navigate(href, opts) {
885
935
  // Flipped the moment this nav commits or gives up, so a pending-placeholder
886
936
  // timer that fires late can never paint a skeleton over real content.
887
937
  const navState = { settled: false };
938
+
939
+ // ---- Instant navigation --------------------------------------------------
940
+ // A route whose page exports \`navigation = "client"\`, or whose URL is an
941
+ // offline route, renders from the client with no page fetch: its data comes
942
+ // from the local replica. The manifest is in hand after hydration, and a
943
+ // Link warms the entry ahead of the click, so this paints in the same frame.
944
+ if (activeRoot) {
945
+ const manifest = await loadManifest();
946
+ if (myEpoch !== navEpoch) return;
947
+ const matched = clientRouteFor(manifest, url.pathname, matchRoute);
948
+ if (matched) {
949
+ let route = null;
950
+ try {
951
+ route = await loadRouteEntry(matched.component);
952
+ } catch (e) {
953
+ // Fall through to the fetch below, which reloads the page when the
954
+ // entry cannot load.
955
+ console.warn("[pylon ssr] client navigation fell back to a page fetch:", e);
956
+ }
957
+ if (myEpoch !== navEpoch) return;
958
+ if (route) {
959
+ renderFromClient(url, matched, route, myEpoch, opts, push);
960
+ return;
961
+ }
962
+ }
963
+ }
888
964
  const fullLoad = () => {
889
965
  navState.settled = true;
890
966
  window.location.href = href;
@@ -1076,16 +1152,15 @@ async function navigate(href, opts) {
1076
1152
  }
1077
1153
  }
1078
1154
 
1079
- // Render an offline route with no server: the page reads its data from the
1080
- // local replica (db.useQuery), so it needs only its code, its params and the
1081
- // identity-free props the last page had. Returns false when the URL is not an
1082
- // offline route, or its code is not on the device.
1155
+ // Render an offline route with no server, after the page fetch failed: the
1156
+ // page reads its data from the local replica (db.useQuery), so it needs only
1157
+ // its code, its params and the identity-free props the last page had. Returns
1158
+ // false when the URL is not an offline route, or its code is not on the
1159
+ // device.
1083
1160
  async function renderOffline(url, myEpoch, opts, push) {
1084
1161
  const manifest = await loadManifest();
1085
- if (!manifest || !manifest.offline || !activeRoot) return false;
1086
- const offlineRoutes = {};
1087
- for (const r of manifest.offline.routes || []) offlineRoutes[r] = { path: r };
1088
- if (!matchRoute({ routes: offlineRoutes }, url.pathname)) return false;
1162
+ if (!manifest || !activeRoot) return false;
1163
+ if (!isOfflinePath(manifest, url.pathname, matchRoute)) return false;
1089
1164
  const matched = matchRoute(manifest, url.pathname);
1090
1165
  if (!matched) return false;
1091
1166
  let route;
@@ -1095,6 +1170,14 @@ async function renderOffline(url, myEpoch, opts, push) {
1095
1170
  return false;
1096
1171
  }
1097
1172
  if (myEpoch !== navEpoch) return true;
1173
+ renderFromClient(url, matched, route, myEpoch, opts, push);
1174
+ return true;
1175
+ }
1176
+
1177
+ // Commit a navigation rendered from the client (instant navigation, or an
1178
+ // offline route with no network). History, the navigation event and the
1179
+ // scroll reset are the same as a fetched navigation's.
1180
+ function renderFromClient(url, matched, route, myEpoch, opts, push) {
1098
1181
  const prev = currentPageProps || {};
1099
1182
  const searchParams = {};
1100
1183
  url.searchParams.forEach((v, k) => {
@@ -1122,10 +1205,17 @@ async function renderOffline(url, myEpoch, opts, push) {
1122
1205
  data.component,
1123
1206
  myEpoch,
1124
1207
  );
1208
+ const target = url.pathname + url.search + url.hash;
1209
+ // See the fetched path: a commit that throws falls back to a page load.
1210
+ pendingNav = target;
1125
1211
  currentComponent = data.component;
1126
1212
  navPayloads.clear();
1213
+ const title = route.metadata && route.metadata.title;
1214
+ if (typeof title === "string" && title) document.title = title;
1127
1215
  activeRoot.render(tree);
1128
- const target = url.pathname + url.search + url.hash;
1216
+ setTimeout(() => {
1217
+ if (pendingNav === target) pendingNav = null;
1218
+ }, 0);
1129
1219
  if (opts && opts.replace) {
1130
1220
  history.replaceState({ component: data.component }, "", target);
1131
1221
  } else if (push) {
@@ -1134,7 +1224,6 @@ async function renderOffline(url, myEpoch, opts, push) {
1134
1224
  window.dispatchEvent(new Event("pylon:navigation"));
1135
1225
  renderedPath = location.pathname + location.search;
1136
1226
  window.scrollTo(0, 0);
1137
- return true;
1138
1227
  }
1139
1228
 
1140
1229
  function installNavHandlers() {
@@ -1234,6 +1323,64 @@ if (typeof window !== "undefined") {
1234
1323
  }
1235
1324
  `;
1236
1325
 
1326
+ /**
1327
+ * Write the client runtime (`client-runtime.ts`) and the sibling modules it
1328
+ * imports into `stageDir`. Each sibling's source of truth is this package's
1329
+ * real, unit-tested module (ssr-client-boundary.ts and so on), not an inline
1330
+ * string. The app-specific `loading-registry.ts` and route entries are
1331
+ * written by the caller.
1332
+ */
1333
+ export async function writeClientRuntimeModules(fs: any, path: any, stageDir: string): Promise<void> {
1334
+ fs.writeFileSync(path.join(stageDir, "client-runtime.ts"), CLIENT_RUNTIME_SOURCE, "utf8");
1335
+ // Resolve THIS module's directory from the standard `import.meta.url`
1336
+ // (via node:url) rather than Bun's `import.meta.dir`: the latter is a
1337
+ // Bun-only extension that `tsc` doesn't know about, so any app that
1338
+ // imports `@pylonsync/functions` and runs `tsc` would otherwise fail
1339
+ // type-checking on this file. `import.meta.url` works in Bun and Node.
1340
+ const urlMod: any = await import("node:url");
1341
+ const fileURLToPath = urlMod.fileURLToPath ?? urlMod.default?.fileURLToPath;
1342
+ const here = path.dirname(fileURLToPath(import.meta.url));
1343
+ const siblings: Array<[string, string]> = [
1344
+ // The not-found/error boundary (`./client-boundary`).
1345
+ ["client-boundary.ts", "ssr-client-boundary.ts"],
1346
+ // The page's tree shape, shared with the server render (`./page-tree`).
1347
+ ["page-tree.ts", "ssr-page-tree.ts"],
1348
+ // The route matcher for optimistic navigation (`./route-match`).
1349
+ ["route-match.ts", "ssr-route-match.ts"],
1350
+ // The prefetch payload cache (`./nav-cache`).
1351
+ ["nav-cache.ts", "ssr-nav-cache.ts"],
1352
+ // Which routes navigate with no page fetch (`./client-nav`).
1353
+ ["client-nav.ts", "ssr-client-nav.ts"],
1354
+ ];
1355
+ for (const [staged, source] of siblings) {
1356
+ fs.writeFileSync(
1357
+ path.join(stageDir, staged),
1358
+ fs.readFileSync(path.join(here, source), "utf8"),
1359
+ "utf8",
1360
+ );
1361
+ }
1362
+ }
1363
+
1364
+ /** A component's source (`app/leads/page` → the .tsx/.ts/.jsx/.js file), or
1365
+ * "" when none is found. */
1366
+ function readComponentSource(fs: any, path: any, cwd: string, component: string): string {
1367
+ for (const ext of ["tsx", "ts", "jsx", "js"]) {
1368
+ const file = path.join(cwd, `${component}.${ext}`);
1369
+ if (fs.existsSync(file)) return fs.readFileSync(file, "utf8");
1370
+ }
1371
+ return "";
1372
+ }
1373
+
1374
+ /** Whether a page exports a static `metadata` object. The route entry passes
1375
+ * it to the runtime, which takes the title from it on a navigation with no
1376
+ * page fetch. A static scan, so a page without one imports nothing extra. */
1377
+ export function exportsStaticMetadata(source: string): boolean {
1378
+ return (
1379
+ /\bexport\s+(?:const|let|var)\s+metadata\b/.test(source) ||
1380
+ /\bexport\s*\{[^}]*\bmetadata\b[^}]*\}/.test(source)
1381
+ );
1382
+ }
1383
+
1237
1384
  /**
1238
1385
  * Per-route entry source. Stays tiny on purpose — react /
1239
1386
  * react-dom / client-runtime get hoisted into the shared chunk
@@ -1245,7 +1392,10 @@ if (typeof window !== "undefined") {
1245
1392
  * `.pylon/client-entry-app__hello__page.tsx` — flat namespace
1246
1393
  * keyed on the slug we'd already need anyway for the manifest.
1247
1394
  */
1248
- function generateRouteEntry(route: DiscoveredRoute): string {
1395
+ function generateRouteEntry(
1396
+ route: DiscoveredRoute,
1397
+ opts: { metadata?: boolean } = {},
1398
+ ): string {
1249
1399
  const layoutImports = route.layouts
1250
1400
  .map((l, i) => `import L${i} from "${cwd_to_import(l)}";`)
1251
1401
  .join("\n");
@@ -1254,10 +1404,10 @@ function generateRouteEntry(route: DiscoveredRoute): string {
1254
1404
  // DO NOT EDIT — overwritten on every pylon dev / build.
1255
1405
 
1256
1406
  import { hydrate } from "./client-runtime";
1257
- import Page from "${cwd_to_import(route.component)}";
1407
+ import Page${opts.metadata ? ", { metadata }" : ""} from "${cwd_to_import(route.component)}";
1258
1408
  ${layoutImports}
1259
1409
 
1260
- hydrate(${JSON.stringify(route.component)}, Page, [${layoutArray}]);
1410
+ hydrate(${JSON.stringify(route.component)}, Page, [${layoutArray}]${opts.metadata ? ", metadata" : ""});
1261
1411
  `;
1262
1412
  }
1263
1413
 
@@ -1317,6 +1467,9 @@ export interface PylonBundleManifest {
1317
1467
  /** URL pattern (e.g. `/p/[slug]`) — page routes only. Lets the client
1318
1468
  * matcher resolve an href to this route for optimistic navigation. */
1319
1469
  path?: string;
1470
+ /** `"client"` when the page exports \`navigation = "client"\`: the
1471
+ * runtime renders it with no page fetch (ssr-client-nav.ts). */
1472
+ navigation?: "client";
1320
1473
  }
1321
1474
  >;
1322
1475
  /** Self-hosted fonts (next/font parity): structured `@font-face`s + the
@@ -1449,6 +1602,10 @@ export interface ClientBuildOptions {
1449
1602
  /** The app manifest (fonts, build settings). Default
1450
1603
  * `<cwd>/pylon.manifest.json`. */
1451
1604
  manifestPath?: string;
1605
+ /** Write brotli and gzip copies of the build's text files for the
1606
+ * runtime to send. Default: on, except in dev (`NODE_ENV=development`),
1607
+ * where every edit rebuilds and the browser is local. */
1608
+ precompress?: boolean;
1452
1609
  }
1453
1610
 
1454
1611
  /** The `build` block of `<cwd>/pylon.manifest.json`, if any. */
@@ -1652,55 +1809,7 @@ export async function _doBuildInner(
1652
1809
  // The shared runtime + per-route entries. We track which file
1653
1810
  // each entry corresponds to so we can match metafile.outputs
1654
1811
  // back to the original route afterwards.
1655
- const runtimePath = path.join(stageDir, "client-runtime.ts");
1656
- fs.writeFileSync(runtimePath, CLIENT_RUNTIME_SOURCE, "utf8");
1657
-
1658
- // Emit the not-found/error boundary as a sibling module the runtime
1659
- // imports (`./client-boundary`). Its source is this package's real
1660
- // ssr-client-boundary.ts — ONE source of truth, unit-tested directly,
1661
- // rather than an inline string in CLIENT_RUNTIME_SOURCE that nothing
1662
- // could render. Bun pulls it into the shared chunk via the runtime's
1663
- // static `import { createPylonBoundary } from "./client-boundary"`.
1664
- //
1665
- // Resolve THIS module's directory from the standard `import.meta.url`
1666
- // (via node:url) rather than Bun's `import.meta.dir` — the latter is a
1667
- // Bun-only extension that `tsc` doesn't know about, so any app that
1668
- // imports `@pylonsync/functions` and runs `tsc` would otherwise fail
1669
- // type-checking on this file. `import.meta.url` works in Bun and Node.
1670
- const urlMod: any = await import("node:url");
1671
- const fileURLToPath = urlMod.fileURLToPath ?? urlMod.default?.fileURLToPath;
1672
- const here = path.dirname(fileURLToPath(import.meta.url));
1673
- fs.writeFileSync(
1674
- path.join(stageDir, "client-boundary.ts"),
1675
- fs.readFileSync(path.join(here, "ssr-client-boundary.ts"), "utf8"),
1676
- "utf8",
1677
- );
1678
-
1679
- // Same pattern for the page's tree shape, which the server render
1680
- // shares (ssr-page-tree.ts): the runtime imports it as `./page-tree`.
1681
- fs.writeFileSync(
1682
- path.join(stageDir, "page-tree.ts"),
1683
- fs.readFileSync(path.join(here, "ssr-page-tree.ts"), "utf8"),
1684
- "utf8",
1685
- );
1686
-
1687
- // Same pattern for the optimistic-navigation route matcher — the runtime
1688
- // imports it as `./route-match`; its source of truth is ssr-route-match.ts,
1689
- // unit-tested directly.
1690
- fs.writeFileSync(
1691
- path.join(stageDir, "route-match.ts"),
1692
- fs.readFileSync(path.join(here, "ssr-route-match.ts"), "utf8"),
1693
- "utf8",
1694
- );
1695
-
1696
- // Same pattern for the prefetch payload cache — the runtime imports it as
1697
- // `./nav-cache`; its source of truth is ssr-nav-cache.ts, unit-tested
1698
- // directly (expiry, eviction, single-use).
1699
- fs.writeFileSync(
1700
- path.join(stageDir, "nav-cache.ts"),
1701
- fs.readFileSync(path.join(here, "ssr-nav-cache.ts"), "utf8"),
1702
- "utf8",
1703
- );
1812
+ await writeClientRuntimeModules(fs, path, stageDir);
1704
1813
 
1705
1814
  // The app's loading.tsx modules, gathered into one module the runtime
1706
1815
  // imports statically (`./loading-registry`) so a pending navigation can
@@ -1718,10 +1827,18 @@ export async function _doBuildInner(
1718
1827
  const entryPaths: string[] = [];
1719
1828
  // entryPath (absolute) → component path (for manifest lookup).
1720
1829
  const entryToComponent = new Map<string, string>();
1830
+ // Pages that export `navigation = "client"` (instant navigation).
1831
+ const clientNavigation = new Set<string>();
1721
1832
  for (const r of routes) {
1722
1833
  const slug = slugForComponent(r.component);
1723
1834
  const entryPath = path.join(stageDir, `client-entry-${slug}.tsx`);
1724
- fs.writeFileSync(entryPath, generateRouteEntry(r), "utf8");
1835
+ const source = readComponentSource(fs, path, cwd, r.component);
1836
+ if (r.pattern && declaresClientNavigation(source)) clientNavigation.add(r.component);
1837
+ fs.writeFileSync(
1838
+ entryPath,
1839
+ generateRouteEntry(r, { metadata: exportsStaticMetadata(source) }),
1840
+ "utf8",
1841
+ );
1725
1842
  entryPaths.push(entryPath);
1726
1843
  entryToComponent.set(entryPath, r.component);
1727
1844
  }
@@ -1776,6 +1893,7 @@ export async function _doBuildInner(
1776
1893
  // to the browser.
1777
1894
  plugins: [
1778
1895
  absoluteAssetPlugin(outdir, fs, path, (await import("node:crypto")).createHash),
1896
+ ...[barrelImportsPlugin(fs, path, cwd)].filter((p) => p !== null),
1779
1897
  {
1780
1898
  name: "pylon-server-only",
1781
1899
  setup(build) {
@@ -1969,6 +2087,7 @@ export async function _doBuildInner(
1969
2087
  // Page routes carry their URL pattern for the client route matcher;
1970
2088
  // boundary modules (no pattern) are omitted so they never match.
1971
2089
  ...(r.pattern ? { path: r.pattern } : {}),
2090
+ ...(clientNavigation.has(r.component) ? { navigation: "client" as const } : {}),
1972
2091
  };
1973
2092
  }
1974
2093
 
@@ -2066,7 +2185,15 @@ export async function _doBuildInner(
2066
2185
  try {
2067
2186
  const offline = readManifestOffline(fs, path, cwd, manifestFile);
2068
2187
  if (offline) {
2069
- manifest.offline = await buildOfflineWorker(fs, path, outdir, manifest, offline);
2188
+ manifest.offline = await buildOfflineWorker(
2189
+ fs,
2190
+ path,
2191
+ outdir,
2192
+ manifest,
2193
+ offline,
2194
+ cwd,
2195
+ appDirRel,
2196
+ );
2070
2197
  }
2071
2198
  } catch (oErr: any) {
2072
2199
  if (opts.strict) throw new Error(`offline worker build failed: ${oErr?.message ?? oErr}`);
@@ -2117,6 +2244,21 @@ export async function _doBuildInner(
2117
2244
  const manifestPath = path.join(outdir, "manifest.json");
2118
2245
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
2119
2246
 
2247
+ // Last, so the copies cover every file above (CSS, fonts' CSS, the
2248
+ // service worker, the manifest).
2249
+ const precompress = opts.precompress ?? process.env.NODE_ENV !== "development";
2250
+ if (precompress) {
2251
+ const zlib: any = await import("node:zlib");
2252
+ const { files, bytes, brotliBytes } = precompressBuild(fs, path, zlib.default ?? zlib, outdir);
2253
+ if (files > 0) {
2254
+ // eslint-disable-next-line no-console
2255
+ console.log(
2256
+ `[pylon ssr] precompressed ${files} files: ${Math.round(bytes / 1024)} KB, ` +
2257
+ `${Math.round(brotliBytes / 1024)} KB with brotli`,
2258
+ );
2259
+ }
2260
+ }
2261
+
2120
2262
  // Bump our in-process manifest cache so SSR re-reads on next request.
2121
2263
  _manifestCache = null;
2122
2264
 
@@ -2230,6 +2372,106 @@ const ASSET_RE = /\.(wasm|png|jpe?g|gif|webp|avif|ico|svg|woff2?|ttf|otf|mp3|mp4
2230
2372
  * the absolute URL. A stylesheet's `url(...)` is left to Bun: CSS cannot
2231
2373
  * import a JavaScript module.
2232
2374
  */
2375
+ /**
2376
+ * Rewrite named imports from namespace barrels (`radix-ui`) in app source
2377
+ * into imports of the re-exported packages, so a route bundles only the
2378
+ * ones it uses. See ssr-barrel-imports.ts.
2379
+ *
2380
+ * The app's files are scanned before the build and the plugin loads only
2381
+ * the files whose source changes. An onLoad that matches a file changes how
2382
+ * Bun resolves that file's own imports (a workspace package's CommonJS
2383
+ * dependency stopped resolving when every file went through one), so the
2384
+ * filter names exactly those files.
2385
+ */
2386
+ function barrelImportsPlugin(
2387
+ fs: typeof import("node:fs"),
2388
+ path: typeof import("node:path"),
2389
+ cwd: string,
2390
+ ): { name: string; setup(build: BunPluginBuild): void } | null {
2391
+ const resolve = (spec: string, from: string): string | null => {
2392
+ try {
2393
+ return (Bun as any).resolveSync(spec, from) as string;
2394
+ } catch {
2395
+ return null;
2396
+ }
2397
+ };
2398
+ // Barrel entry file → its namespaces (null: not a barrel).
2399
+ const parsed = new Map<string, Map<string, string> | null>();
2400
+ const rewritten = new Map<string, string>();
2401
+ const SOURCE = /\.(tsx|ts|jsx|js|mjs)$/;
2402
+ const NAMED_BARE_IMPORT = /import\s*\{[^}]*\}\s*from\s*["'][^"'./]/;
2403
+ const walk = (dir: string) => {
2404
+ let entries: import("node:fs").Dirent[];
2405
+ try {
2406
+ entries = fs.readdirSync(dir, { withFileTypes: true });
2407
+ } catch {
2408
+ return;
2409
+ }
2410
+ for (const entry of entries) {
2411
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
2412
+ const full = path.join(dir, entry.name);
2413
+ if (entry.isDirectory()) {
2414
+ walk(full);
2415
+ continue;
2416
+ }
2417
+ if (!entry.isFile() || !SOURCE.test(entry.name)) continue;
2418
+ const source = fs.readFileSync(full, "utf8");
2419
+ if (!NAMED_BARE_IMPORT.test(source)) continue;
2420
+ const fileDir = path.dirname(full);
2421
+ const entriesBySpec = new Map<string, string>();
2422
+ const barrelOf = (spec: string) => {
2423
+ const entryFile = resolve(spec, fileDir);
2424
+ if (!entryFile || !entryFile.split(path.sep).includes("node_modules")) return null;
2425
+ entriesBySpec.set(spec, entryFile);
2426
+ if (!parsed.has(entryFile)) {
2427
+ let map: Map<string, string> | null = null;
2428
+ try {
2429
+ map = parseNamespaceBarrel(fs.readFileSync(entryFile, "utf8"));
2430
+ } catch {
2431
+ map = null;
2432
+ }
2433
+ parsed.set(entryFile, map);
2434
+ }
2435
+ return parsed.get(entryFile) ?? null;
2436
+ };
2437
+ // The re-exported package as the barrel resolves it; the bare name
2438
+ // when this file resolves it to the same file.
2439
+ const target = (barrel: string, spec: string) => {
2440
+ const fromBarrel = resolve(spec, path.dirname(entriesBySpec.get(barrel)!));
2441
+ if (!fromBarrel) return spec;
2442
+ return resolve(spec, fileDir) === fromBarrel ? spec : fromBarrel;
2443
+ };
2444
+ const out = rewriteBarrelImports(source, barrelOf, target);
2445
+ if (out !== source) {
2446
+ // Bun may name the file by either path when the app dir sits
2447
+ // behind a symlink (/var and /private/var on macOS).
2448
+ rewritten.set(full, out);
2449
+ rewritten.set(fs.realpathSync(full), out);
2450
+ }
2451
+ }
2452
+ };
2453
+ walk(cwd);
2454
+ if (rewritten.size === 0) return null;
2455
+ const escape = (p: string) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2456
+ const filter = new RegExp(`^(?:${[...rewritten.keys()].map(escape).join("|")})$`);
2457
+ const loaders: Record<string, "tsx" | "ts" | "jsx" | "js"> = {
2458
+ ".tsx": "tsx",
2459
+ ".ts": "ts",
2460
+ ".jsx": "jsx",
2461
+ ".js": "js",
2462
+ ".mjs": "js",
2463
+ };
2464
+ return {
2465
+ name: "pylon-barrel-imports",
2466
+ setup(build) {
2467
+ build.onLoad({ filter }, (args) => {
2468
+ const contents = rewritten.get(args.path) ?? fs.readFileSync(args.path, "utf8");
2469
+ return { contents, loader: loaders[path.extname(args.path)] ?? "tsx" } as any;
2470
+ });
2471
+ },
2472
+ };
2473
+ }
2474
+
2233
2475
  function absoluteAssetPlugin(
2234
2476
  outdir: string,
2235
2477
  fs: typeof import("node:fs"),