@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.
- package/dist/index.d.ts +1 -1
- package/dist/offline-sw.d.ts +46 -0
- package/dist/runtime.d.ts +5 -1
- package/dist/ssr-barrel-imports.d.ts +16 -0
- package/dist/ssr-client-bundler.d.ts +50 -0
- package/dist/ssr-client-nav.d.ts +30 -0
- package/dist/ssr-offline.d.ts +93 -0
- package/dist/ssr-precompress.d.ts +16 -0
- package/dist/ssr-route-assets.d.ts +35 -0
- package/dist/ssr-runtime.d.ts +43 -6
- package/dist/types.d.ts +53 -0
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/offline-sw.test.ts +393 -0
- package/src/offline-sw.ts +343 -0
- 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 +223 -0
- package/src/ssr-client-bundler.ts +424 -63
- 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 +253 -0
- package/src/ssr-offline.ts +311 -0
- package/src/ssr-precompress.test.ts +49 -0
- package/src/ssr-precompress.ts +67 -0
- package/src/ssr-route-assets.ts +68 -0
- package/src/ssr-runtime.ts +159 -28
- package/src/ssr-serverdata-fn.test.ts +2 -2
- package/src/types.ts +50 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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"),
|