@pylonsync/functions 0.11.6 → 0.13.0

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.
@@ -34,6 +34,14 @@
34
34
  // - CSS chunking. No CSS support in SSR yet.
35
35
 
36
36
  import { buildFonts, readManifestFonts, type ManifestFonts } from "./ssr-fonts";
37
+ import {
38
+ applyClientCompat,
39
+ resolveClientCompat,
40
+ transformCss,
41
+ type ClientCompat,
42
+ type ManifestBuildConfig,
43
+ } from "./build-compat";
44
+ import { serverBundle } from "./server-bundle";
37
45
 
38
46
  type Send = (msg: Record<string, unknown>) => void;
39
47
 
@@ -88,7 +96,7 @@ declare const Bun: {
88
96
  target?: "browser" | "bun" | "node";
89
97
  format?: "esm" | "iife";
90
98
  minify?: boolean;
91
- sourcemap?: "none" | "inline" | "external";
99
+ sourcemap?: "none" | "inline" | "external" | "linked";
92
100
  define?: Record<string, string>;
93
101
  external?: string[];
94
102
  splitting?: boolean;
@@ -1149,8 +1157,12 @@ function slugForComponent(component: string): string {
1149
1157
  export interface PylonBundleManifest {
1150
1158
  /** Build identity — bumps every successful build. */
1151
1159
  build_id: string;
1152
- /** Output root, relative to cwd (always `.pylon/client-build`). */
1160
+ /** Output root, relative to cwd: `.pylon/client-build`, or `client` in a
1161
+ * `pylon build` artifact. */
1153
1162
  outdir: string;
1163
+ /** core-js polyfill bundle (relative to outdir), loaded before any route
1164
+ * entry. Present when `build.polyfill` is set and the targets need one. */
1165
+ polyfills?: string;
1154
1166
  /** Public URL prefix the Rust host serves chunks under. */
1155
1167
  public_prefix: string;
1156
1168
  /** routeComponentPath → file + imports for that route. */
@@ -1208,7 +1220,7 @@ async function _prebuiltBundle(): Promise<BuildOutput | null> {
1208
1220
  const pathMod: any = await import("node:path");
1209
1221
  const fs = fsMod.default ?? fsMod;
1210
1222
  const path = pathMod.default ?? pathMod;
1211
- const outdir = path.join(process.cwd(), ".pylon", "client-build");
1223
+ const outdir = path.join(process.cwd(), clientBuildDir());
1212
1224
  const manifestPath = path.join(outdir, "manifest.json");
1213
1225
  if (!fs.existsSync(manifestPath)) return null;
1214
1226
 
@@ -1269,6 +1281,48 @@ export async function buildClientBundle(
1269
1281
  return _inflightBuild;
1270
1282
  }
1271
1283
 
1284
+ /** Default client bundle directory, relative to the project root. */
1285
+ export const CLIENT_BUILD_DIR = ".pylon/client-build";
1286
+
1287
+ /** The client bundle directory, relative to cwd. A production server bundle
1288
+ * names its own; source mode uses `CLIENT_BUILD_DIR`. */
1289
+ export function clientBuildDir(): string {
1290
+ return serverBundle()?.clientDir ?? CLIENT_BUILD_DIR;
1291
+ }
1292
+
1293
+ export interface ClientBuildOptions {
1294
+ /** Absolute output directory. Default: `<cwd>/.pylon/client-build`. */
1295
+ outdir?: string;
1296
+ /** The manifest's `outdir` value. Default: `outdir` relative to cwd. A
1297
+ * `pylon build` artifact sets `client`, its path at run time. */
1298
+ manifestOutdir?: string;
1299
+ /** The `build` block to apply. Default: read from `pylon.manifest.json`,
1300
+ * except in dev (`NODE_ENV=development`), which applies none. `null`
1301
+ * applies none. */
1302
+ buildConfig?: ManifestBuildConfig | null;
1303
+ /** Fail on a CSS or font build error instead of shipping without it. */
1304
+ strict?: boolean;
1305
+ /** The app manifest (fonts, build settings). Default
1306
+ * `<cwd>/pylon.manifest.json`. */
1307
+ manifestPath?: string;
1308
+ }
1309
+
1310
+ /** The `build` block of `<cwd>/pylon.manifest.json`, if any. */
1311
+ export function readManifestBuildConfig(
1312
+ fs: any,
1313
+ path: any,
1314
+ cwd: string,
1315
+ ): ManifestBuildConfig | undefined {
1316
+ const manifestPath = path.join(cwd, "pylon.manifest.json");
1317
+ if (!fs.existsSync(manifestPath)) return undefined;
1318
+ try {
1319
+ const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
1320
+ return m && typeof m.build === "object" && m.build !== null ? m.build : undefined;
1321
+ } catch {
1322
+ return undefined;
1323
+ }
1324
+ }
1325
+
1272
1326
  async function _doBuild(appDirRel: string): Promise<BuildOutput> {
1273
1327
  // node:* are available in Bun, but `globalThis.require` is
1274
1328
  // not defined in ESM. Use dynamic import; Bun fast-paths these.
@@ -1300,6 +1354,7 @@ export async function buildTailwind(
1300
1354
  cwd: string,
1301
1355
  outdir: string,
1302
1356
  appDirRel: string,
1357
+ cssTargets?: string[],
1303
1358
  ): Promise<string | null> {
1304
1359
  const globalsPath = path.join(cwd, appDirRel, "globals.css");
1305
1360
  if (!fs.existsSync(globalsPath)) return null;
@@ -1360,7 +1415,13 @@ export async function buildTailwind(
1360
1415
  throw new Error(`tailwindcss build failed (exit ${exitCode}): ${err}`);
1361
1416
  }
1362
1417
 
1363
- const out = fs.readFileSync(tmpPath, "utf8");
1418
+ let out = fs.readFileSync(tmpPath, "utf8");
1419
+ if (cssTargets && cssTargets.length > 0) {
1420
+ // Hash the TRANSFORMED output (below), so a CSS target change changes
1421
+ // the file name.
1422
+ out = await transformCss(cwd, out, "globals.css", cssTargets);
1423
+ fs.writeFileSync(tmpPath, out, "utf8");
1424
+ }
1364
1425
  let hash = 0;
1365
1426
  for (let i = 0; i < out.length; i++) {
1366
1427
  hash = (hash * 31 + out.charCodeAt(i)) >>> 0;
@@ -1400,7 +1461,20 @@ export async function _doBuildInner(
1400
1461
  path: any,
1401
1462
  cwd: string,
1402
1463
  appDirRel: string,
1464
+ opts: ClientBuildOptions = {},
1403
1465
  ): Promise<BuildOutput> {
1466
+ // Production settings from the manifest's `build` block. Dev builds skip
1467
+ // them: lowering and polyfills cost build time and change nothing a modern
1468
+ // dev browser needs.
1469
+ const buildConfig =
1470
+ opts.buildConfig !== undefined
1471
+ ? opts.buildConfig
1472
+ : process.env.NODE_ENV === "development"
1473
+ ? undefined
1474
+ : readManifestBuildConfig(fs, path, cwd);
1475
+ const compat = resolveClientCompat(buildConfig ?? undefined);
1476
+ const manifestFile = opts.manifestPath ?? path.join(cwd, "pylon.manifest.json");
1477
+ const sourcemap = buildConfig?.sourcemap === true;
1404
1478
  const routes = discoverRoutes(fs, path, cwd, appDirRel);
1405
1479
  if (routes.length === 0) {
1406
1480
  throw new Error(
@@ -1423,7 +1497,7 @@ export async function _doBuildInner(
1423
1497
  }
1424
1498
  }
1425
1499
  }
1426
- const outdir = path.join(stageDir, "client-build");
1500
+ const outdir = opts.outdir ?? path.join(cwd, CLIENT_BUILD_DIR);
1427
1501
  try {
1428
1502
  fs.rmSync(outdir, { recursive: true, force: true });
1429
1503
  } catch {
@@ -1522,7 +1596,7 @@ export async function _doBuildInner(
1522
1596
  target: "browser",
1523
1597
  format: "esm",
1524
1598
  minify: true,
1525
- sourcemap: "none",
1599
+ sourcemap: sourcemap ? "linked" : "none",
1526
1600
  splitting: true,
1527
1601
  // The browser has no `process`. React, next-themes, sonner, and most
1528
1602
  // npm UI deps reference `process.env.NODE_ENV` (and migrated Next code
@@ -1574,7 +1648,35 @@ export async function _doBuildInner(
1574
1648
  // Manifest paths are URL paths under `public_prefix` ("/"-separated). The
1575
1649
  // runtime's /_pylon/build/ handler rejects any request with a backslash.
1576
1650
  const toUrlPath = (p: string) => p.replace(/\\/g, "/");
1577
- const outdirRel = toUrlPath(path.relative(cwd, outdir));
1651
+ const outdirRel =
1652
+ opts.manifestOutdir ?? toUrlPath(path.relative(cwd, outdir));
1653
+
1654
+ // Browser compatibility pass (build.target): lowers syntax, collects
1655
+ // polyfills, and renames the .js outputs. Later steps read the renamed
1656
+ // paths.
1657
+ let outputs: Array<{ path: string; kind: string }> = result.outputs.map(
1658
+ (o) => ({ path: o.path, kind: o.kind }),
1659
+ );
1660
+ let polyfills: string | undefined;
1661
+ if (compat && compat.jsTargets.length > 0) {
1662
+ const jsFiles = outputs
1663
+ .filter((o) => o.path.endsWith(".js"))
1664
+ .map((o) => toUrlPath(path.relative(outdir, o.path)));
1665
+ const res = await applyClientCompat({ fs, path, cwd, outdir, jsFiles, compat });
1666
+ if (res.unsupported.length > 0) {
1667
+ // eslint-disable-next-line no-console
1668
+ console.warn(
1669
+ `[pylon build] build.target includes browsers that cannot run the Pylon client runtime ` +
1670
+ `(it needs ES modules and dynamic import()): ${res.unsupported.join(", ")}`,
1671
+ );
1672
+ }
1673
+ outputs = outputs.map((o) => {
1674
+ const rel = toUrlPath(path.relative(outdir, o.path));
1675
+ const next = res.renamed.get(rel);
1676
+ return next ? { ...o, path: path.join(outdir, next) } : o;
1677
+ });
1678
+ polyfills = res.polyfills;
1679
+ }
1578
1680
  const entriesByStem = new Map<
1579
1681
  string,
1580
1682
  { absPath: string; relPath: string }
@@ -1583,7 +1685,7 @@ export async function _doBuildInner(
1583
1685
  string,
1584
1686
  { absPath: string; relPath: string }
1585
1687
  >();
1586
- for (const o of result.outputs) {
1688
+ for (const o of outputs) {
1587
1689
  const absPath: string = o.path;
1588
1690
  const relPath = toUrlPath(path.relative(outdir, absPath));
1589
1691
  const base = path.basename(absPath);
@@ -1608,7 +1710,7 @@ export async function _doBuildInner(
1608
1710
  // the binary next to the entries AND into chunks/ so the runtime URL
1609
1711
  // resolves from either an entry or a split chunk.
1610
1712
  try {
1611
- const referencesLoroWasm = result.outputs.some((o) => {
1713
+ const referencesLoroWasm = outputs.some((o) => {
1612
1714
  if (!o.path.endsWith(".js")) return false;
1613
1715
  try {
1614
1716
  return fs.readFileSync(o.path, "utf8").includes("loro_wasm_bg.wasm");
@@ -1682,6 +1784,7 @@ export async function _doBuildInner(
1682
1784
  outdir: outdirRel,
1683
1785
  public_prefix: "/_pylon/build/",
1684
1786
  routes: {},
1787
+ ...(polyfills ? { polyfills } : {}),
1685
1788
  };
1686
1789
  for (const r of routes) {
1687
1790
  const slug = slugForComponent(r.component);
@@ -1735,7 +1838,7 @@ export async function _doBuildInner(
1735
1838
  // pylon.manifest.json from cwd. It's generated output — commonly
1736
1839
  // gitignored — so when it's absent the bundle still builds but
1737
1840
  // silently loses those features. Say so, loudly, instead.
1738
- if (!fs.existsSync(path.join(cwd, "pylon.manifest.json"))) {
1841
+ if (!fs.existsSync(manifestFile)) {
1739
1842
  // eslint-disable-next-line no-console
1740
1843
  console.warn(
1741
1844
  "[pylon ssr] pylon.manifest.json not found — building the client " +
@@ -1750,7 +1853,14 @@ export async function _doBuildInner(
1750
1853
  // array so SSR head injection emits `<link rel="stylesheet">`.
1751
1854
  let stylesRel: string | null = null;
1752
1855
  try {
1753
- const styles = await buildTailwind(fs, path, cwd, outdir, appDirRel);
1856
+ const styles = await buildTailwind(
1857
+ fs,
1858
+ path,
1859
+ cwd,
1860
+ outdir,
1861
+ appDirRel,
1862
+ compat?.cssTargets,
1863
+ );
1754
1864
  if (styles) {
1755
1865
  stylesRel = styles;
1756
1866
  for (const r of Object.values(manifest.routes)) {
@@ -1769,6 +1879,7 @@ export async function _doBuildInner(
1769
1879
  /\u001b\[[0-9;]*m/g,
1770
1880
  "",
1771
1881
  );
1882
+ if (opts.strict) throw new Error(`CSS build failed: ${cssMsg}`);
1772
1883
  manifest.css_error = cssMsg;
1773
1884
  // eslint-disable-next-line no-console
1774
1885
  console.warn(`[pylon ssr] tailwind compile failed: ${cssMsg}`);
@@ -1782,12 +1893,13 @@ export async function _doBuildInner(
1782
1893
  // the prebuilt artifact. A fetch/parse failure degrades to a variable-only
1783
1894
  // entry — it never kills the build.
1784
1895
  try {
1785
- const declaredFonts = readManifestFonts(fs, path, cwd);
1896
+ const declaredFonts = readManifestFonts(fs, path, cwd, manifestFile);
1786
1897
  if (declaredFonts.length > 0) {
1787
1898
  const builtFonts = await buildFonts(fs, path, cwd, outdir, declaredFonts);
1788
1899
  if (builtFonts) manifest.fonts = builtFonts;
1789
1900
  }
1790
1901
  } catch (fErr: any) {
1902
+ if (opts.strict) throw new Error(`font build failed: ${fErr?.message ?? fErr}`);
1791
1903
  // eslint-disable-next-line no-console
1792
1904
  console.warn(`[pylon ssr] font build failed: ${fErr?.message ?? fErr}`);
1793
1905
  }
@@ -1863,7 +1975,7 @@ export async function getManifest(): Promise<PylonBundleManifest> {
1863
1975
  const fs = fsMod.default ?? fsMod;
1864
1976
  const path = pathMod.default ?? pathMod;
1865
1977
  const cwd = process.cwd();
1866
- const manifestPath = path.join(cwd, ".pylon", "client-build", "manifest.json");
1978
+ const manifestPath = path.join(cwd, clientBuildDir(), "manifest.json");
1867
1979
 
1868
1980
  if (fs.existsSync(manifestPath)) {
1869
1981
  const stat = fs.statSync(manifestPath);
package/src/ssr-fonts.ts CHANGED
@@ -621,8 +621,8 @@ export function readManifestFonts(
621
621
  fs: any,
622
622
  path: any,
623
623
  cwd: string,
624
+ manifestPath: string = path.join(cwd, "pylon.manifest.json"),
624
625
  ): ManifestFontInput[] {
625
- const manifestPath = path.join(cwd, "pylon.manifest.json");
626
626
  if (!fs.existsSync(manifestPath)) return [];
627
627
  try {
628
628
  const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { readFileSync } from "node:fs";
27
27
  import { fileURLToPath } from "node:url";
28
+ import { serverBundle } from "./server-bundle";
28
29
 
29
30
  // Satori's font descriptor.
30
31
  export interface OgFont {
@@ -68,9 +69,15 @@ function loadResvg() {
68
69
  if (!resvgReady) {
69
70
  resvgReady = (async () => {
70
71
  const mod = await import("@resvg/resvg-wasm");
71
- // Locate the wasm binary next to the package entry and init once.
72
- const entry = fileURLToPath(import.meta.resolve("@resvg/resvg-wasm"));
73
- const wasmPath = entry.replace(/index\.[^/]+$/, "index_bg.wasm");
72
+ // Locate the wasm binary next to the package entry (or in the
73
+ // production server bundle) and init once.
74
+ const bundled = serverBundle()?.ogAssets.resvgWasm;
75
+ const wasmPath =
76
+ bundled ??
77
+ fileURLToPath(import.meta.resolve("@resvg/resvg-wasm")).replace(
78
+ /index\.[^/]+$/,
79
+ "index_bg.wasm",
80
+ );
74
81
  await mod.initWasm(readFileSync(wasmPath));
75
82
  return mod;
76
83
  })();
@@ -83,18 +90,23 @@ function loadResvg() {
83
90
  let defaultFonts: OgFont[] | null = null;
84
91
  function loadDefaultFonts(): OgFont[] {
85
92
  if (defaultFonts) return defaultFonts;
93
+ const bundled = serverBundle()?.ogAssets;
86
94
  const read = (rel: string) =>
87
95
  readFileSync(fileURLToPath(new URL(rel, import.meta.url)));
88
96
  defaultFonts = [
89
97
  {
90
98
  name: "Inter",
91
- data: read("../assets/fonts/Inter-Regular.ttf"),
99
+ data: bundled
100
+ ? readFileSync(bundled.interRegular)
101
+ : read("../assets/fonts/Inter-Regular.ttf"),
92
102
  weight: 400,
93
103
  style: "normal",
94
104
  },
95
105
  {
96
106
  name: "Inter",
97
- data: read("../assets/fonts/Inter-SemiBold.ttf"),
107
+ data: bundled
108
+ ? readFileSync(bundled.interSemiBold)
109
+ : read("../assets/fonts/Inter-SemiBold.ttf"),
98
110
  weight: 600,
99
111
  style: "normal",
100
112
  },
@@ -7,6 +7,8 @@
7
7
  // the dispatch arm) so projects without SSR routes pay nothing — no
8
8
  // react-dom dependency requirement, no startup cost.
9
9
 
10
+ import { moduleKey, serverBundle } from "./server-bundle";
11
+
10
12
  // Bun runtime global. This module runs under Bun but is type-checked by
11
13
  // consuming apps under node/DOM (no `Bun` global) — declare the surface used.
12
14
  declare const Bun: {
@@ -716,8 +718,19 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
716
718
 
717
719
  const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
718
720
 
719
- /** Import a project-relative module, trying each common extension. */
721
+ /**
722
+ * Import a project-relative module, trying each common extension. A
723
+ * production server bundle serves it from the bundle's module registry.
724
+ */
720
725
  export async function importModule(cwd: string, relPath: string): Promise<any> {
726
+ const bundle = serverBundle();
727
+ if (bundle) {
728
+ const load = bundle.modules[moduleKey(relPath)];
729
+ if (load === undefined) {
730
+ throw new Error(`module "${relPath}" is not in the server bundle`);
731
+ }
732
+ return load();
733
+ }
721
734
  const base = `${cwd}/${relPath}`;
722
735
  let lastErr: unknown = null;
723
736
  for (const ext of MODULE_EXTS) {
@@ -730,6 +743,58 @@ export async function importModule(cwd: string, relPath: string): Promise<any> {
730
743
  throw lastErr ?? new Error(`could not import module "${relPath}"`);
731
744
  }
732
745
 
746
+ /**
747
+ * True when the project has a module at `<dir>/<name>` with any code
748
+ * extension. `dir` is project-relative and "/"-separated. A production server
749
+ * bundle answers from its module registry; source mode checks the disk.
750
+ */
751
+ export function moduleExistsIn(
752
+ fs: any,
753
+ path: any,
754
+ cwd: string,
755
+ dir: string,
756
+ name: string,
757
+ ): boolean {
758
+ const bundle = serverBundle();
759
+ if (bundle) return moduleKey(`${dir}/${name}`) in bundle.modules;
760
+ return MODULE_EXTS.some((ext) =>
761
+ fs.existsSync(path.join(cwd, dir, `${name}${ext}`)),
762
+ );
763
+ }
764
+
765
+ /**
766
+ * The route-group directories (`(name)`) directly under the project-relative
767
+ * `dir`, sorted by name. A production server bundle derives them from its
768
+ * module registry keys; source mode reads the directory.
769
+ */
770
+ function routeGroupDirsIn(fs: any, path: any, cwd: string, dir: string): string[] {
771
+ const isGroup = (n: string) => n.startsWith("(") && n.endsWith(")");
772
+ const bundle = serverBundle();
773
+ if (bundle) {
774
+ const prefix = `${dir}/`;
775
+ const found = new Set<string>();
776
+ for (const key of Object.keys(bundle.modules)) {
777
+ if (!key.startsWith(prefix)) continue;
778
+ const child = key.slice(prefix.length).split("/")[0];
779
+ // A key with no "/" after the prefix is a module file, not a directory.
780
+ if (key.slice(prefix.length).includes("/") && isGroup(child)) {
781
+ found.add(child);
782
+ }
783
+ }
784
+ return [...found].sort();
785
+ }
786
+ let entries: any[];
787
+ try {
788
+ entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true });
789
+ } catch {
790
+ return [];
791
+ }
792
+ return entries
793
+ .filter((e: any) => e.isDirectory() && isGroup(e.name))
794
+ .map((e: any) => e.name as string)
795
+ .sort();
796
+ }
797
+
733
798
  /**
734
799
  * Wrap a leaf element in its layout chain (leaf → root). Resolves ALL
735
800
  * layouts first so a missing one fails before any chunk is emitted. Reused
@@ -829,25 +894,10 @@ function boundaryInDirOrGroups(
829
894
  dir: string,
830
895
  fileName: string,
831
896
  ): string | null {
832
- for (const ext of MODULE_EXTS) {
833
- if (fs.existsSync(path.join(cwd, dir, `${fileName}${ext}`))) {
834
- return `${dir}/${fileName}`;
835
- }
836
- }
837
- let entries: any[];
838
- try {
839
- entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true });
840
- } catch {
841
- return null;
897
+ if (moduleExistsIn(fs, path, cwd, dir, fileName)) {
898
+ return `${dir}/${fileName}`;
842
899
  }
843
- const groups = entries
844
- .filter(
845
- (e: any) =>
846
- e.isDirectory() && e.name.startsWith("(") && e.name.endsWith(")"),
847
- )
848
- .map((e: any) => e.name as string)
849
- .sort();
850
- for (const g of groups) {
900
+ for (const g of routeGroupDirsIn(fs, path, cwd, dir)) {
851
901
  const hit = boundaryInDirOrGroups(fs, path, cwd, `${dir}/${g}`, fileName);
852
902
  if (hit) return hit;
853
903
  }
@@ -892,8 +942,6 @@ function findColocatedImage(
892
942
  return null;
893
943
  }
894
944
 
895
- const OG_IMAGE_CODE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
896
-
897
945
  /**
898
946
  * Walk up from a page's directory to the nearest colocated dynamic OG
899
947
  * module (`opengraph-image.{tsx,ts,jsx,js}`) and return the CONCRETE
@@ -938,14 +986,12 @@ function findColocatedOgImageRoute(
938
986
  return below.filter((s) => !(s.startsWith("(") && s.endsWith(")"))).length;
939
987
  };
940
988
  while (dir && dir !== "." && dir !== "/") {
941
- for (const ext of OG_IMAGE_CODE_EXTS) {
942
- if (fs.existsSync(path.join(cwd, dir, `opengraph-image${ext}`))) {
943
- const prefix =
944
- dir === pageDir && !isBoundary
945
- ? urlSegs
946
- : urlSegs.slice(0, nonGroupDepth(dir));
947
- return "/" + [...prefix, "opengraph-image"].join("/");
948
- }
989
+ if (moduleExistsIn(fs, path, cwd, dir, "opengraph-image")) {
990
+ const prefix =
991
+ dir === pageDir && !isBoundary
992
+ ? urlSegs
993
+ : urlSegs.slice(0, nonGroupDepth(dir));
994
+ return "/" + [...prefix, "opengraph-image"].join("/");
949
995
  }
950
996
  const slash = dir.lastIndexOf("/");
951
997
  dir = slash >= 0 ? dir.slice(0, slash) : "";
@@ -2098,6 +2144,9 @@ export function buildHydrationTail(args: {
2098
2144
  manifestRoute: { file: string; imports: string[]; css: string[] } | null;
2099
2145
  publicPrefix: string;
2100
2146
  manifestErr: string | null;
2147
+ /** core-js polyfill bundle (relative to `publicPrefix`), from the bundle
2148
+ * manifest. Loaded before the route entry. */
2149
+ polyfills?: string;
2101
2150
  kind?: "error" | "not-found";
2102
2151
  errorForClient?: { message: string; digest?: string };
2103
2152
  // PPR Phase 0 (auth-bucketed caching): when set, this render is being stored
@@ -2181,6 +2230,11 @@ export function buildHydrationTail(args: {
2181
2230
  // reusable (no double fetch) and surfaces load errors. No-op for the
2182
2231
  // same-origin `/_pylon/build/` default.
2183
2232
  const co = /^https?:\/\//i.test(args.publicPrefix) ? " crossorigin" : "";
2233
+ // Module scripts run in document order, so the polyfills are in place
2234
+ // before the entry (and the shared chunk it imports) runs.
2235
+ if (args.polyfills) {
2236
+ tail += `<script type="module"${co} src="${args.publicPrefix}${args.polyfills}"></script>`;
2237
+ }
2184
2238
  tail += `<script type="module"${co} src="${args.publicPrefix}${args.manifestRoute.file}"></script>`;
2185
2239
  } else {
2186
2240
  // Executable script → the serialized message MUST be script-escaped
@@ -2258,11 +2312,8 @@ function resolveLayoutChain(componentRelPath: string, cwd: string): string[] {
2258
2312
  let acc = "";
2259
2313
  for (const part of parts) {
2260
2314
  acc = acc ? `${acc}/${part}` : part;
2261
- for (const ext of MODULE_EXTS) {
2262
- if (fs.existsSync(path.join(cwd, acc, `layout${ext}`))) {
2263
- layouts.push(`${acc}/layout`);
2264
- break;
2265
- }
2315
+ if (moduleExistsIn(fs, path, cwd, acc, "layout")) {
2316
+ layouts.push(`${acc}/layout`);
2266
2317
  }
2267
2318
  }
2268
2319
  return layouts;
@@ -2321,6 +2372,7 @@ async function renderBoundaryToClient(
2321
2372
  | { file: string; imports: string[]; css: string[] }
2322
2373
  | null = null;
2323
2374
  let publicPrefix = "/_pylon/build/";
2375
+ let polyfills: string | undefined;
2324
2376
  let headBlob = "";
2325
2377
  if (tail) {
2326
2378
  try {
@@ -2328,6 +2380,7 @@ async function renderBoundaryToClient(
2328
2380
  const manifest = await getManifest();
2329
2381
  publicPrefix = manifest.public_prefix || publicPrefix;
2330
2382
  manifestRoute = manifest.routes[tail.component] ?? null;
2383
+ polyfills = manifest.polyfills;
2331
2384
  } catch {
2332
2385
  manifestRoute = null;
2333
2386
  }
@@ -2374,6 +2427,7 @@ async function renderBoundaryToClient(
2374
2427
  manifestRoute,
2375
2428
  publicPrefix,
2376
2429
  manifestErr: null,
2430
+ polyfills,
2377
2431
  kind: tail.kind,
2378
2432
  errorForClient: tail.errorForClient,
2379
2433
  });
@@ -3201,22 +3255,30 @@ export async function handleRenderRoute(
3201
3255
  // `react-dom/server` (which is Node-stream-style). Try browser
3202
3256
  // first, fall back to the default entry for environments that
3203
3257
  // re-route it (Next runs a custom dist).
3258
+ //
3259
+ // A production server bundle carries the app's React, bundled with the
3260
+ // page modules, so there is nothing to resolve.
3261
+ const bundle = serverBundle();
3204
3262
  let reactDomServerImport: any;
3205
- try {
3206
- // @ts-ignore — user-dep, resolved at runtime
3207
- reactDomServerImport = await import(
3208
- /* @vite-ignore */ resolveFromUser("react-dom/server.browser")
3209
- );
3210
- } catch {
3263
+ let reactImport: any;
3264
+ if (bundle) {
3265
+ reactDomServerImport = bundle.reactDomServer;
3266
+ reactImport = bundle.react;
3267
+ } else {
3268
+ try {
3269
+ // @ts-ignore — user-dep, resolved at runtime
3270
+ reactDomServerImport = await import(
3271
+ /* @vite-ignore */ resolveFromUser("react-dom/server.browser")
3272
+ );
3273
+ } catch {
3274
+ // @ts-ignore — user-dep, resolved at runtime
3275
+ reactDomServerImport = await import(
3276
+ /* @vite-ignore */ resolveFromUser("react-dom/server")
3277
+ );
3278
+ }
3211
3279
  // @ts-ignore — user-dep, resolved at runtime
3212
- reactDomServerImport = await import(
3213
- /* @vite-ignore */ resolveFromUser("react-dom/server")
3214
- );
3280
+ reactImport = await import(/* @vite-ignore */ resolveFromUser("react"));
3215
3281
  }
3216
- // @ts-ignore — user-dep, resolved at runtime
3217
- const reactImport = await import(
3218
- /* @vite-ignore */ resolveFromUser("react")
3219
- );
3220
3282
  React = reactImport.default ?? reactImport;
3221
3283
  renderToReadableStream =
3222
3284
  reactDomServerImport.renderToReadableStream ??
@@ -3700,11 +3762,13 @@ export async function handleRenderRoute(
3700
3762
  | null = null;
3701
3763
  let preloadManifestErr: string | null = null;
3702
3764
  let preloadPublicPrefix = "/_pylon/build/";
3765
+ let preloadPolyfills: string | undefined;
3703
3766
  try {
3704
3767
  const { getManifest } = await import("./ssr-client-bundler");
3705
3768
  const manifest = await getManifest();
3706
3769
  preloadPublicPrefix = manifest.public_prefix || preloadPublicPrefix;
3707
3770
  preloadManifestRoute = manifest.routes[msg.component] ?? null;
3771
+ preloadPolyfills = manifest.polyfills;
3708
3772
  if (!preloadManifestRoute) {
3709
3773
  preloadManifestErr = `manifest has no entry for "${msg.component}"`;
3710
3774
  }
@@ -3866,6 +3930,7 @@ export async function handleRenderRoute(
3866
3930
  manifestRoute: preloadManifestRoute,
3867
3931
  publicPrefix: preloadPublicPrefix,
3868
3932
  manifestErr: preloadManifestErr,
3933
+ polyfills: preloadPolyfills,
3869
3934
  kind: isBoundaryComponent
3870
3935
  ? /(^|\/)error$/.test(msg.component)
3871
3936
  ? "error"
@@ -0,0 +1,69 @@
1
+ // Keeps stdout for the host protocol. See `fenceStdout`.
2
+
3
+ // Bun global used here. Declared locally so apps that type-check this source
4
+ // without bun-types still pass `tsc` (same pattern as runtime.ts).
5
+ declare const Bun: {
6
+ write(dest: unknown, data: string): unknown;
7
+ stderr: unknown;
8
+ };
9
+
10
+ /**
11
+ * Redirect console.* from user code to stderr so handlers can't accidentally
12
+ * emit a line that looks like a protocol frame and confuse the Rust reader.
13
+ *
14
+ * Before this guard, a handler calling `console.log('{"type":"return",...}')`
15
+ * — either intentionally or by logging an object shaped that way — would be
16
+ * parsed by the host as a real protocol message. Moving all console output
17
+ * to stderr keeps stdout reserved for NDJSON protocol frames only.
18
+ *
19
+ * The original console methods are saved on the console object as
20
+ * `__stdoutLog` etc. in case the runtime itself needs to write diagnostics
21
+ * to stdout for some reason (it currently doesn't).
22
+ *
23
+ * Safe to call more than once. A production server bundle calls it from its
24
+ * entry, before the app modules load, and the runtime's `main()` calls it
25
+ * again.
26
+ */
27
+ export function fenceStdout(): void {
28
+ const c = globalThis.console as unknown as Record<string, unknown>;
29
+ if (c.__pylonFenced) return;
30
+ c.__pylonFenced = true;
31
+ const toStderr = (prefix: string) => (...args: unknown[]) => {
32
+ const line = args
33
+ .map((a) => {
34
+ if (typeof a === "string") return a;
35
+ // Error: JSON.stringify yields `{}` because message/stack are
36
+ // non-enumerable. That made `console.error("x:", err)` log as `x: {}`,
37
+ // hiding the real failure from operators. Unwrap by hand.
38
+ if (a instanceof Error) {
39
+ const parts = [a.stack || `${a.name}: ${a.message}`];
40
+ const code = (a as { code?: unknown }).code;
41
+ if (code !== undefined) parts.push(`code=${String(code)}`);
42
+ const cause = (a as { cause?: unknown }).cause;
43
+ if (cause !== undefined) {
44
+ try {
45
+ parts.push(`cause=${cause instanceof Error ? cause.stack || cause.message : JSON.stringify(cause)}`);
46
+ } catch {
47
+ parts.push(`cause=${String(cause)}`);
48
+ }
49
+ }
50
+ return parts.join(" ");
51
+ }
52
+ try {
53
+ return JSON.stringify(a);
54
+ } catch {
55
+ return String(a);
56
+ }
57
+ })
58
+ .join(" ");
59
+ Bun.write(Bun.stderr, `${prefix}${line}\n`);
60
+ };
61
+ // Intentional: we want console.* for user handlers to go to stderr.
62
+ // Overwrite the globals before any user code is loaded.
63
+ c.__stdoutLog = c.log;
64
+ c.log = toStderr("");
65
+ c.info = toStderr("");
66
+ c.warn = toStderr("[warn] ");
67
+ c.error = toStderr("[error] ");
68
+ c.debug = toStderr("[debug] ");
69
+ }