@pylonsync/functions 0.22.47 → 0.22.58

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,10 @@ 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";
48
+ import { buildOfflineWorker, readManifestOffline, type BundleOffline } from "./ssr-offline";
45
49
 
46
50
  type Send = (msg: Record<string, unknown>) => void;
47
51
 
@@ -352,16 +356,17 @@ ${entries}
352
356
  * exposes `window.__pylon` for the `<Link>` component to call
353
357
  * directly (prefetch, navigate).
354
358
  */
355
- 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).
356
360
  // DO NOT EDIT — overwritten on every pylon dev / build.
357
361
 
358
362
  import { createElement, Fragment, Suspense } from "react";
359
363
  import { hydrateRoot } from "react-dom/client";
360
- import { configureClient } from "@pylonsync/react";
364
+ import { configureClient, registerOfflineWorker } from "@pylonsync/react";
361
365
  import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
362
366
  import { LOADING_MODULES } from "./loading-registry";
363
367
  import { createNavPayloadCache } from "./nav-cache";
364
368
  import { matchRoute, prefetchTargets } from "./route-match";
369
+ import { clientRouteFor, isOfflinePath } from "./client-nav";
365
370
  import { withMetaSlot } from "./page-tree";
366
371
 
367
372
  const routeCache = Object.create(null);
@@ -647,6 +652,26 @@ function whenLoaded(fn) {
647
652
  window.addEventListener("load", fn, { once: true });
648
653
  }
649
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
+
650
675
  // Marks a request as a client-side NAVIGATION rather than a document load.
651
676
  // The server answers with the head metadata + __PYLON_DATA__ and skips the
652
677
  // page markup, which this runtime re-renders from that data anyway. The host
@@ -674,25 +699,43 @@ const navPayloads = createNavPayloadCache({
674
699
  async function prefetch(href, opts) {
675
700
  const url = new URL(href, location.href);
676
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);
677
706
  // The page payload, but ONLY on a real intent signal (hover / touch). It
678
707
  // costs a full SSR render, so warming a screenful of links on sight would
679
708
  // spend a dozen renders per page load to save one.
680
- if (opts && opts.document) {
709
+ if (opts && opts.document && !clientRoute) {
681
710
  navPayloads.prefetch(url.pathname + url.search);
682
711
  }
683
- const manifest = await loadManifest();
684
712
  if (!manifest) return;
685
713
  // Chunk prefetch. The page payload above is the half that was already
686
714
  // cheap: nothing can render until the destination's own entry chunk lands,
687
715
  // so warming only the HTML leaves a full round-trip on the click.
688
716
  const targets = prefetchTargets(manifest, url.pathname);
689
- whenLoaded(() =>
717
+ const warm = () => {
690
718
  preloadChunks({
691
719
  public_prefix: manifest.public_prefix,
692
720
  file: targets.file,
693
721
  imports: targets.imports,
694
- }),
695
- );
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 || "");
696
739
  }
697
740
 
698
741
  async function loadRouteEntry(component) {
@@ -712,9 +755,10 @@ async function loadRouteEntry(component) {
712
755
  return routeCache[component];
713
756
  }
714
757
 
715
- export function hydrate(component, Page, Layouts) {
716
- // Always cache the route for nav.
717
- 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 };
718
762
  // Start the manifest fetch NOW rather than on first use. Nothing can resolve
719
763
  // a route without it — the first <Link> reaching the viewport warms its
720
764
  // chunks only after it lands — so leaving it lazy put a round trip in front
@@ -771,6 +815,32 @@ export function hydrate(component, Page, Layouts) {
771
815
  },
772
816
  });
773
817
  installNavHandlers();
818
+ // Registers the offline service worker for an app that opted in, and
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.
823
+ registerOfflineWorker({
824
+ optedIn: !!data.offline,
825
+ production: !(data.offline && data.offline.dev),
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,
831
+ });
832
+ if (data.offline) {
833
+ // The service worker answered this load with the page it kept for
834
+ // another URL: a dynamic offline route, or no network. The page on
835
+ // screen is that URL's; render the address bar's URL from the replica.
836
+ const shellPath = data.props && data.props.pathname;
837
+ if (typeof shellPath === "string" && shellPath !== location.pathname) {
838
+ navigate(location.pathname + location.search + location.hash, {
839
+ replace: true,
840
+ shellPath,
841
+ });
842
+ }
843
+ }
774
844
  return;
775
845
  }
776
846
  // Subsequent hydrate calls fire from dynamic-loaded entries
@@ -865,6 +935,32 @@ async function navigate(href, opts) {
865
935
  // Flipped the moment this nav commits or gives up, so a pending-placeholder
866
936
  // timer that fires late can never paint a skeleton over real content.
867
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
+ }
868
964
  const fullLoad = () => {
869
965
  navState.settled = true;
870
966
  window.location.href = href;
@@ -959,6 +1055,24 @@ async function navigate(href, opts) {
959
1055
  html = await res.text();
960
1056
  }
961
1057
  } catch {
1058
+ // No network. An offline route renders from the replica; anything else
1059
+ // needs the server.
1060
+ if (myEpoch !== navEpoch) return;
1061
+ if (await renderOffline(url, myEpoch, opts, push)) {
1062
+ navState.settled = true;
1063
+ if (placeholderTimer) clearTimeout(placeholderTimer);
1064
+ return;
1065
+ }
1066
+ if (opts && opts.shellPath) {
1067
+ // This load already came from a kept page and the URL cannot open
1068
+ // offline. Reloading would get the same kept page again; show its URL.
1069
+ navState.settled = true;
1070
+ if (placeholderTimer) clearTimeout(placeholderTimer);
1071
+ history.replaceState({ component: currentComponent }, "", opts.shellPath);
1072
+ renderedPath = location.pathname + location.search;
1073
+ window.dispatchEvent(new Event("pylon:navigation"));
1074
+ return;
1075
+ }
962
1076
  fullLoad();
963
1077
  return;
964
1078
  }
@@ -1038,6 +1152,80 @@ async function navigate(href, opts) {
1038
1152
  }
1039
1153
  }
1040
1154
 
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.
1160
+ async function renderOffline(url, myEpoch, opts, push) {
1161
+ const manifest = await loadManifest();
1162
+ if (!manifest || !activeRoot) return false;
1163
+ if (!isOfflinePath(manifest, url.pathname, matchRoute)) return false;
1164
+ const matched = matchRoute(manifest, url.pathname);
1165
+ if (!matched) return false;
1166
+ let route;
1167
+ try {
1168
+ route = await loadRouteEntry(matched.component);
1169
+ } catch (e) {
1170
+ return false;
1171
+ }
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) {
1181
+ const prev = currentPageProps || {};
1182
+ const searchParams = {};
1183
+ url.searchParams.forEach((v, k) => {
1184
+ searchParams[k] = v;
1185
+ });
1186
+ const data = {
1187
+ component: matched.component,
1188
+ props: {
1189
+ pathname: url.pathname,
1190
+ url: url.pathname,
1191
+ host: prev.host || "",
1192
+ params: matched.params,
1193
+ searchParams,
1194
+ auth: prev.auth,
1195
+ session: prev.session,
1196
+ headers: {},
1197
+ cookies: {},
1198
+ },
1199
+ ssrData: {},
1200
+ };
1201
+ setNavParams(data);
1202
+ currentPageProps = withClientProps(data);
1203
+ const tree = withBoundary(
1204
+ buildTree(route.Page, route.Layouts, currentPageProps),
1205
+ data.component,
1206
+ myEpoch,
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;
1211
+ currentComponent = data.component;
1212
+ navPayloads.clear();
1213
+ const title = route.metadata && route.metadata.title;
1214
+ if (typeof title === "string" && title) document.title = title;
1215
+ activeRoot.render(tree);
1216
+ setTimeout(() => {
1217
+ if (pendingNav === target) pendingNav = null;
1218
+ }, 0);
1219
+ if (opts && opts.replace) {
1220
+ history.replaceState({ component: data.component }, "", target);
1221
+ } else if (push) {
1222
+ history.pushState({ component: data.component }, "", target);
1223
+ }
1224
+ window.dispatchEvent(new Event("pylon:navigation"));
1225
+ renderedPath = location.pathname + location.search;
1226
+ window.scrollTo(0, 0);
1227
+ }
1228
+
1041
1229
  function installNavHandlers() {
1042
1230
  document.addEventListener("click", (e) => {
1043
1231
  if (e.defaultPrevented) return;
@@ -1135,6 +1323,64 @@ if (typeof window !== "undefined") {
1135
1323
  }
1136
1324
  `;
1137
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
+
1138
1384
  /**
1139
1385
  * Per-route entry source. Stays tiny on purpose — react /
1140
1386
  * react-dom / client-runtime get hoisted into the shared chunk
@@ -1146,7 +1392,10 @@ if (typeof window !== "undefined") {
1146
1392
  * `.pylon/client-entry-app__hello__page.tsx` — flat namespace
1147
1393
  * keyed on the slug we'd already need anyway for the manifest.
1148
1394
  */
1149
- function generateRouteEntry(route: DiscoveredRoute): string {
1395
+ function generateRouteEntry(
1396
+ route: DiscoveredRoute,
1397
+ opts: { metadata?: boolean } = {},
1398
+ ): string {
1150
1399
  const layoutImports = route.layouts
1151
1400
  .map((l, i) => `import L${i} from "${cwd_to_import(l)}";`)
1152
1401
  .join("\n");
@@ -1155,10 +1404,10 @@ function generateRouteEntry(route: DiscoveredRoute): string {
1155
1404
  // DO NOT EDIT — overwritten on every pylon dev / build.
1156
1405
 
1157
1406
  import { hydrate } from "./client-runtime";
1158
- import Page from "${cwd_to_import(route.component)}";
1407
+ import Page${opts.metadata ? ", { metadata }" : ""} from "${cwd_to_import(route.component)}";
1159
1408
  ${layoutImports}
1160
1409
 
1161
- hydrate(${JSON.stringify(route.component)}, Page, [${layoutArray}]);
1410
+ hydrate(${JSON.stringify(route.component)}, Page, [${layoutArray}]${opts.metadata ? ", metadata" : ""});
1162
1411
  `;
1163
1412
  }
1164
1413
 
@@ -1218,6 +1467,9 @@ export interface PylonBundleManifest {
1218
1467
  /** URL pattern (e.g. `/p/[slug]`) — page routes only. Lets the client
1219
1468
  * matcher resolve an href to this route for optimistic navigation. */
1220
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";
1221
1473
  }
1222
1474
  >;
1223
1475
  /** Self-hosted fonts (next/font parity): structured `@font-face`s + the
@@ -1225,6 +1477,11 @@ export interface PylonBundleManifest {
1225
1477
  * independent); rendered into every SSR `<head>` against `public_prefix`.
1226
1478
  * Absent when the app declares no `font({...})`. */
1227
1479
  fonts?: ManifestFonts;
1480
+ /** `buildManifest({ offline })`: the install tags for every page's head,
1481
+ * the routes the client may render with no network, and the service
1482
+ * worker version. The worker is `pylon-sw.js` in the outdir. Absent when
1483
+ * the app did not opt in. */
1484
+ offline?: BundleOffline;
1228
1485
  /** Set when the Tailwind compile failed for this build: the pages are
1229
1486
  * serving WITHOUT styles. Dev surfaces it as an on-page banner (see
1230
1487
  * pylonDevHud) — a silent unstyled page reads as a CSS bug, not a
@@ -1345,6 +1602,10 @@ export interface ClientBuildOptions {
1345
1602
  /** The app manifest (fonts, build settings). Default
1346
1603
  * `<cwd>/pylon.manifest.json`. */
1347
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;
1348
1609
  }
1349
1610
 
1350
1611
  /** The `build` block of `<cwd>/pylon.manifest.json`, if any. */
@@ -1548,55 +1809,7 @@ export async function _doBuildInner(
1548
1809
  // The shared runtime + per-route entries. We track which file
1549
1810
  // each entry corresponds to so we can match metafile.outputs
1550
1811
  // back to the original route afterwards.
1551
- const runtimePath = path.join(stageDir, "client-runtime.ts");
1552
- fs.writeFileSync(runtimePath, CLIENT_RUNTIME_SOURCE, "utf8");
1553
-
1554
- // Emit the not-found/error boundary as a sibling module the runtime
1555
- // imports (`./client-boundary`). Its source is this package's real
1556
- // ssr-client-boundary.ts — ONE source of truth, unit-tested directly,
1557
- // rather than an inline string in CLIENT_RUNTIME_SOURCE that nothing
1558
- // could render. Bun pulls it into the shared chunk via the runtime's
1559
- // static `import { createPylonBoundary } from "./client-boundary"`.
1560
- //
1561
- // Resolve THIS module's directory from the standard `import.meta.url`
1562
- // (via node:url) rather than Bun's `import.meta.dir` — the latter is a
1563
- // Bun-only extension that `tsc` doesn't know about, so any app that
1564
- // imports `@pylonsync/functions` and runs `tsc` would otherwise fail
1565
- // type-checking on this file. `import.meta.url` works in Bun and Node.
1566
- const urlMod: any = await import("node:url");
1567
- const fileURLToPath = urlMod.fileURLToPath ?? urlMod.default?.fileURLToPath;
1568
- const here = path.dirname(fileURLToPath(import.meta.url));
1569
- fs.writeFileSync(
1570
- path.join(stageDir, "client-boundary.ts"),
1571
- fs.readFileSync(path.join(here, "ssr-client-boundary.ts"), "utf8"),
1572
- "utf8",
1573
- );
1574
-
1575
- // Same pattern for the page's tree shape, which the server render
1576
- // shares (ssr-page-tree.ts): the runtime imports it as `./page-tree`.
1577
- fs.writeFileSync(
1578
- path.join(stageDir, "page-tree.ts"),
1579
- fs.readFileSync(path.join(here, "ssr-page-tree.ts"), "utf8"),
1580
- "utf8",
1581
- );
1582
-
1583
- // Same pattern for the optimistic-navigation route matcher — the runtime
1584
- // imports it as `./route-match`; its source of truth is ssr-route-match.ts,
1585
- // unit-tested directly.
1586
- fs.writeFileSync(
1587
- path.join(stageDir, "route-match.ts"),
1588
- fs.readFileSync(path.join(here, "ssr-route-match.ts"), "utf8"),
1589
- "utf8",
1590
- );
1591
-
1592
- // Same pattern for the prefetch payload cache — the runtime imports it as
1593
- // `./nav-cache`; its source of truth is ssr-nav-cache.ts, unit-tested
1594
- // directly (expiry, eviction, single-use).
1595
- fs.writeFileSync(
1596
- path.join(stageDir, "nav-cache.ts"),
1597
- fs.readFileSync(path.join(here, "ssr-nav-cache.ts"), "utf8"),
1598
- "utf8",
1599
- );
1812
+ await writeClientRuntimeModules(fs, path, stageDir);
1600
1813
 
1601
1814
  // The app's loading.tsx modules, gathered into one module the runtime
1602
1815
  // imports statically (`./loading-registry`) so a pending navigation can
@@ -1614,10 +1827,18 @@ export async function _doBuildInner(
1614
1827
  const entryPaths: string[] = [];
1615
1828
  // entryPath (absolute) → component path (for manifest lookup).
1616
1829
  const entryToComponent = new Map<string, string>();
1830
+ // Pages that export `navigation = "client"` (instant navigation).
1831
+ const clientNavigation = new Set<string>();
1617
1832
  for (const r of routes) {
1618
1833
  const slug = slugForComponent(r.component);
1619
1834
  const entryPath = path.join(stageDir, `client-entry-${slug}.tsx`);
1620
- 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
+ );
1621
1842
  entryPaths.push(entryPath);
1622
1843
  entryToComponent.set(entryPath, r.component);
1623
1844
  }
@@ -1672,6 +1893,7 @@ export async function _doBuildInner(
1672
1893
  // to the browser.
1673
1894
  plugins: [
1674
1895
  absoluteAssetPlugin(outdir, fs, path, (await import("node:crypto")).createHash),
1896
+ ...[barrelImportsPlugin(fs, path, cwd)].filter((p) => p !== null),
1675
1897
  {
1676
1898
  name: "pylon-server-only",
1677
1899
  setup(build) {
@@ -1865,6 +2087,7 @@ export async function _doBuildInner(
1865
2087
  // Page routes carry their URL pattern for the client route matcher;
1866
2088
  // boundary modules (no pattern) are omitted so they never match.
1867
2089
  ...(r.pattern ? { path: r.pattern } : {}),
2090
+ ...(clientNavigation.has(r.component) ? { navigation: "client" as const } : {}),
1868
2091
  };
1869
2092
  }
1870
2093
 
@@ -1955,6 +2178,29 @@ export async function _doBuildInner(
1955
2178
  console.warn(`[pylon ssr] font build failed: ${fErr?.message ?? fErr}`);
1956
2179
  }
1957
2180
 
2181
+ // Offline service worker (`buildManifest({ offline })`). After the CSS
2182
+ // and fonts, because the precache list is every file the offline routes'
2183
+ // pages load. A failure never kills the build: the app still works
2184
+ // online, and the runtime answers 404 for `/pylon-sw.js`.
2185
+ try {
2186
+ const offline = readManifestOffline(fs, path, cwd, manifestFile);
2187
+ if (offline) {
2188
+ manifest.offline = await buildOfflineWorker(
2189
+ fs,
2190
+ path,
2191
+ outdir,
2192
+ manifest,
2193
+ offline,
2194
+ cwd,
2195
+ appDirRel,
2196
+ );
2197
+ }
2198
+ } catch (oErr: any) {
2199
+ if (opts.strict) throw new Error(`offline worker build failed: ${oErr?.message ?? oErr}`);
2200
+ // eslint-disable-next-line no-console
2201
+ console.warn(`[pylon offline] service worker build failed: ${oErr?.message ?? oErr}`);
2202
+ }
2203
+
1958
2204
  // App-overridable rate-limit page. The rate limiter short-circuits in the
1959
2205
  // Rust layer BEFORE SSR, so it can't render a route per (shed) request —
1960
2206
  // instead pre-render `app/rate-limit.tsx` ONCE here to a self-contained
@@ -1998,6 +2244,21 @@ export async function _doBuildInner(
1998
2244
  const manifestPath = path.join(outdir, "manifest.json");
1999
2245
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
2000
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
+
2001
2262
  // Bump our in-process manifest cache so SSR re-reads on next request.
2002
2263
  _manifestCache = null;
2003
2264
 
@@ -2111,6 +2372,106 @@ const ASSET_RE = /\.(wasm|png|jpe?g|gif|webp|avif|ico|svg|woff2?|ttf|otf|mp3|mp4
2111
2372
  * the absolute URL. A stylesheet's `url(...)` is left to Bun: CSS cannot
2112
2373
  * import a JavaScript module.
2113
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
+
2114
2475
  function absoluteAssetPlugin(
2115
2476
  outdir: string,
2116
2477
  fs: typeof import("node:fs"),