@pylonsync/functions 0.3.311 → 0.3.314
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/server-only.d.ts +20 -0
- package/dist/ssr-client-bundler.d.ts +15 -0
- package/dist/ssr-runtime.d.ts +1 -0
- package/package.json +5 -1
- package/src/runtime.ts +25 -2
- package/src/server-only.ts +20 -0
- package/src/ssr-client-bundler.test.ts +58 -0
- package/src/ssr-client-bundler.ts +127 -3
- package/src/ssr-inline-css.test.ts +59 -0
- package/src/ssr-runtime.ts +89 -3
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker import that pins a module to the SERVER. Put it at the top of any
|
|
3
|
+
* module that must never reach the browser — one holding secrets, server
|
|
4
|
+
* config, or node-only APIs:
|
|
5
|
+
*
|
|
6
|
+
* import "@pylonsync/functions/server-only";
|
|
7
|
+
*
|
|
8
|
+
* export const stripeKey = process.env.STRIPE_SECRET_KEY!;
|
|
9
|
+
*
|
|
10
|
+
* Page (`page.tsx`) and layout (`layout.tsx`) modules — and everything they
|
|
11
|
+
* transitively import — are bundled for client hydration, so a plain literal or
|
|
12
|
+
* server config in that graph would ship to the browser. If a module marked
|
|
13
|
+
* with this import is pulled into a client-reachable page, the SSR client
|
|
14
|
+
* bundler REFUSES to build and names the offending importer (see the
|
|
15
|
+
* `pylon-server-only` plugin in `ssr-client-bundler.ts`).
|
|
16
|
+
*
|
|
17
|
+
* On the server this is an inert no-op. There is nothing to call — importing it
|
|
18
|
+
* is the whole contract.
|
|
19
|
+
*/
|
|
20
|
+
export {};
|
|
@@ -14,6 +14,16 @@ interface BundleClientMessage {
|
|
|
14
14
|
*/
|
|
15
15
|
app_dir?: string;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Fail the CLIENT bundle when a `server-only` module is resolved — meaning it
|
|
19
|
+
* was pulled into a page/layout's client graph. Page/layout modules (and their
|
|
20
|
+
* transitive imports) are bundled for hydration, so a literal secret or server
|
|
21
|
+
* config in that graph would ship to the browser (the `process.env.*` `define`
|
|
22
|
+
* only neutralizes env reads). Authors mark such modules with
|
|
23
|
+
* `import "@pylonsync/functions/server-only"`; this turns an accidental client
|
|
24
|
+
* import into a loud build failure that names the offending importer.
|
|
25
|
+
*/
|
|
26
|
+
export declare function assertNotServerOnly(specifier: string, importer: string): void;
|
|
17
27
|
/**
|
|
18
28
|
* Manifest schema. One entry per route, indexed by the same
|
|
19
29
|
* project-relative component path the SSR side passes through.
|
|
@@ -49,6 +59,11 @@ export interface PylonBundleManifest {
|
|
|
49
59
|
* independent); rendered into every SSR `<head>` against `public_prefix`.
|
|
50
60
|
* Absent when the app declares no `font({...})`. */
|
|
51
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;
|
|
52
67
|
}
|
|
53
68
|
/** Result of an in-process build — same shape the protocol returns. */
|
|
54
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/functions",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.314",
|
|
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",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"types": "./dist/runtime.d.ts",
|
|
15
15
|
"default": "./src/runtime.ts"
|
|
16
16
|
},
|
|
17
|
+
"./server-only": {
|
|
18
|
+
"types": "./dist/server-only.d.ts",
|
|
19
|
+
"default": "./src/server-only.ts"
|
|
20
|
+
},
|
|
17
21
|
"./client-bundler": {
|
|
18
22
|
"types": "./dist/ssr-client-bundler.d.ts",
|
|
19
23
|
"default": "./src/ssr-client-bundler.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:
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker import that pins a module to the SERVER. Put it at the top of any
|
|
3
|
+
* module that must never reach the browser — one holding secrets, server
|
|
4
|
+
* config, or node-only APIs:
|
|
5
|
+
*
|
|
6
|
+
* import "@pylonsync/functions/server-only";
|
|
7
|
+
*
|
|
8
|
+
* export const stripeKey = process.env.STRIPE_SECRET_KEY!;
|
|
9
|
+
*
|
|
10
|
+
* Page (`page.tsx`) and layout (`layout.tsx`) modules — and everything they
|
|
11
|
+
* transitively import — are bundled for client hydration, so a plain literal or
|
|
12
|
+
* server config in that graph would ship to the browser. If a module marked
|
|
13
|
+
* with this import is pulled into a client-reachable page, the SSR client
|
|
14
|
+
* bundler REFUSES to build and names the offending importer (see the
|
|
15
|
+
* `pylon-server-only` plugin in `ssr-client-bundler.ts`).
|
|
16
|
+
*
|
|
17
|
+
* On the server this is an inert no-op. There is nothing to call — importing it
|
|
18
|
+
* is the whole contract.
|
|
19
|
+
*/
|
|
20
|
+
export {};
|
|
@@ -24,6 +24,7 @@ import * as os from "node:os";
|
|
|
24
24
|
import {
|
|
25
25
|
buildClientBundle,
|
|
26
26
|
buildTailwind,
|
|
27
|
+
assertNotServerOnly,
|
|
27
28
|
type PylonBundleManifest,
|
|
28
29
|
} from "./ssr-client-bundler";
|
|
29
30
|
import { nearestBoundaryComponent } from "./ssr-client-boundary";
|
|
@@ -430,3 +431,60 @@ describe("Tailwind compile is concurrency-safe", () => {
|
|
|
430
431
|
expect(stranded).toEqual([]);
|
|
431
432
|
}, 20_000);
|
|
432
433
|
});
|
|
434
|
+
|
|
435
|
+
describe("server-only guard (secrets can't leak into the client bundle)", () => {
|
|
436
|
+
test("assertNotServerOnly throws for server-only specifiers, passes others", () => {
|
|
437
|
+
expect(() =>
|
|
438
|
+
assertNotServerOnly("@pylonsync/functions/server-only", "app/page.tsx"),
|
|
439
|
+
).toThrow(/server-only/i);
|
|
440
|
+
expect(() => assertNotServerOnly("server-only", "app/x/layout.tsx")).toThrow(
|
|
441
|
+
/server-only/i,
|
|
442
|
+
);
|
|
443
|
+
// The importer is named so the author can find the offending module.
|
|
444
|
+
expect(() => assertNotServerOnly("server-only", "app/secrets.ts")).toThrow(
|
|
445
|
+
/app\/secrets\.ts/,
|
|
446
|
+
);
|
|
447
|
+
// Ordinary imports pass untouched.
|
|
448
|
+
expect(() => assertNotServerOnly("react", "app/page.tsx")).not.toThrow();
|
|
449
|
+
expect(() => assertNotServerOnly("@/lib/utils", "app/page.tsx")).not.toThrow();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("a real client build importing a server-only module FAILS via the guard", async () => {
|
|
453
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-server-only-"));
|
|
454
|
+
const entry = path.join(dir, "entry.ts");
|
|
455
|
+
fs.writeFileSync(entry, `import "server-only";\nexport const x = 1;\n`);
|
|
456
|
+
// Same plugin the bundler installs. `guardFired` proves the guard's
|
|
457
|
+
// onResolve ran for the marker (Bun runs plugin resolution BEFORE default
|
|
458
|
+
// resolution) — distinguishing a real guard rejection from an unrelated
|
|
459
|
+
// "module not found". Bun surfaces the plugin throw as a failed build.
|
|
460
|
+
let guardFired = false;
|
|
461
|
+
let failed = false;
|
|
462
|
+
try {
|
|
463
|
+
const result = await Bun.build({
|
|
464
|
+
entrypoints: [entry],
|
|
465
|
+
outdir: path.join(dir, "out"),
|
|
466
|
+
target: "browser",
|
|
467
|
+
plugins: [
|
|
468
|
+
{
|
|
469
|
+
name: "pylon-server-only",
|
|
470
|
+
setup(build: any) {
|
|
471
|
+
build.onResolve(
|
|
472
|
+
{ filter: /^(@pylonsync\/functions\/server-only|server-only)$/ },
|
|
473
|
+
(args: any) => {
|
|
474
|
+
guardFired = true;
|
|
475
|
+
assertNotServerOnly(args.path, args.importer);
|
|
476
|
+
},
|
|
477
|
+
);
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
],
|
|
481
|
+
} as any);
|
|
482
|
+
failed = !result.success;
|
|
483
|
+
} catch {
|
|
484
|
+
failed = true;
|
|
485
|
+
}
|
|
486
|
+
expect(guardFired).toBe(true);
|
|
487
|
+
expect(failed).toBe(true);
|
|
488
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
489
|
+
});
|
|
490
|
+
});
|
|
@@ -101,10 +101,46 @@ declare const Bun: {
|
|
|
101
101
|
};
|
|
102
102
|
publicPath?: string;
|
|
103
103
|
root?: string;
|
|
104
|
+
plugins?: Array<{
|
|
105
|
+
name: string;
|
|
106
|
+
setup(build: {
|
|
107
|
+
onResolve(
|
|
108
|
+
opts: { filter: RegExp; namespace?: string },
|
|
109
|
+
callback: (args: { path: string; importer: string }) => void,
|
|
110
|
+
): void;
|
|
111
|
+
}): void;
|
|
112
|
+
}>;
|
|
104
113
|
}): Promise<BunBuildOutput>;
|
|
105
114
|
file(path: string): { exists(): Promise<boolean> };
|
|
106
115
|
};
|
|
107
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Specifiers marking a module as SERVER-ONLY: `@pylonsync/functions/server-only`
|
|
119
|
+
* (in-ecosystem) or the bare `server-only` (Next.js compat). A module that
|
|
120
|
+
* imports one must never reach the browser.
|
|
121
|
+
*/
|
|
122
|
+
const SERVER_ONLY_RE = /^(@pylonsync\/functions\/server-only|server-only)$/;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Fail the CLIENT bundle when a `server-only` module is resolved — meaning it
|
|
126
|
+
* was pulled into a page/layout's client graph. Page/layout modules (and their
|
|
127
|
+
* transitive imports) are bundled for hydration, so a literal secret or server
|
|
128
|
+
* config in that graph would ship to the browser (the `process.env.*` `define`
|
|
129
|
+
* only neutralizes env reads). Authors mark such modules with
|
|
130
|
+
* `import "@pylonsync/functions/server-only"`; this turns an accidental client
|
|
131
|
+
* import into a loud build failure that names the offending importer.
|
|
132
|
+
*/
|
|
133
|
+
export function assertNotServerOnly(specifier: string, importer: string): void {
|
|
134
|
+
if (SERVER_ONLY_RE.test(specifier)) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`pylon: "${specifier}" is server-only but was imported into the client bundle by ` +
|
|
137
|
+
`${importer || "a page/layout module"}. Page/layout modules (and everything they import) ship to ` +
|
|
138
|
+
`the browser — move server-only code (secrets, server config, node-only APIs) into a server function ` +
|
|
139
|
+
`(functions/) or a route.ts handler and pass only the rendered values as props.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
108
144
|
/**
|
|
109
145
|
* Synchronously walk the route dir (`<appDirRel>` under cwd, e.g.
|
|
110
146
|
* `app` or `web/app`) and return one entry per discovered page, each
|
|
@@ -899,6 +935,11 @@ export interface PylonBundleManifest {
|
|
|
899
935
|
* independent); rendered into every SSR `<head>` against `public_prefix`.
|
|
900
936
|
* Absent when the app declares no `font({...})`. */
|
|
901
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;
|
|
902
943
|
}
|
|
903
944
|
|
|
904
945
|
/** Result of an in-process build — same shape the protocol returns. */
|
|
@@ -1240,6 +1281,19 @@ async function _doBuildInner(
|
|
|
1240
1281
|
chunk: "chunks/[name]-[hash].js",
|
|
1241
1282
|
asset: "assets/[name]-[hash][ext]",
|
|
1242
1283
|
},
|
|
1284
|
+
// Refuse to bundle a server-only module into a client-reachable page —
|
|
1285
|
+
// secrets / server config in a page's import graph would otherwise ship
|
|
1286
|
+
// to the browser.
|
|
1287
|
+
plugins: [
|
|
1288
|
+
{
|
|
1289
|
+
name: "pylon-server-only",
|
|
1290
|
+
setup(build) {
|
|
1291
|
+
build.onResolve({ filter: SERVER_ONLY_RE }, (args) => {
|
|
1292
|
+
assertNotServerOnly(args.path, args.importer);
|
|
1293
|
+
});
|
|
1294
|
+
},
|
|
1295
|
+
},
|
|
1296
|
+
],
|
|
1243
1297
|
});
|
|
1244
1298
|
|
|
1245
1299
|
if (!result.success) {
|
|
@@ -1281,6 +1335,52 @@ async function _doBuildInner(
|
|
|
1281
1335
|
}
|
|
1282
1336
|
}
|
|
1283
1337
|
|
|
1338
|
+
// loro-crdt's web build locates its WASM sibling at RUNTIME via
|
|
1339
|
+
// `new URL("loro_wasm_bg.wasm", import.meta.url)` — the file never
|
|
1340
|
+
// appears in the static import graph, so Bun doesn't emit it and every
|
|
1341
|
+
// CRDT-using page 404s on /_pylon/build/loro_wasm_bg.wasm during
|
|
1342
|
+
// hydration. When any built output references the wasm by name, copy
|
|
1343
|
+
// the binary next to the entries AND into chunks/ so the runtime URL
|
|
1344
|
+
// resolves from either an entry or a split chunk.
|
|
1345
|
+
try {
|
|
1346
|
+
const referencesLoroWasm = result.outputs.some((o) => {
|
|
1347
|
+
if (!o.path.endsWith(".js")) return false;
|
|
1348
|
+
try {
|
|
1349
|
+
return fs.readFileSync(o.path, "utf8").includes("loro_wasm_bg.wasm");
|
|
1350
|
+
} catch {
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
if (referencesLoroWasm) {
|
|
1355
|
+
const loroPkg = (Bun as any).resolveSync(
|
|
1356
|
+
"loro-crdt/package.json",
|
|
1357
|
+
cwd,
|
|
1358
|
+
) as string;
|
|
1359
|
+
// The wasm must come from the SAME build variant whose JS glue got
|
|
1360
|
+
// bundled — the wasm-bindgen import namespaces differ between
|
|
1361
|
+
// variants (mixing them fails instantiation with `Import #0 "wbg"`).
|
|
1362
|
+
// `target: "browser"` resolves the package's "browser" condition, so
|
|
1363
|
+
// prefer browser/; web/ is the fallback for older package layouts.
|
|
1364
|
+
const loroDir = path.dirname(loroPkg);
|
|
1365
|
+
const wasmSrc = ["browser", "web"]
|
|
1366
|
+
.map((v) => path.join(loroDir, v, "loro_wasm_bg.wasm"))
|
|
1367
|
+
.find((p) => fs.existsSync(p));
|
|
1368
|
+
if (wasmSrc) {
|
|
1369
|
+
fs.copyFileSync(wasmSrc, path.join(outdir, "loro_wasm_bg.wasm"));
|
|
1370
|
+
const chunksDir = path.join(outdir, "chunks");
|
|
1371
|
+
if (fs.existsSync(chunksDir)) {
|
|
1372
|
+
fs.copyFileSync(
|
|
1373
|
+
wasmSrc,
|
|
1374
|
+
path.join(chunksDir, "loro_wasm_bg.wasm"),
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
} catch {
|
|
1380
|
+
// Best-effort: failing to copy just reproduces the 404 this guards
|
|
1381
|
+
// against; the build itself is fine.
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1284
1384
|
// Scan a built JS file for static `import` literals pointing
|
|
1285
1385
|
// at `./chunks/<file>.js` and return them resolved to outdir-
|
|
1286
1386
|
// relative paths. Bun's minified output uses simple double
|
|
@@ -1362,6 +1462,20 @@ async function _doBuildInner(
|
|
|
1362
1462
|
);
|
|
1363
1463
|
}
|
|
1364
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
|
+
|
|
1365
1479
|
// Tailwind v4 compile. Optional — only fires if the project has
|
|
1366
1480
|
// `app/globals.css`. Adds the stylesheet to every route's css
|
|
1367
1481
|
// array so SSR head injection emits `<link rel="stylesheet">`.
|
|
@@ -1375,10 +1489,20 @@ async function _doBuildInner(
|
|
|
1375
1489
|
}
|
|
1376
1490
|
}
|
|
1377
1491
|
} catch (twErr: any) {
|
|
1378
|
-
// Tailwind failure shouldn't kill the SSR build —
|
|
1379
|
-
//
|
|
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;
|
|
1380
1504
|
// eslint-disable-next-line no-console
|
|
1381
|
-
console.warn(`[pylon ssr] tailwind compile failed: ${
|
|
1505
|
+
console.warn(`[pylon ssr] tailwind compile failed: ${cssMsg}`);
|
|
1382
1506
|
}
|
|
1383
1507
|
|
|
1384
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,
|