@pylonsync/functions 0.22.50 → 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.
- package/dist/index.d.ts +1 -1
- package/dist/runtime.d.ts +5 -1
- package/dist/ssr-barrel-imports.d.ts +16 -0
- package/dist/ssr-client-bundler.d.ts +44 -0
- package/dist/ssr-client-nav.d.ts +30 -0
- package/dist/ssr-offline.d.ts +34 -5
- package/dist/ssr-precompress.d.ts +16 -0
- package/dist/ssr-runtime.d.ts +12 -8
- package/dist/types.d.ts +53 -0
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/offline-sw.test.ts +36 -0
- package/src/offline-sw.ts +15 -2
- package/src/runtime-files.test.ts +55 -0
- package/src/runtime.ts +17 -4
- package/src/ssr-barrel-imports.test.ts +70 -0
- package/src/ssr-barrel-imports.ts +104 -0
- package/src/ssr-bucket-serverdata.test.ts +167 -0
- package/src/ssr-client-bundler.test.ts +160 -0
- package/src/ssr-client-bundler.ts +315 -73
- package/src/ssr-client-nav-runtime.fixture.ts +273 -0
- package/src/ssr-client-nav-runtime.test.ts +79 -0
- package/src/ssr-client-nav.test.ts +78 -0
- package/src/ssr-client-nav.ts +70 -0
- package/src/ssr-offline-shell.test.ts +7 -2
- package/src/ssr-offline.ts +114 -8
- package/src/ssr-precompress.test.ts +49 -0
- package/src/ssr-precompress.ts +67 -0
- package/src/ssr-runtime.ts +45 -26
- package/src/ssr-serverdata-fn.test.ts +2 -2
- package/src/types.ts +50 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
1080
|
-
// local replica (db.useQuery), so it needs only
|
|
1081
|
-
// identity-free props the last page had. Returns
|
|
1082
|
-
// offline route, or its code is not on the
|
|
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 || !
|
|
1086
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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"),
|