@pylonsync/functions 0.11.5 → 0.12.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;
@@ -141,6 +149,20 @@ export function assertNotServerOnly(specifier: string, importer: string): void {
141
149
  }
142
150
  }
143
151
 
152
+ /**
153
+ * A module file → its project-relative, extension-less component path
154
+ * (`<cwd>/app/blog/page.tsx` → `app/blog/page`). Always "/"-separated, on
155
+ * Windows too: the result is a manifest key, the input to the SSR boundary
156
+ * walk, and is spliced into generated `import` specifiers, where a backslash
157
+ * is a string escape (`"../app\page"` resolves `../apppage`).
158
+ */
159
+ export function componentPathFor(path: any, cwd: string, file: string): string {
160
+ return path
161
+ .relative(cwd, file)
162
+ .replace(/\.(tsx?|jsx?)$/, "")
163
+ .replace(/\\/g, "/");
164
+ }
165
+
144
166
  /**
145
167
  * Synchronously walk the route dir (`<appDirRel>` under cwd, e.g.
146
168
  * `app` or `web/app`) and return one entry per discovered page, each
@@ -182,7 +204,7 @@ function discoverRoutes(
182
204
  .map((n: string) => path.join(dir, n))
183
205
  .find((p: string) => fs.existsSync(p));
184
206
  const nextLayouts = layoutHere
185
- ? [...layouts, path.relative(cwd, layoutHere).replace(/\.(tsx?|jsx?)$/, "")]
207
+ ? [...layouts, componentPathFor(path, cwd, layoutHere)]
186
208
  : layouts;
187
209
  const pageHere = ["page.tsx", "page.ts", "page.jsx", "page.js"]
188
210
  .map((n: string) => path.join(dir, n))
@@ -190,7 +212,7 @@ function discoverRoutes(
190
212
  if (pageHere) {
191
213
  pages.push({
192
214
  segments: [...segments],
193
- component: path.relative(cwd, pageHere).replace(/\.(tsx?|jsx?)$/, ""),
215
+ component: componentPathFor(path, cwd, pageHere),
194
216
  layouts: nextLayouts,
195
217
  // e.g. [] → "/", ["p","[slug]"] → "/p/[slug]".
196
218
  pattern: "/" + segments.join("/"),
@@ -207,7 +229,7 @@ function discoverRoutes(
207
229
  if (bHere) {
208
230
  pages.push({
209
231
  segments: [...segments],
210
- component: path.relative(cwd, bHere).replace(/\.(tsx?|jsx?)$/, ""),
232
+ component: componentPathFor(path, cwd, bHere),
211
233
  layouts: nextLayouts,
212
234
  });
213
235
  }
@@ -255,9 +277,7 @@ export function discoverLoadingModules(
255
277
  .map((n: string) => path.join(dir, n))
256
278
  .find((p: string) => fs.existsSync(p));
257
279
  if (here) {
258
- found.push(
259
- path.relative(cwd, here).replace(/\.(tsx?|jsx?)$/, "").replace(/\\/g, "/"),
260
- );
280
+ found.push(componentPathFor(path, cwd, here));
261
281
  }
262
282
  for (const e of entries) {
263
283
  if (!e.isDirectory()) continue;
@@ -1137,8 +1157,12 @@ function slugForComponent(component: string): string {
1137
1157
  export interface PylonBundleManifest {
1138
1158
  /** Build identity — bumps every successful build. */
1139
1159
  build_id: string;
1140
- /** 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. */
1141
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;
1142
1166
  /** Public URL prefix the Rust host serves chunks under. */
1143
1167
  public_prefix: string;
1144
1168
  /** routeComponentPath → file + imports for that route. */
@@ -1196,7 +1220,7 @@ async function _prebuiltBundle(): Promise<BuildOutput | null> {
1196
1220
  const pathMod: any = await import("node:path");
1197
1221
  const fs = fsMod.default ?? fsMod;
1198
1222
  const path = pathMod.default ?? pathMod;
1199
- const outdir = path.join(process.cwd(), ".pylon", "client-build");
1223
+ const outdir = path.join(process.cwd(), clientBuildDir());
1200
1224
  const manifestPath = path.join(outdir, "manifest.json");
1201
1225
  if (!fs.existsSync(manifestPath)) return null;
1202
1226
 
@@ -1257,6 +1281,48 @@ export async function buildClientBundle(
1257
1281
  return _inflightBuild;
1258
1282
  }
1259
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
+
1260
1326
  async function _doBuild(appDirRel: string): Promise<BuildOutput> {
1261
1327
  // node:* are available in Bun, but `globalThis.require` is
1262
1328
  // not defined in ESM. Use dynamic import; Bun fast-paths these.
@@ -1288,6 +1354,7 @@ export async function buildTailwind(
1288
1354
  cwd: string,
1289
1355
  outdir: string,
1290
1356
  appDirRel: string,
1357
+ cssTargets?: string[],
1291
1358
  ): Promise<string | null> {
1292
1359
  const globalsPath = path.join(cwd, appDirRel, "globals.css");
1293
1360
  if (!fs.existsSync(globalsPath)) return null;
@@ -1348,7 +1415,13 @@ export async function buildTailwind(
1348
1415
  throw new Error(`tailwindcss build failed (exit ${exitCode}): ${err}`);
1349
1416
  }
1350
1417
 
1351
- 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
+ }
1352
1425
  let hash = 0;
1353
1426
  for (let i = 0; i < out.length; i++) {
1354
1427
  hash = (hash * 31 + out.charCodeAt(i)) >>> 0;
@@ -1381,12 +1454,27 @@ export async function buildTailwind(
1381
1454
  return stylesName;
1382
1455
  }
1383
1456
 
1384
- async function _doBuildInner(
1457
+ // Exported for the Windows-separator regression test (ssr-client-bundler.test.ts),
1458
+ // which injects a `path` whose `relative` returns "\"-separated paths.
1459
+ export async function _doBuildInner(
1385
1460
  fs: any,
1386
1461
  path: any,
1387
1462
  cwd: string,
1388
1463
  appDirRel: string,
1464
+ opts: ClientBuildOptions = {},
1389
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;
1390
1478
  const routes = discoverRoutes(fs, path, cwd, appDirRel);
1391
1479
  if (routes.length === 0) {
1392
1480
  throw new Error(
@@ -1409,7 +1497,7 @@ async function _doBuildInner(
1409
1497
  }
1410
1498
  }
1411
1499
  }
1412
- const outdir = path.join(stageDir, "client-build");
1500
+ const outdir = opts.outdir ?? path.join(cwd, CLIENT_BUILD_DIR);
1413
1501
  try {
1414
1502
  fs.rmSync(outdir, { recursive: true, force: true });
1415
1503
  } catch {
@@ -1508,7 +1596,7 @@ async function _doBuildInner(
1508
1596
  target: "browser",
1509
1597
  format: "esm",
1510
1598
  minify: true,
1511
- sourcemap: "none",
1599
+ sourcemap: sourcemap ? "linked" : "none",
1512
1600
  splitting: true,
1513
1601
  // The browser has no `process`. React, next-themes, sonner, and most
1514
1602
  // npm UI deps reference `process.env.NODE_ENV` (and migrated Next code
@@ -1557,7 +1645,38 @@ async function _doBuildInner(
1557
1645
  // - chunks (kind === "chunk") — looked up by basename when
1558
1646
  // scanning entry files for static `import "./chunks/..."`
1559
1647
  // specifiers.
1560
- const outdirRel = path.relative(cwd, outdir);
1648
+ // Manifest paths are URL paths under `public_prefix` ("/"-separated). The
1649
+ // runtime's /_pylon/build/ handler rejects any request with a backslash.
1650
+ const toUrlPath = (p: string) => p.replace(/\\/g, "/");
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
+ }
1561
1680
  const entriesByStem = new Map<
1562
1681
  string,
1563
1682
  { absPath: string; relPath: string }
@@ -1566,9 +1685,9 @@ async function _doBuildInner(
1566
1685
  string,
1567
1686
  { absPath: string; relPath: string }
1568
1687
  >();
1569
- for (const o of result.outputs) {
1688
+ for (const o of outputs) {
1570
1689
  const absPath: string = o.path;
1571
- const relPath = path.relative(outdir, absPath);
1690
+ const relPath = toUrlPath(path.relative(outdir, absPath));
1572
1691
  const base = path.basename(absPath);
1573
1692
  // Strip `-<hash>.js` to recover the entry source's stem
1574
1693
  // (e.g. `client-entry-app__hello__page`). The hash is
@@ -1591,7 +1710,7 @@ async function _doBuildInner(
1591
1710
  // the binary next to the entries AND into chunks/ so the runtime URL
1592
1711
  // resolves from either an entry or a split chunk.
1593
1712
  try {
1594
- const referencesLoroWasm = result.outputs.some((o) => {
1713
+ const referencesLoroWasm = outputs.some((o) => {
1595
1714
  if (!o.path.endsWith(".js")) return false;
1596
1715
  try {
1597
1716
  return fs.readFileSync(o.path, "utf8").includes("loro_wasm_bg.wasm");
@@ -1665,6 +1784,7 @@ async function _doBuildInner(
1665
1784
  outdir: outdirRel,
1666
1785
  public_prefix: "/_pylon/build/",
1667
1786
  routes: {},
1787
+ ...(polyfills ? { polyfills } : {}),
1668
1788
  };
1669
1789
  for (const r of routes) {
1670
1790
  const slug = slugForComponent(r.component);
@@ -1718,7 +1838,7 @@ async function _doBuildInner(
1718
1838
  // pylon.manifest.json from cwd. It's generated output — commonly
1719
1839
  // gitignored — so when it's absent the bundle still builds but
1720
1840
  // silently loses those features. Say so, loudly, instead.
1721
- if (!fs.existsSync(path.join(cwd, "pylon.manifest.json"))) {
1841
+ if (!fs.existsSync(manifestFile)) {
1722
1842
  // eslint-disable-next-line no-console
1723
1843
  console.warn(
1724
1844
  "[pylon ssr] pylon.manifest.json not found — building the client " +
@@ -1733,7 +1853,14 @@ async function _doBuildInner(
1733
1853
  // array so SSR head injection emits `<link rel="stylesheet">`.
1734
1854
  let stylesRel: string | null = null;
1735
1855
  try {
1736
- 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
+ );
1737
1864
  if (styles) {
1738
1865
  stylesRel = styles;
1739
1866
  for (const r of Object.values(manifest.routes)) {
@@ -1752,6 +1879,7 @@ async function _doBuildInner(
1752
1879
  /\u001b\[[0-9;]*m/g,
1753
1880
  "",
1754
1881
  );
1882
+ if (opts.strict) throw new Error(`CSS build failed: ${cssMsg}`);
1755
1883
  manifest.css_error = cssMsg;
1756
1884
  // eslint-disable-next-line no-console
1757
1885
  console.warn(`[pylon ssr] tailwind compile failed: ${cssMsg}`);
@@ -1765,12 +1893,13 @@ async function _doBuildInner(
1765
1893
  // the prebuilt artifact. A fetch/parse failure degrades to a variable-only
1766
1894
  // entry — it never kills the build.
1767
1895
  try {
1768
- const declaredFonts = readManifestFonts(fs, path, cwd);
1896
+ const declaredFonts = readManifestFonts(fs, path, cwd, manifestFile);
1769
1897
  if (declaredFonts.length > 0) {
1770
1898
  const builtFonts = await buildFonts(fs, path, cwd, outdir, declaredFonts);
1771
1899
  if (builtFonts) manifest.fonts = builtFonts;
1772
1900
  }
1773
1901
  } catch (fErr: any) {
1902
+ if (opts.strict) throw new Error(`font build failed: ${fErr?.message ?? fErr}`);
1774
1903
  // eslint-disable-next-line no-console
1775
1904
  console.warn(`[pylon ssr] font build failed: ${fErr?.message ?? fErr}`);
1776
1905
  }
@@ -1846,7 +1975,7 @@ export async function getManifest(): Promise<PylonBundleManifest> {
1846
1975
  const fs = fsMod.default ?? fsMod;
1847
1976
  const path = pathMod.default ?? pathMod;
1848
1977
  const cwd = process.cwd();
1849
- const manifestPath = path.join(cwd, ".pylon", "client-build", "manifest.json");
1978
+ const manifestPath = path.join(cwd, clientBuildDir(), "manifest.json");
1850
1979
 
1851
1980
  if (fs.existsSync(manifestPath)) {
1852
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"