@pylonsync/functions 0.3.312 → 0.3.315

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.
@@ -59,6 +59,11 @@ export interface PylonBundleManifest {
59
59
  * independent); rendered into every SSR `<head>` against `public_prefix`.
60
60
  * Absent when the app declares no `font({...})`. */
61
61
  fonts?: ManifestFonts;
62
+ /** Set when the Tailwind compile failed for this build: the pages are
63
+ * serving WITHOUT styles. Dev surfaces it as an on-page banner (see
64
+ * pylonDevHud) — a silent unstyled page reads as a CSS bug, not a
65
+ * build failure, and costs real debugging time. */
66
+ css_error?: string;
62
67
  }
63
68
  /** Result of an in-process build — same shape the protocol returns. */
64
69
  export interface BuildOutput {
@@ -351,6 +351,7 @@ export declare function applyAutoSocialImages(component: string, headers: Record
351
351
  */
352
352
  export declare function escapeScriptJson(json: string): string;
353
353
  export declare function buildDevHudChunk(devInfo: Record<string, unknown>): string;
354
+ export declare function cssHeadTag(css: string, prefix: string, routeOverride?: boolean): Promise<string>;
354
355
  /**
355
356
  * Build the hydration tail appended after React's stream EOFs: the
356
357
  * `__PYLON_DATA__` JSON blob (props + ssrData) + the per-route entry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.312",
3
+ "version": "0.3.315",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/runtime.ts CHANGED
@@ -32,6 +32,7 @@ import type {
32
32
  AuthInfo,
33
33
  } from "./types";
34
34
  import { makeRequireMember } from "./member";
35
+ import { isDevMode } from "./ssr-runtime";
35
36
  import { validateArgs } from "./validators";
36
37
  import { readdirSync } from "fs";
37
38
  import { join, basename } from "path";
@@ -989,21 +990,43 @@ async function handleCall(msg: CallMessage): Promise<void> {
989
990
  } else {
990
991
  // No explicit code — assume it's an unexpected Error/thrown value.
991
992
  // Log the real error to stderr (server operator visible) and return
992
- // a safe placeholder to the client.
993
+ // a safe placeholder to the client. In DEV the real message (and
994
+ // top stack frame) rides along: the developer debugging a 500 IS
995
+ // the operator, and hiding the reason from the HTTP response just
996
+ // sends them (or their agent) digging through server logs for
997
+ // something we already know. Production responses stay masked.
993
998
  console.error(
994
999
  `[functions] unhandled error in ${msg.fn_name} (${msg.call_id}):`,
995
1000
  err,
996
1001
  );
1002
+ const devDetail =
1003
+ isDevMode() && typeof err?.message === "string" && err.message.length > 0
1004
+ ? ` (dev): ${err.message}${firstStackFrame(err)}`
1005
+ : "";
997
1006
  send({
998
1007
  type: "error",
999
1008
  call_id: msg.call_id,
1000
1009
  code: "HANDLER_ERROR",
1001
- message: "Internal handler error",
1010
+ message: `Internal handler error${devDetail}`,
1002
1011
  });
1003
1012
  }
1004
1013
  }
1005
1014
  }
1006
1015
 
1016
+ /**
1017
+ * The first user-code stack frame of an error, for dev-mode error
1018
+ * detail — one frame locates the throw without shipping a whole trace.
1019
+ */
1020
+ function firstStackFrame(err: unknown): string {
1021
+ const stack = (err as { stack?: string })?.stack;
1022
+ if (typeof stack !== "string") return "";
1023
+ const frame = stack
1024
+ .split("\n")
1025
+ .map((l) => l.trim())
1026
+ .find((l) => l.startsWith("at "));
1027
+ return frame ? ` [${frame}]` : "";
1028
+ }
1029
+
1007
1030
  // ---------------------------------------------------------------------------
1008
1031
  // Startup: scan functions dir, send ready, then start reader loop
1009
1032
  // ---------------------------------------------------------------------------
@@ -935,6 +935,11 @@ export interface PylonBundleManifest {
935
935
  * independent); rendered into every SSR `<head>` against `public_prefix`.
936
936
  * Absent when the app declares no `font({...})`. */
937
937
  fonts?: ManifestFonts;
938
+ /** Set when the Tailwind compile failed for this build: the pages are
939
+ * serving WITHOUT styles. Dev surfaces it as an on-page banner (see
940
+ * pylonDevHud) — a silent unstyled page reads as a CSS bug, not a
941
+ * build failure, and costs real debugging time. */
942
+ css_error?: string;
938
943
  }
939
944
 
940
945
  /** Result of an in-process build — same shape the protocol returns. */
@@ -1457,6 +1462,20 @@ async function _doBuildInner(
1457
1462
  );
1458
1463
  }
1459
1464
 
1465
+ // Manifest-driven features (fonts, route metadata) read
1466
+ // pylon.manifest.json from cwd. It's generated output — commonly
1467
+ // gitignored — so when it's absent the bundle still builds but
1468
+ // silently loses those features. Say so, loudly, instead.
1469
+ if (!fs.existsSync(path.join(cwd, "pylon.manifest.json"))) {
1470
+ // eslint-disable-next-line no-console
1471
+ console.warn(
1472
+ "[pylon ssr] pylon.manifest.json not found — building the client " +
1473
+ "bundle WITHOUT manifest-derived features (declared fonts will " +
1474
+ "be missing). Run `pylon codegen` first, or deploy through a " +
1475
+ "path that derives the manifest.",
1476
+ );
1477
+ }
1478
+
1460
1479
  // Tailwind v4 compile. Optional — only fires if the project has
1461
1480
  // `app/globals.css`. Adds the stylesheet to every route's css
1462
1481
  // array so SSR head injection emits `<link rel="stylesheet">`.
@@ -1470,10 +1489,20 @@ async function _doBuildInner(
1470
1489
  }
1471
1490
  }
1472
1491
  } catch (twErr: any) {
1473
- // Tailwind failure shouldn't kill the SSR build — log a loud
1474
- // warning + ship the bundle without styles so devs can iterate.
1492
+ // Tailwind failure shouldn't kill the SSR build — ship the bundle
1493
+ // without styles so devs can iterate. But NOT silently: the error
1494
+ // rides the bundle manifest so dev renders banner it on-page.
1495
+ // (The log line alone proved invisible in practice — an unstyled
1496
+ // page doesn't say "go read the bundler log".)
1497
+ // Tailwind's CLI output arrives ANSI-colored; strip the escapes —
1498
+ // this string renders in a browser banner, not a terminal.
1499
+ const cssMsg = String(twErr?.message ?? twErr).replace(
1500
+ /\u001b\[[0-9;]*m/g,
1501
+ "",
1502
+ );
1503
+ manifest.css_error = cssMsg;
1475
1504
  // eslint-disable-next-line no-console
1476
- console.warn(`[pylon ssr] tailwind compile failed: ${twErr?.message ?? twErr}`);
1505
+ console.warn(`[pylon ssr] tailwind compile failed: ${cssMsg}`);
1477
1506
  }
1478
1507
 
1479
1508
  // Self-hosted fonts (next/font parity). Reads `fonts` from the app's
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test, afterEach } from "bun:test";
2
+ import { cssHeadTag } from "./ssr-runtime";
3
+
4
+ const CSS = "styles-abc123.css";
5
+
6
+ afterEach(() => {
7
+ delete process.env.PYLON_SSR_INLINE_CSS;
8
+ delete process.env.PYLON_SSR_INLINE_CSS_MAX;
9
+ });
10
+
11
+ describe("cssHeadTag (PYLON_SSR_INLINE_CSS)", () => {
12
+ test("default off → plain stylesheet link, untouched", async () => {
13
+ const tag = await cssHeadTag(CSS, "/_pylon/build/");
14
+ expect(tag).toBe(`<link rel="stylesheet" href="/_pylon/build/${CSS}">`);
15
+ });
16
+
17
+ test("enabled but sheet unreadable → falls back to the link", async () => {
18
+ process.env.PYLON_SSR_INLINE_CSS = "1";
19
+ // No client build exists in the test cwd — the helper must degrade
20
+ // to the link, never throw or emit an empty <style>.
21
+ const tag = await cssHeadTag("styles-does-not-exist.css", "/_pylon/build/");
22
+ expect(tag).toBe(
23
+ '<link rel="stylesheet" href="/_pylon/build/styles-does-not-exist.css">',
24
+ );
25
+ });
26
+
27
+ test("CDN prefix rides the fallback link unchanged", async () => {
28
+ process.env.PYLON_SSR_INLINE_CSS = "true";
29
+ const tag = await cssHeadTag(
30
+ "styles-missing.css",
31
+ "https://assets.pyln.dev/o/p/",
32
+ );
33
+ expect(tag).toBe(
34
+ '<link rel="stylesheet" href="https://assets.pyln.dev/o/p/styles-missing.css">',
35
+ );
36
+ });
37
+ });
38
+
39
+ describe("cssHeadTag per-route override", () => {
40
+ test("route true wins over env off (falls back to link only because no build exists here)", async () => {
41
+ // Override=true ENABLES the inline path with env unset; with no
42
+ // client build in the test cwd it degrades to the link — proving
43
+ // the override reached the enabled gate (default-off would have
44
+ // returned the link WITHOUT attempting the read; exercised via the
45
+ // cache: a distinct filename keeps the assertions independent).
46
+ const tag = await cssHeadTag("styles-override.css", "/_pylon/build/", true);
47
+ expect(tag).toBe(
48
+ '<link rel="stylesheet" href="/_pylon/build/styles-override.css">',
49
+ );
50
+ });
51
+
52
+ test("route false wins over env on", async () => {
53
+ process.env.PYLON_SSR_INLINE_CSS = "1";
54
+ const tag = await cssHeadTag("styles-forced-off.css", "/_pylon/build/", false);
55
+ expect(tag).toBe(
56
+ '<link rel="stylesheet" href="/_pylon/build/styles-forced-off.css">',
57
+ );
58
+ });
59
+ });
@@ -1220,6 +1220,20 @@ function pylonDevHud() {
1220
1220
  // Marker the sync engine checks before publishing its dev status probe.
1221
1221
  g.__PYLON_DEV__ = info;
1222
1222
 
1223
+ // Build failure banner: when the build degraded the page (e.g. the
1224
+ // Tailwind compile failed and the page is serving unstyled), paint an
1225
+ // unmissable fixed banner. This is the loud path for failures that
1226
+ // would otherwise masquerade as app bugs.
1227
+ if (info.buildWarning) {
1228
+ const b = d.createElement("div");
1229
+ b.textContent = "⚠ " + String(info.buildWarning);
1230
+ b.style.cssText =
1231
+ "position:fixed;top:0;left:0;right:0;z-index:2147483647;" +
1232
+ "background:#dc2626;color:#fff;padding:8px 14px;" +
1233
+ "font:13px/1.4 ui-monospace,monospace;white-space:pre-wrap;";
1234
+ d.body.appendChild(b);
1235
+ }
1236
+
1223
1237
  // Client errors.
1224
1238
  const errs: string[] = [];
1225
1239
  const onErr = (m: any) => {
@@ -1553,6 +1567,66 @@ export function buildDevHudChunk(devInfo: Record<string, unknown>): string {
1553
1567
  * would look broken. Returns "" if the manifest can't be loaded — the
1554
1568
  * boundary still renders (unstyled); CSS must never block the error path.
1555
1569
  */
1570
+ /**
1571
+ * One `<head>` tag per route stylesheet: a `<link rel="stylesheet">` by
1572
+ * default, or the file's contents inlined in a `<style>` when inlining
1573
+ * is enabled — per route via `export const inlineCss = true|false`
1574
+ * (wins), else globally via PYLON_SSR_INLINE_CSS — and the compiled
1575
+ * sheet is small enough (PYLON_SSR_INLINE_CSS_MAX bytes, default
1576
+ * 32768). Inlining
1577
+ * removes the render-blocking stylesheet round trip on cold
1578
+ * connections — at 14KB of compiled Tailwind that's the whole sheet.
1579
+ *
1580
+ * Read from the local client-build outdir (present even in CDN mode —
1581
+ * the CDN prefix changes where BROWSERS fetch, not where the build
1582
+ * lives). Contents are cached per content-hashed filename. Falls back
1583
+ * to the link on any miss: unreadable file, over threshold, or a
1584
+ * sheet containing "</style" (would break out of the tag).
1585
+ */
1586
+ const inlineCssCache = new Map<string, string | null>();
1587
+ export async function cssHeadTag(
1588
+ css: string,
1589
+ prefix: string,
1590
+ routeOverride?: boolean,
1591
+ ): Promise<string> {
1592
+ const link = `<link rel="stylesheet" href="${prefix}${css}">`;
1593
+ // Per-route `export const inlineCss = true|false` beats the env
1594
+ // default — the tradeoff is inherently per-route (cold landing pages
1595
+ // win from inlining; cached logged-in pages win from the link).
1596
+ const enabled =
1597
+ routeOverride ??
1598
+ /^(1|true)$/i.test(process.env.PYLON_SSR_INLINE_CSS ?? "");
1599
+ if (!enabled) return link;
1600
+ let cached = inlineCssCache.get(css);
1601
+ if (cached === undefined) {
1602
+ cached = null;
1603
+ try {
1604
+ const { getManifest } = await import("./ssr-client-bundler");
1605
+ const manifest: any = await getManifest();
1606
+ const fs = await import("fs");
1607
+ const path = await import("path");
1608
+ const max = Number(process.env.PYLON_SSR_INLINE_CSS_MAX ?? "") || 32768;
1609
+ const file = path.join(
1610
+ process.cwd(),
1611
+ manifest.outdir || ".pylon/client-build",
1612
+ css,
1613
+ );
1614
+ const stat = fs.statSync(file);
1615
+ if (stat.size <= max) {
1616
+ const text = fs.readFileSync(file, "utf8");
1617
+ if (!/<\/style/i.test(text)) cached = text;
1618
+ }
1619
+ } catch {
1620
+ cached = null;
1621
+ }
1622
+ if (inlineCssCache.size > 64) inlineCssCache.clear();
1623
+ inlineCssCache.set(css, cached);
1624
+ }
1625
+ return cached === null
1626
+ ? link
1627
+ : `<style data-pylon-css="${css}">${cached}</style>`;
1628
+ }
1629
+
1556
1630
  async function collectBoundaryHeadBlob(): Promise<string> {
1557
1631
  try {
1558
1632
  const { getManifest } = await import("./ssr-client-bundler");
@@ -1566,7 +1640,7 @@ async function collectBoundaryHeadBlob(): Promise<string> {
1566
1640
  for (const css of (route.css || []) as string[]) {
1567
1641
  if (seen.has(css)) continue;
1568
1642
  seen.add(css);
1569
- blob += `<link rel="stylesheet" href="${prefix}${css}">`;
1643
+ blob += await cssHeadTag(css, prefix);
1570
1644
  }
1571
1645
  }
1572
1646
  return blob;
@@ -1791,7 +1865,7 @@ async function renderBoundaryToClient(
1791
1865
  headBlob += await buildFontHeadBlob();
1792
1866
  const co = /^https?:\/\//i.test(publicPrefix) ? " crossorigin" : "";
1793
1867
  for (const css of manifestRoute.css) {
1794
- headBlob += `<link rel="stylesheet" href="${publicPrefix}${css}">`;
1868
+ headBlob += await cssHeadTag(css, publicPrefix);
1795
1869
  }
1796
1870
  for (const chunk of manifestRoute.imports) {
1797
1871
  headBlob += `<link rel="modulepreload"${co} href="${publicPrefix}${chunk}">`;
@@ -2840,8 +2914,10 @@ export async function handleRenderRoute(
2840
2914
  if (preloadManifestRoute) {
2841
2915
  headBlob += await buildFontHeadBlob();
2842
2916
  const co = /^https?:\/\//i.test(preloadPublicPrefix) ? " crossorigin" : "";
2917
+ const inlineOverride =
2918
+ typeof mod?.inlineCss === "boolean" ? mod.inlineCss : undefined;
2843
2919
  for (const css of preloadManifestRoute.css) {
2844
- headBlob += `<link rel="stylesheet" href="${preloadPublicPrefix}${css}">`;
2920
+ headBlob += await cssHeadTag(css, preloadPublicPrefix, inlineOverride);
2845
2921
  }
2846
2922
  for (const chunk of preloadManifestRoute.imports) {
2847
2923
  headBlob += `<link rel="modulepreload"${co} href="${preloadPublicPrefix}${chunk}">`;
@@ -2972,8 +3048,18 @@ export async function handleRenderRoute(
2972
3048
  // agent running `pylon dev` sees the verdict without any extra call.
2973
3049
  if (devVerdict) {
2974
3050
  const renderMs = Math.round((performance.now() - renderStart) * 10) / 10;
3051
+ // Build-level failures that degraded this page (currently: the
3052
+ // Tailwind compile). Dev-only and unmissable — the HUD paints a
3053
+ // banner, because an unstyled page never says "go read the log".
3054
+ let buildWarning: string | undefined;
3055
+ try {
3056
+ const { getManifest } = await import("./ssr-client-bundler");
3057
+ const m: any = await getManifest();
3058
+ if (m?.css_error) buildWarning = `Tailwind compile failed — serving without styles: ${m.css_error}`;
3059
+ } catch {}
2975
3060
  sendChunk(
2976
3061
  buildDevHudChunk({
3062
+ buildWarning,
2977
3063
  route: msg.url,
2978
3064
  component: msg.component,
2979
3065
  renderMode,