@pylonsync/functions 0.3.292 → 0.3.294

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.
@@ -160,6 +160,38 @@ describe("ssr-client-bundler (Phase 1.5e)", () => {
160
160
  expect(chunkHasReact).toBe(true);
161
161
  });
162
162
 
163
+ test("client runtime wires the nav-fallback guard (uncaught render error → full page load)", async () => {
164
+ // Regression: a page that renders React-19-hoisted <title>/<meta>/<link> in
165
+ // its own tree (use the `metadata` export instead) makes a client-side nav
166
+ // re-render throw in React's commit phase — the URL changes but the page
167
+ // can't swap (white screen). hydrateRoot must carry an onUncaughtError that
168
+ // falls back to a full page load of the in-flight destination so nav
169
+ // degrades gracefully. The distinctive console string is preserved through
170
+ // minification, so we assert the guard actually ships in the bundle.
171
+ tempDir = makeFixture(
172
+ { "page.tsx": PAGE_BODY("Home") },
173
+ { "layout.tsx": LAYOUT_BODY },
174
+ );
175
+ originalCwd = process.cwd();
176
+ process.chdir(tempDir);
177
+
178
+ const { outdir } = await buildClientBundle();
179
+ // The runtime (hydrateRoot + guard) lands in the shared chunk with multiple
180
+ // entries, or inlined in the entry with one — read every emitted .js.
181
+ const readJsRecursive = (dir: string): string[] =>
182
+ fs.readdirSync(dir, { withFileTypes: true }).flatMap((d) => {
183
+ const full = path.join(dir, d.name);
184
+ if (d.isDirectory()) return readJsRecursive(full);
185
+ return d.name.endsWith(".js") ? [fs.readFileSync(full, "utf8")] : [];
186
+ });
187
+ const bundled = readJsRecursive(outdir).join("\n");
188
+
189
+ // onUncaughtError is a React hydrateRoot option (property name preserved).
190
+ expect(bundled).toMatch(/onUncaughtError/);
191
+ // The fallback path: a full page load of the pending destination.
192
+ expect(bundled).toMatch(/falling back to a full page load/);
193
+ });
194
+
163
195
  test("manifest names every route, each with a non-empty imports list", async () => {
164
196
  tempDir = makeFixture(
165
197
  {
@@ -33,6 +33,8 @@
33
33
  // prefetch is a follow-up — splitting is the precondition.
34
34
  // - CSS chunking. No CSS support in SSR yet.
35
35
 
36
+ import { buildFonts, readManifestFonts, type ManifestFonts } from "./ssr-fonts";
37
+
36
38
  type Send = (msg: Record<string, unknown>) => void;
37
39
 
38
40
  interface BundleClientMessage {
@@ -210,6 +212,10 @@ import { createPylonBoundary } from "./client-boundary";
210
212
 
211
213
  const routeCache = Object.create(null);
212
214
  let activeRoot = null;
215
+ // Destination of an in-flight client navigation. Read by hydrateRoot's
216
+ // onUncaughtError so a re-render that throws mid-nav degrades to a full page
217
+ // load instead of a white screen. Null when no nav is in flight.
218
+ let pendingNav = null;
213
219
  let manifestPromise = null;
214
220
  const prefetchedChunks = new Set();
215
221
 
@@ -464,7 +470,30 @@ export function hydrate(component, Page, Layouts) {
464
470
  data.component,
465
471
  navEpoch,
466
472
  );
467
- activeRoot = hydrateRoot(document, tree);
473
+ activeRoot = hydrateRoot(document, tree, {
474
+ // Safety net for client navigation. If a nav re-render throws an uncaught
475
+ // error in React's commit phase, the URL has already changed but the page
476
+ // can't swap — a white/stale screen. The classic trigger is a page that
477
+ // renders hoisted <title>/<meta>/<link> in its own tree (use the
478
+ // \`metadata\` export instead); React 19 owns those head nodes on the client
479
+ // and reconciling them across routes can throw. Rather than strand the
480
+ // user, fall back to a full page load of the pending destination, which
481
+ // re-renders it cleanly from SSR. Non-navigation errors keep React's
482
+ // default reporting.
483
+ onUncaughtError(error) {
484
+ if (pendingNav) {
485
+ const dest = pendingNav;
486
+ pendingNav = null;
487
+ console.error(
488
+ "[pylon ssr] client navigation failed to render; falling back to a full page load:",
489
+ error,
490
+ );
491
+ window.location.href = dest;
492
+ return;
493
+ }
494
+ console.error(error);
495
+ },
496
+ });
468
497
  installNavHandlers();
469
498
  return;
470
499
  }
@@ -542,13 +571,21 @@ async function navigate(href, opts) {
542
571
  setNavParams(data);
543
572
  navEpoch++;
544
573
  currentPageProps = withClientProps(data);
574
+ const target = url.pathname + url.search;
545
575
  const tree = withBoundary(
546
576
  buildTree(route.Page, route.Layouts, currentPageProps),
547
577
  data.component,
548
578
  navEpoch,
549
579
  );
580
+ // Track the in-flight destination so hydrateRoot's onUncaughtError can fall
581
+ // back to a full page load if this re-render throws in React's commit phase
582
+ // (instead of leaving the URL changed but the page unswapped). Cleared on the
583
+ // next macrotask once the commit has settled with no error.
584
+ pendingNav = target;
550
585
  activeRoot.render(tree);
551
- const target = url.pathname + url.search;
586
+ setTimeout(() => {
587
+ if (pendingNav === target) pendingNav = null;
588
+ }, 0);
552
589
  if (opts && opts.replace) {
553
590
  history.replaceState({ component: data.component }, "", target);
554
591
  } else if (push) {
@@ -724,6 +761,11 @@ export interface PylonBundleManifest {
724
761
  css: string[];
725
762
  }
726
763
  >;
764
+ /** Self-hosted fonts (next/font parity): structured `@font-face`s + the
765
+ * `:root` CSS variables + the woff2 files to preload. Global (route-
766
+ * independent); rendered into every SSR `<head>` against `public_prefix`.
767
+ * Absent when the app declares no `font({...})`. */
768
+ fonts?: ManifestFonts;
727
769
  }
728
770
 
729
771
  /** Result of an in-process build — same shape the protocol returns. */
@@ -1169,6 +1211,24 @@ async function _doBuildInner(
1169
1211
  console.warn(`[pylon ssr] tailwind compile failed: ${twErr?.message ?? twErr}`);
1170
1212
  }
1171
1213
 
1214
+ // Self-hosted fonts (next/font parity). Reads `fonts` from the app's
1215
+ // pylon.manifest.json, fetches + self-hosts each woff2 into outdir (served
1216
+ // under /_pylon/build/), and bakes the structured faces + size-adjusted
1217
+ // fallback metrics into the bundle manifest for SSR head injection. On
1218
+ // Pylon Cloud the builder runs this same path, so the woff2 + faces ship in
1219
+ // the prebuilt artifact. A fetch/parse failure degrades to a variable-only
1220
+ // entry — it never kills the build.
1221
+ try {
1222
+ const declaredFonts = readManifestFonts(fs, path, cwd);
1223
+ if (declaredFonts.length > 0) {
1224
+ const builtFonts = await buildFonts(fs, path, cwd, outdir, declaredFonts);
1225
+ if (builtFonts) manifest.fonts = builtFonts;
1226
+ }
1227
+ } catch (fErr: any) {
1228
+ // eslint-disable-next-line no-console
1229
+ console.warn(`[pylon ssr] font build failed: ${fErr?.message ?? fErr}`);
1230
+ }
1231
+
1172
1232
  const manifestPath = path.join(outdir, "manifest.json");
1173
1233
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
1174
1234
 
@@ -0,0 +1,303 @@
1
+ // Tests for the self-hosted font engine: sfnt metrics parsing, size-adjust
2
+ // math, CSS rendering, and the Google Fonts CSS2 request/parse helpers.
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import {
6
+ buildGoogleFontsUrl,
7
+ computeFallbackFace,
8
+ decodeFontMetrics,
9
+ parseGoogleFontsCss,
10
+ renderFontFaceCss,
11
+ type ManifestFonts,
12
+ } from "./ssr-fonts";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Synthesize a minimal valid raw sfnt (ttf) with head/hhea/OS-2 tables so we
16
+ // can assert the byte-offset metrics parser against known values.
17
+ // ---------------------------------------------------------------------------
18
+ function makeSfnt(opts: {
19
+ unitsPerEm: number;
20
+ hheaAscent: number;
21
+ hheaDescent: number;
22
+ hheaLineGap: number;
23
+ xAvgCharWidth: number;
24
+ useTypoMetrics: boolean;
25
+ sTypoAscender: number;
26
+ sTypoDescender: number;
27
+ sTypoLineGap: number;
28
+ }): Uint8Array {
29
+ const HEAD_LEN = 54;
30
+ const HHEA_LEN = 36;
31
+ const OS2_LEN = 96;
32
+ const dirStart = 12;
33
+ const numTables = 3;
34
+ const dataStart = dirStart + numTables * 16; // 60
35
+ const headOff = dataStart;
36
+ const hheaOff = headOff + HEAD_LEN;
37
+ const os2Off = hheaOff + HHEA_LEN;
38
+ const total = os2Off + OS2_LEN;
39
+
40
+ const buf = new Uint8Array(total);
41
+ const dv = new DataView(buf.buffer);
42
+
43
+ // sfnt offset table
44
+ dv.setUint32(0, 0x00010000); // version 1.0 (truetype)
45
+ dv.setUint16(4, numTables);
46
+
47
+ // table directory (tag, checksum, offset, length). Tags sorted alphabetically
48
+ // isn't required by our parser, which indexes by tag.
49
+ function writeRecord(i: number, tag: string, off: number, len: number) {
50
+ const p = dirStart + i * 16;
51
+ buf[p] = tag.charCodeAt(0);
52
+ buf[p + 1] = tag.charCodeAt(1);
53
+ buf[p + 2] = tag.charCodeAt(2);
54
+ buf[p + 3] = tag.charCodeAt(3);
55
+ dv.setUint32(p + 4, 0); // checksum (unused by parser)
56
+ dv.setUint32(p + 8, off);
57
+ dv.setUint32(p + 12, len);
58
+ }
59
+ writeRecord(0, "head", headOff, HEAD_LEN);
60
+ writeRecord(1, "hhea", hheaOff, HHEA_LEN);
61
+ writeRecord(2, "OS/2", os2Off, OS2_LEN);
62
+
63
+ // head: unitsPerEm @ +18
64
+ dv.setUint16(headOff + 18, opts.unitsPerEm);
65
+
66
+ // hhea: ascender @ +4, descender @ +6, lineGap @ +8
67
+ dv.setInt16(hheaOff + 4, opts.hheaAscent);
68
+ dv.setInt16(hheaOff + 6, opts.hheaDescent);
69
+ dv.setInt16(hheaOff + 8, opts.hheaLineGap);
70
+
71
+ // OS/2: xAvgCharWidth @ +2, fsSelection @ +62, sTypo* @ +68/+70/+72
72
+ dv.setInt16(os2Off + 2, opts.xAvgCharWidth);
73
+ dv.setUint16(os2Off + 62, opts.useTypoMetrics ? 0x80 : 0x00);
74
+ dv.setInt16(os2Off + 68, opts.sTypoAscender);
75
+ dv.setInt16(os2Off + 70, opts.sTypoDescender);
76
+ dv.setInt16(os2Off + 72, opts.sTypoLineGap);
77
+
78
+ return buf;
79
+ }
80
+
81
+ describe("decodeFontMetrics — raw sfnt", () => {
82
+ test("reads unitsPerEm + hhea metrics + xAvgCharWidth", () => {
83
+ const buf = makeSfnt({
84
+ unitsPerEm: 1000,
85
+ hheaAscent: 950,
86
+ hheaDescent: -250,
87
+ hheaLineGap: 0,
88
+ xAvgCharWidth: 500,
89
+ useTypoMetrics: false,
90
+ sTypoAscender: 800,
91
+ sTypoDescender: -200,
92
+ sTypoLineGap: 10,
93
+ });
94
+ const m = decodeFontMetrics(buf);
95
+ expect(m).not.toBeNull();
96
+ expect(m!.unitsPerEm).toBe(1000);
97
+ // USE_TYPO_METRICS off → hhea ascender/descender/lineGap.
98
+ expect(m!.ascent).toBe(950);
99
+ expect(m!.descent).toBe(-250);
100
+ expect(m!.lineGap).toBe(0);
101
+ expect(m!.xAvgCharWidth).toBe(500);
102
+ });
103
+
104
+ test("prefers OS/2 sTypo* when USE_TYPO_METRICS is set", () => {
105
+ const buf = makeSfnt({
106
+ unitsPerEm: 2048,
107
+ hheaAscent: 1900,
108
+ hheaDescent: -500,
109
+ hheaLineGap: 0,
110
+ xAvgCharWidth: 1000,
111
+ useTypoMetrics: true,
112
+ sTypoAscender: 1600,
113
+ sTypoDescender: -400,
114
+ sTypoLineGap: 90,
115
+ });
116
+ const m = decodeFontMetrics(buf);
117
+ expect(m!.ascent).toBe(1600);
118
+ expect(m!.descent).toBe(-400);
119
+ expect(m!.lineGap).toBe(90);
120
+ });
121
+
122
+ test("returns null for a too-short / unknown buffer", () => {
123
+ expect(decodeFontMetrics(new Uint8Array(4))).toBeNull();
124
+ });
125
+ });
126
+
127
+ describe("computeFallbackFace — size-adjust math", () => {
128
+ test("computes size-adjust + overrides against Arial", () => {
129
+ // unitsPerEm 1000, xAvgCharWidth 500 → webAvg 0.5.
130
+ // Arial xWidthAvg/em = 904/2048 = 0.4414062500.
131
+ // sizeAdjust = 0.5 / 0.44140625 = 1.13274336... → "113.27%"
132
+ const face = computeFallbackFace(
133
+ {
134
+ unitsPerEm: 1000,
135
+ ascent: 800,
136
+ descent: -200,
137
+ lineGap: 0,
138
+ xAvgCharWidth: 500,
139
+ },
140
+ "Geist",
141
+ "sans-serif",
142
+ );
143
+ expect(face.family).toBe("Geist Fallback");
144
+ expect(face.local).toBe("Arial");
145
+ expect(face.src).toEqual([]);
146
+ expect(face.sizeAdjust).toBe("113.27%");
147
+ // em = 1000 * 1.13274336 = 1132.7436; ascent 800/em ≈ 0.70625 → ~70.62%
148
+ expect(parseFloat(face.ascentOverride!)).toBeCloseTo(70.62, 1);
149
+ // descent is negative; override is the positive magnitude. 200/em ≈ 17.66%
150
+ expect(parseFloat(face.descentOverride!)).toBeCloseTo(17.66, 1);
151
+ expect(face.lineGapOverride).toBe("0.00%");
152
+ });
153
+
154
+ test("falls back to size-adjust 100% when avg widths are missing", () => {
155
+ const face = computeFallbackFace(
156
+ { unitsPerEm: 1000, ascent: 800, descent: -200, lineGap: 0, xAvgCharWidth: 0 },
157
+ "Mono",
158
+ "sans-serif",
159
+ );
160
+ expect(face.sizeAdjust).toBe("100.00%");
161
+ });
162
+
163
+ test("serif category uses Times New Roman as the local fallback", () => {
164
+ const face = computeFallbackFace(
165
+ { unitsPerEm: 2048, ascent: 1500, descent: -400, lineGap: 0, xAvgCharWidth: 900 },
166
+ "Lora",
167
+ "serif",
168
+ );
169
+ expect(face.local).toBe("Times New Roman");
170
+ });
171
+ });
172
+
173
+ describe("renderFontFaceCss", () => {
174
+ const fonts: ManifestFonts = {
175
+ faces: [
176
+ {
177
+ family: "Geist",
178
+ src: ["font-abc123.woff2"],
179
+ weight: "400",
180
+ style: "normal",
181
+ display: "swap",
182
+ unicodeRange: "U+0000-00FF",
183
+ },
184
+ {
185
+ family: "Geist Fallback",
186
+ src: [],
187
+ local: "Arial",
188
+ sizeAdjust: "113.27%",
189
+ ascentOverride: "70.62%",
190
+ descentOverride: "17.66%",
191
+ lineGapOverride: "0.00%",
192
+ },
193
+ ],
194
+ variables: { "--font-sans": '"Geist", "Geist Fallback", sans-serif' },
195
+ preload: ["font-abc123.woff2"],
196
+ };
197
+
198
+ test("resolves woff2 URLs against the given prefix", () => {
199
+ const css = renderFontFaceCss(fonts, "/_pylon/build/");
200
+ expect(css).toContain(
201
+ 'src:url(/_pylon/build/font-abc123.woff2) format("woff2")',
202
+ );
203
+ });
204
+
205
+ test("uses an absolute CDN prefix verbatim", () => {
206
+ const css = renderFontFaceCss(fonts, "https://cdn.example.com/b/");
207
+ expect(css).toContain("url(https://cdn.example.com/b/font-abc123.woff2)");
208
+ });
209
+
210
+ test("emits the size-adjusted fallback face with local() + overrides", () => {
211
+ const css = renderFontFaceCss(fonts, "/_pylon/build/");
212
+ expect(css).toContain('font-family:"Geist Fallback"');
213
+ expect(css).toContain('src:local("Arial")');
214
+ expect(css).toContain("size-adjust:113.27%");
215
+ expect(css).toContain("ascent-override:70.62%");
216
+ expect(css).toContain("descent-override:17.66%");
217
+ expect(css).toContain("line-gap-override:0.00%");
218
+ });
219
+
220
+ test("emits the :root variable", () => {
221
+ const css = renderFontFaceCss(fonts, "/_pylon/build/");
222
+ expect(css).toContain(
223
+ ':root{--font-sans:"Geist", "Geist Fallback", sans-serif;}',
224
+ );
225
+ });
226
+ });
227
+
228
+ describe("buildGoogleFontsUrl", () => {
229
+ test("single axis, weights sorted ascending", () => {
230
+ const url = buildGoogleFontsUrl({
231
+ family: "Geist",
232
+ weights: ["700", "400"],
233
+ styles: ["normal"],
234
+ subsets: ["latin"],
235
+ display: "swap",
236
+ });
237
+ expect(url).toBe(
238
+ "https://fonts.googleapis.com/css2?family=Geist:wght@400;700&display=swap",
239
+ );
240
+ });
241
+
242
+ test("encodes spaces in the family name", () => {
243
+ const url = buildGoogleFontsUrl({
244
+ family: "Open Sans",
245
+ weights: ["400"],
246
+ styles: ["normal"],
247
+ subsets: ["latin"],
248
+ display: "swap",
249
+ });
250
+ expect(url).toContain("family=Open+Sans:wght@400");
251
+ });
252
+
253
+ test("italic uses the ital,wght axis with sorted tuples", () => {
254
+ const url = buildGoogleFontsUrl({
255
+ family: "Inter",
256
+ weights: ["400", "700"],
257
+ styles: ["normal", "italic"],
258
+ subsets: ["latin"],
259
+ display: "swap",
260
+ });
261
+ expect(url).toContain("Inter:ital,wght@0,400;0,700;1,400;1,700");
262
+ });
263
+ });
264
+
265
+ describe("parseGoogleFontsCss", () => {
266
+ const css = `
267
+ /* cyrillic */
268
+ @font-face {
269
+ font-family: 'Geist';
270
+ font-style: normal;
271
+ font-weight: 400;
272
+ font-display: swap;
273
+ src: url(https://fonts.gstatic.com/s/geist/v1/cyr.woff2) format('woff2');
274
+ unicode-range: U+0301, U+0400-045F;
275
+ }
276
+ /* latin */
277
+ @font-face {
278
+ font-family: 'Geist';
279
+ font-style: normal;
280
+ font-weight: 400;
281
+ font-display: swap;
282
+ src: url(https://fonts.gstatic.com/s/geist/v1/latin.woff2) format('woff2');
283
+ unicode-range: U+0000-00FF;
284
+ }
285
+ `;
286
+
287
+ test("keeps only the requested subsets", () => {
288
+ const blocks = parseGoogleFontsCss(css, ["latin"]);
289
+ expect(blocks.length).toBe(1);
290
+ expect(blocks[0].subset).toBe("latin");
291
+ expect(blocks[0].url).toBe(
292
+ "https://fonts.gstatic.com/s/geist/v1/latin.woff2",
293
+ );
294
+ expect(blocks[0].weight).toBe("400");
295
+ expect(blocks[0].style).toBe("normal");
296
+ expect(blocks[0].unicodeRange).toBe("U+0000-00FF");
297
+ });
298
+
299
+ test("returns every block when no subset filter is given", () => {
300
+ const blocks = parseGoogleFontsCss(css, []);
301
+ expect(blocks.length).toBe(2);
302
+ });
303
+ });