@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.
- package/dist/ssr-client-bundler.d.ts +5 -0
- package/dist/ssr-runtime.d.ts +1 -0
- package/package.json +1 -1
- package/src/runtime.ts +25 -2
- package/src/ssr-client-bundler.ts +32 -3
- package/src/ssr-inline-css.test.ts +59 -0
- package/src/ssr-runtime.ts +89 -3
|
@@ -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 {
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -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
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:
|
|
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 —
|
|
1474
|
-
//
|
|
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: ${
|
|
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
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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 +=
|
|
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 +=
|
|
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 +=
|
|
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,
|