@pylonsync/functions 0.11.6 → 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.
- package/dist/build-cli.d.ts +1 -0
- package/dist/build-compat.d.ts +81 -0
- package/dist/index.d.ts +1 -1
- package/dist/production-build.d.ts +47 -0
- package/dist/server-bundle.d.ts +31 -0
- package/dist/ssr-client-bundler.d.ts +31 -3
- package/dist/ssr-fonts.d.ts +1 -1
- package/dist/ssr-runtime.d.ts +13 -1
- package/dist/stdout-fence.d.ts +18 -0
- package/dist/types.d.ts +42 -0
- package/package.json +8 -4
- package/src/build-cli.ts +36 -0
- package/src/build-compat.test.ts +173 -0
- package/src/build-compat.ts +430 -0
- package/src/index.ts +1 -0
- package/src/production-build.test.ts +269 -0
- package/src/production-build.ts +577 -0
- package/src/runtime-shard-ticket.test.ts +87 -0
- package/src/runtime.ts +64 -82
- package/src/server-bundle.test.ts +89 -0
- package/src/server-bundle.ts +63 -0
- package/src/ssr-client-bundler.ts +125 -13
- package/src/ssr-fonts.ts +1 -1
- package/src/ssr-og-runtime.ts +17 -5
- package/src/ssr-runtime.ts +112 -47
- package/src/stdout-fence.ts +69 -0
- package/src/types.ts +42 -0
package/src/runtime.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
EmailOptions,
|
|
23
23
|
EmailSender,
|
|
24
24
|
Files,
|
|
25
|
+
Shards,
|
|
25
26
|
Stream,
|
|
26
27
|
Scheduler,
|
|
27
28
|
Llm,
|
|
@@ -46,6 +47,8 @@ import { normalizeAuthClaims } from "./auth";
|
|
|
46
47
|
import { makeRequireMember } from "./member";
|
|
47
48
|
import { isDevMode } from "./ssr-runtime";
|
|
48
49
|
import { validateArgs } from "./validators";
|
|
50
|
+
import { serverBundle } from "./server-bundle";
|
|
51
|
+
import { fenceStdout } from "./stdout-fence";
|
|
49
52
|
import { readdirSync } from "fs";
|
|
50
53
|
import { join, basename } from "path";
|
|
51
54
|
|
|
@@ -111,61 +114,6 @@ function send(msg: Record<string, unknown>): void {
|
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
/**
|
|
115
|
-
* Redirect console.* from user code to stderr so handlers can't accidentally
|
|
116
|
-
* emit a line that looks like a protocol frame and confuse the Rust reader.
|
|
117
|
-
*
|
|
118
|
-
* Before this guard, a handler calling `console.log('{"type":"return",...}')`
|
|
119
|
-
* — either intentionally or by logging an object shaped that way — would be
|
|
120
|
-
* parsed by the host as a real protocol message. Moving all console output
|
|
121
|
-
* to stderr keeps stdout reserved for NDJSON protocol frames only.
|
|
122
|
-
*
|
|
123
|
-
* The original console methods are saved on the console object as
|
|
124
|
-
* `__stdoutLog` etc. in case the runtime itself needs to write diagnostics
|
|
125
|
-
* to stdout for some reason (it currently doesn't).
|
|
126
|
-
*/
|
|
127
|
-
function fenceStdout(): void {
|
|
128
|
-
const toStderr = (prefix: string) => (...args: unknown[]) => {
|
|
129
|
-
const line = args
|
|
130
|
-
.map((a) => {
|
|
131
|
-
if (typeof a === "string") return a;
|
|
132
|
-
// Error: JSON.stringify yields `{}` because message/stack are
|
|
133
|
-
// non-enumerable. That made `console.error("x:", err)` log as `x: {}`,
|
|
134
|
-
// hiding the real failure from operators. Unwrap by hand.
|
|
135
|
-
if (a instanceof Error) {
|
|
136
|
-
const parts = [a.stack || `${a.name}: ${a.message}`];
|
|
137
|
-
const code = (a as { code?: unknown }).code;
|
|
138
|
-
if (code !== undefined) parts.push(`code=${String(code)}`);
|
|
139
|
-
const cause = (a as { cause?: unknown }).cause;
|
|
140
|
-
if (cause !== undefined) {
|
|
141
|
-
try {
|
|
142
|
-
parts.push(`cause=${cause instanceof Error ? cause.stack || cause.message : JSON.stringify(cause)}`);
|
|
143
|
-
} catch {
|
|
144
|
-
parts.push(`cause=${String(cause)}`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return parts.join(" ");
|
|
148
|
-
}
|
|
149
|
-
try {
|
|
150
|
-
return JSON.stringify(a);
|
|
151
|
-
} catch {
|
|
152
|
-
return String(a);
|
|
153
|
-
}
|
|
154
|
-
})
|
|
155
|
-
.join(" ");
|
|
156
|
-
Bun.write(Bun.stderr, `${prefix}${line}\n`);
|
|
157
|
-
};
|
|
158
|
-
// Intentional: we want console.* for user handlers to go to stderr.
|
|
159
|
-
// Overwrite the globals before any user code is loaded.
|
|
160
|
-
const c = globalThis.console as unknown as Record<string, unknown>;
|
|
161
|
-
c.__stdoutLog = c.log;
|
|
162
|
-
c.log = toStderr("");
|
|
163
|
-
c.info = toStderr("");
|
|
164
|
-
c.warn = toStderr("[warn] ");
|
|
165
|
-
c.error = toStderr("[error] ");
|
|
166
|
-
c.debug = toStderr("[debug] ");
|
|
167
|
-
}
|
|
168
|
-
|
|
169
117
|
// ---------------------------------------------------------------------------
|
|
170
118
|
// Single reader + dispatcher
|
|
171
119
|
// ---------------------------------------------------------------------------
|
|
@@ -575,6 +523,21 @@ export function buildDbReader(callId: string, ssrRead = false): DbReader {
|
|
|
575
523
|
* op_id) is correct here: concurrent calls queue settle-chained on the one
|
|
576
524
|
* call_id, matching how ctx.runQuery behaves inside a function.
|
|
577
525
|
*/
|
|
526
|
+
/** `ctx.shards` — shard tickets, signed host-side. */
|
|
527
|
+
function buildShards(callId: string): Shards {
|
|
528
|
+
return {
|
|
529
|
+
async ticket(shardId, opts) {
|
|
530
|
+
return rpc(callId, {
|
|
531
|
+
type: "sign_shard_ticket",
|
|
532
|
+
shard: shardId,
|
|
533
|
+
subscriber_id: opts?.subscriberId,
|
|
534
|
+
claims: opts?.claims ?? {},
|
|
535
|
+
ttl_secs: opts?.ttlSecs,
|
|
536
|
+
}) as Promise<string>;
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
578
541
|
/** `ctx.files` — signed download URLs, minted host-side. */
|
|
579
542
|
function buildFiles(callId: string): Files {
|
|
580
543
|
return {
|
|
@@ -1192,6 +1155,7 @@ function buildActionCtx(
|
|
|
1192
1155
|
return err;
|
|
1193
1156
|
},
|
|
1194
1157
|
files: buildFiles(callId),
|
|
1158
|
+
shards: buildShards(callId),
|
|
1195
1159
|
// Actions have no ctx.db; read membership via the built-in internal query.
|
|
1196
1160
|
requireMember: makeRequireMember(auth.userId, (entity, filter) =>
|
|
1197
1161
|
rpc(callId, {
|
|
@@ -1344,6 +1308,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1344
1308
|
reader.query(entity, { ...filter, $limit: 1 }),
|
|
1345
1309
|
),
|
|
1346
1310
|
files: buildFiles(msg.call_id),
|
|
1311
|
+
shards: buildShards(msg.call_id),
|
|
1347
1312
|
};
|
|
1348
1313
|
break;
|
|
1349
1314
|
}
|
|
@@ -1368,6 +1333,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1368
1333
|
writer.query(entity, { ...filter, $limit: 1 }),
|
|
1369
1334
|
),
|
|
1370
1335
|
files: buildFiles(msg.call_id),
|
|
1336
|
+
shards: buildShards(msg.call_id),
|
|
1371
1337
|
};
|
|
1372
1338
|
break;
|
|
1373
1339
|
}
|
|
@@ -1474,6 +1440,30 @@ function firstStackFrame(err: unknown): string {
|
|
|
1474
1440
|
// Startup: scan functions dir, send ready, then start reader loop
|
|
1475
1441
|
// ---------------------------------------------------------------------------
|
|
1476
1442
|
|
|
1443
|
+
/**
|
|
1444
|
+
* The `.ts` / `.js` modules directly inside `dir`, each with its name (file
|
|
1445
|
+
* name without extension) and a loader. A missing directory yields none: a
|
|
1446
|
+
* pure-SSR app has no `functions/`, and most apps have no `workflows/`. The
|
|
1447
|
+
* runner must still send `ready` and serve renders in that case.
|
|
1448
|
+
*/
|
|
1449
|
+
function listModuleFiles(
|
|
1450
|
+
dir: string,
|
|
1451
|
+
): Array<{ name: string; file: string; load: () => Promise<any> }> {
|
|
1452
|
+
let files: string[];
|
|
1453
|
+
try {
|
|
1454
|
+
files = readdirSync(dir).filter(
|
|
1455
|
+
(f) => f.endsWith(".ts") || f.endsWith(".js"),
|
|
1456
|
+
);
|
|
1457
|
+
} catch {
|
|
1458
|
+
return [];
|
|
1459
|
+
}
|
|
1460
|
+
return files.map((file) => ({
|
|
1461
|
+
name: basename(file, file.endsWith(".ts") ? ".ts" : ".js"),
|
|
1462
|
+
file,
|
|
1463
|
+
load: () => import(join(dir, file)),
|
|
1464
|
+
}));
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1477
1467
|
async function main() {
|
|
1478
1468
|
// Fence user `console.*` away from stdout BEFORE any user code is
|
|
1479
1469
|
// imported — the import side-effects alone could print a stray line
|
|
@@ -1481,27 +1471,21 @@ async function main() {
|
|
|
1481
1471
|
fenceStdout();
|
|
1482
1472
|
|
|
1483
1473
|
const fnDir = process.argv[2] || "./functions";
|
|
1474
|
+
const bundle = serverBundle();
|
|
1484
1475
|
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
// `app/**/page.tsx` routes + entity CRUD, no server functions) — the host
|
|
1493
|
-
// still spawns this runner to execute SSR renders. Load zero functions and
|
|
1494
|
-
// fall through so we send `ready` AND start the reader loop; returning here
|
|
1495
|
-
// would leave the runner unable to serve renders (silent 404s).
|
|
1496
|
-
files = [];
|
|
1497
|
-
}
|
|
1476
|
+
const fnSources = bundle
|
|
1477
|
+
? Object.keys(bundle.functions).map((name) => ({
|
|
1478
|
+
name,
|
|
1479
|
+
file: `${name} (bundled)`,
|
|
1480
|
+
load: bundle.functions[name],
|
|
1481
|
+
}))
|
|
1482
|
+
: listModuleFiles(join(process.cwd(), fnDir));
|
|
1498
1483
|
|
|
1499
1484
|
const { isAgentDefinition, AGENT_MARKER } = await import("./agent");
|
|
1500
1485
|
let agentsPresent = false;
|
|
1501
|
-
for (const file of
|
|
1502
|
-
const name = basename(file, file.endsWith(".ts") ? ".ts" : ".js");
|
|
1486
|
+
for (const { name, file, load } of fnSources) {
|
|
1503
1487
|
try {
|
|
1504
|
-
const mod = await
|
|
1488
|
+
const mod = await load();
|
|
1505
1489
|
const def = mod.default as FnDefinition | undefined;
|
|
1506
1490
|
// Runtime shape check — a misnamed/malformed export should
|
|
1507
1491
|
// log + skip, not crash the loader. TS narrows `def.handler`
|
|
@@ -1547,18 +1531,16 @@ async function main() {
|
|
|
1547
1531
|
);
|
|
1548
1532
|
type WorkflowDefinition = import("./workflows").WorkflowDefinition;
|
|
1549
1533
|
const workflowRegistry = new Map<string, WorkflowDefinition>();
|
|
1550
|
-
const
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
}
|
|
1559
|
-
for (const file of wfFiles) {
|
|
1534
|
+
const wfSources = bundle
|
|
1535
|
+
? Object.keys(bundle.workflows).map((name) => ({
|
|
1536
|
+
name,
|
|
1537
|
+
file: `${name} (bundled)`,
|
|
1538
|
+
load: bundle.workflows[name],
|
|
1539
|
+
}))
|
|
1540
|
+
: listModuleFiles(join(process.cwd(), "workflows"));
|
|
1541
|
+
for (const { file, load } of wfSources) {
|
|
1560
1542
|
try {
|
|
1561
|
-
const mod = await
|
|
1543
|
+
const mod = await load();
|
|
1562
1544
|
const def = mod.default;
|
|
1563
1545
|
if (isWorkflowDefinition(def)) {
|
|
1564
1546
|
if (workflowRegistry.has(def.name)) {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SSR module lookups in a production server bundle.
|
|
2
|
+
//
|
|
3
|
+
// An artifact has no app/ source files, so every lookup that checks the disk
|
|
4
|
+
// in source mode (boundary walk, route-group dirs, layout chain, module
|
|
5
|
+
// import) must answer from the bundle's module registry instead. These run
|
|
6
|
+
// the lookups with a registry set and a cwd that holds no files, and expect
|
|
7
|
+
// the same answers the source-mode tests get from a real tree.
|
|
8
|
+
|
|
9
|
+
import { afterAll, afterEach, expect, test } from "bun:test";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { moduleKey, setServerBundle, type PylonServerBundle } from "./server-bundle";
|
|
15
|
+
import { findBoundaryIn, importModule, moduleExistsIn } from "./ssr-runtime";
|
|
16
|
+
|
|
17
|
+
const EMPTY_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-bundle-lookup-"));
|
|
18
|
+
|
|
19
|
+
/** Set a registry with one loader per key. Returns the module namespaces the
|
|
20
|
+
* loaders resolve to, and how many times each loader ran. */
|
|
21
|
+
function withModules(keys: string[]): { ns: Record<string, any>; loads: Record<string, number> } {
|
|
22
|
+
const ns: Record<string, any> = {};
|
|
23
|
+
const loads: Record<string, number> = {};
|
|
24
|
+
const modules: PylonServerBundle["modules"] = {};
|
|
25
|
+
for (const k of keys) {
|
|
26
|
+
ns[k] = { default: () => k };
|
|
27
|
+
loads[k] = 0;
|
|
28
|
+
modules[k] = async () => {
|
|
29
|
+
loads[k] += 1;
|
|
30
|
+
return ns[k];
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const bundle: PylonServerBundle = {
|
|
34
|
+
functions: {},
|
|
35
|
+
workflows: {},
|
|
36
|
+
modules,
|
|
37
|
+
react: null,
|
|
38
|
+
reactDomServer: null,
|
|
39
|
+
clientDir: "client",
|
|
40
|
+
ogAssets: { resvgWasm: "", interRegular: "", interSemiBold: "" },
|
|
41
|
+
};
|
|
42
|
+
setServerBundle(bundle);
|
|
43
|
+
return { ns, loads };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
delete (globalThis as any).__PYLON_SERVER_BUNDLE__;
|
|
48
|
+
});
|
|
49
|
+
afterAll(() => fs.rmSync(EMPTY_DIR, { recursive: true, force: true }));
|
|
50
|
+
|
|
51
|
+
test("moduleKey normalizes separators, a leading ./, and extensions", () => {
|
|
52
|
+
expect(moduleKey(".\\app\\blog\\page.tsx")).toBe("app/blog/page");
|
|
53
|
+
expect(moduleKey("app/page")).toBe("app/page");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("moduleExistsIn answers from the registry, not the disk", () => {
|
|
57
|
+
withModules(["app/layout", "app/blog/page"]);
|
|
58
|
+
expect(moduleExistsIn(fs, path, EMPTY_DIR, "app", "layout")).toBe(true);
|
|
59
|
+
expect(moduleExistsIn(fs, path, EMPTY_DIR, "app/blog", "page")).toBe(true);
|
|
60
|
+
expect(moduleExistsIn(fs, path, EMPTY_DIR, "app/blog", "layout")).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("the boundary walk finds the nearest boundary, through route groups", () => {
|
|
64
|
+
withModules([
|
|
65
|
+
"app/not-found",
|
|
66
|
+
"app/(shop)/cart/not-found",
|
|
67
|
+
"app/(shop)/cart/items/page",
|
|
68
|
+
"app/(marketing)/error",
|
|
69
|
+
"app/blog/page",
|
|
70
|
+
]);
|
|
71
|
+
expect(findBoundaryIn(fs, path, EMPTY_DIR, "app/(shop)/cart/items/page", "not-found")).toBe(
|
|
72
|
+
"app/(shop)/cart/not-found",
|
|
73
|
+
);
|
|
74
|
+
expect(findBoundaryIn(fs, path, EMPTY_DIR, "app/blog/page", "not-found")).toBe("app/not-found");
|
|
75
|
+
// A group directly under app/ answers for "/" too.
|
|
76
|
+
expect(findBoundaryIn(fs, path, EMPTY_DIR, "app/blog/page", "error")).toBe(
|
|
77
|
+
"app/(marketing)/error",
|
|
78
|
+
);
|
|
79
|
+
expect(findBoundaryIn(fs, path, EMPTY_DIR, "app/blog/page", "loading")).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("importModule loads the bundled module on demand and names a missing one", async () => {
|
|
83
|
+
const { ns, loads } = withModules(["app/page", "app/layout"]);
|
|
84
|
+
expect(await importModule(EMPTY_DIR, "app/page")).toBe(ns["app/page"]);
|
|
85
|
+
// Lookups by key never evaluate a module; only an import does.
|
|
86
|
+
expect(moduleExistsIn(fs, path, EMPTY_DIR, "app", "layout")).toBe(true);
|
|
87
|
+
expect(loads).toEqual({ "app/page": 1, "app/layout": 0 });
|
|
88
|
+
await expect(importModule(EMPTY_DIR, "app/missing")).rejects.toThrow("not in the server bundle");
|
|
89
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The module registry of a production server bundle (`pylon build`).
|
|
2
|
+
//
|
|
3
|
+
// In source mode the runner imports app code by path at run time: function
|
|
4
|
+
// files from `functions/`, workflows from `workflows/`, and page / layout /
|
|
5
|
+
// boundary modules from `app/`. It also resolves `react` from the app's
|
|
6
|
+
// `node_modules`. A production bundle has none of those files. Its generated
|
|
7
|
+
// entry puts a loader for every module in this registry and then starts the
|
|
8
|
+
// runtime. Every loader in this package asks the registry first.
|
|
9
|
+
//
|
|
10
|
+
// The registry holds loaders, not modules, so each module is evaluated when
|
|
11
|
+
// it is first needed, as in source mode: the runtime loads each function
|
|
12
|
+
// with its own try/catch at boot, and a page loads on its first render. A
|
|
13
|
+
// module that throws at the top level fails alone.
|
|
14
|
+
//
|
|
15
|
+
// The entry sets the registry before it imports the runtime, whose top-level
|
|
16
|
+
// `main()` reads it.
|
|
17
|
+
|
|
18
|
+
/** Loads one bundled module and returns its namespace. */
|
|
19
|
+
export type ModuleLoader = () => Promise<any>;
|
|
20
|
+
|
|
21
|
+
export interface PylonServerBundle {
|
|
22
|
+
/** Function name (file name without extension) → module loader. */
|
|
23
|
+
functions: Record<string, ModuleLoader>;
|
|
24
|
+
/** Workflow file name (without extension) → module loader. */
|
|
25
|
+
workflows: Record<string, ModuleLoader>;
|
|
26
|
+
/** Project-relative, extension-less, "/"-separated module path
|
|
27
|
+
* (`app/blog/page`) → module loader. Holds every module under the app
|
|
28
|
+
* dir that the SSR runtime can import: pages, layouts, boundaries,
|
|
29
|
+
* loading states, route handlers, OG image modules, metadata routes. */
|
|
30
|
+
modules: Record<string, ModuleLoader>;
|
|
31
|
+
/** The app's React and React DOM server, as bundled with the pages, so SSR
|
|
32
|
+
* renders with the same React instance the page modules import. */
|
|
33
|
+
react: any;
|
|
34
|
+
reactDomServer: any;
|
|
35
|
+
/** Client bundle directory, relative to the artifact root. */
|
|
36
|
+
clientDir: string;
|
|
37
|
+
/** Absolute paths of the files the OG image renderer reads at run time.
|
|
38
|
+
* In source mode it finds them next to its own source file and in
|
|
39
|
+
* `node_modules`; the bundle copies them next to the server output. */
|
|
40
|
+
ogAssets: {
|
|
41
|
+
resvgWasm: string;
|
|
42
|
+
interRegular: string;
|
|
43
|
+
interSemiBold: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const KEY = "__PYLON_SERVER_BUNDLE__";
|
|
48
|
+
|
|
49
|
+
export function serverBundle(): PylonServerBundle | null {
|
|
50
|
+
return ((globalThis as any)[KEY] as PylonServerBundle | undefined) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function setServerBundle(bundle: PylonServerBundle): void {
|
|
54
|
+
(globalThis as any)[KEY] = bundle;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Normalize a module path to the registry key form. */
|
|
58
|
+
export function moduleKey(relPath: string): string {
|
|
59
|
+
return relPath
|
|
60
|
+
.replace(/\\/g, "/")
|
|
61
|
+
.replace(/^\.\//, "")
|
|
62
|
+
.replace(/\.(tsx?|jsx?)$/, "");
|
|
63
|
+
}
|
|
@@ -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;
|
|
@@ -1149,8 +1157,12 @@ function slugForComponent(component: string): string {
|
|
|
1149
1157
|
export interface PylonBundleManifest {
|
|
1150
1158
|
/** Build identity — bumps every successful build. */
|
|
1151
1159
|
build_id: string;
|
|
1152
|
-
/** Output root, relative to cwd
|
|
1160
|
+
/** Output root, relative to cwd: `.pylon/client-build`, or `client` in a
|
|
1161
|
+
* `pylon build` artifact. */
|
|
1153
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;
|
|
1154
1166
|
/** Public URL prefix the Rust host serves chunks under. */
|
|
1155
1167
|
public_prefix: string;
|
|
1156
1168
|
/** routeComponentPath → file + imports for that route. */
|
|
@@ -1208,7 +1220,7 @@ async function _prebuiltBundle(): Promise<BuildOutput | null> {
|
|
|
1208
1220
|
const pathMod: any = await import("node:path");
|
|
1209
1221
|
const fs = fsMod.default ?? fsMod;
|
|
1210
1222
|
const path = pathMod.default ?? pathMod;
|
|
1211
|
-
const outdir = path.join(process.cwd(),
|
|
1223
|
+
const outdir = path.join(process.cwd(), clientBuildDir());
|
|
1212
1224
|
const manifestPath = path.join(outdir, "manifest.json");
|
|
1213
1225
|
if (!fs.existsSync(manifestPath)) return null;
|
|
1214
1226
|
|
|
@@ -1269,6 +1281,48 @@ export async function buildClientBundle(
|
|
|
1269
1281
|
return _inflightBuild;
|
|
1270
1282
|
}
|
|
1271
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
|
+
|
|
1272
1326
|
async function _doBuild(appDirRel: string): Promise<BuildOutput> {
|
|
1273
1327
|
// node:* are available in Bun, but `globalThis.require` is
|
|
1274
1328
|
// not defined in ESM. Use dynamic import; Bun fast-paths these.
|
|
@@ -1300,6 +1354,7 @@ export async function buildTailwind(
|
|
|
1300
1354
|
cwd: string,
|
|
1301
1355
|
outdir: string,
|
|
1302
1356
|
appDirRel: string,
|
|
1357
|
+
cssTargets?: string[],
|
|
1303
1358
|
): Promise<string | null> {
|
|
1304
1359
|
const globalsPath = path.join(cwd, appDirRel, "globals.css");
|
|
1305
1360
|
if (!fs.existsSync(globalsPath)) return null;
|
|
@@ -1360,7 +1415,13 @@ export async function buildTailwind(
|
|
|
1360
1415
|
throw new Error(`tailwindcss build failed (exit ${exitCode}): ${err}`);
|
|
1361
1416
|
}
|
|
1362
1417
|
|
|
1363
|
-
|
|
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
|
+
}
|
|
1364
1425
|
let hash = 0;
|
|
1365
1426
|
for (let i = 0; i < out.length; i++) {
|
|
1366
1427
|
hash = (hash * 31 + out.charCodeAt(i)) >>> 0;
|
|
@@ -1400,7 +1461,20 @@ export async function _doBuildInner(
|
|
|
1400
1461
|
path: any,
|
|
1401
1462
|
cwd: string,
|
|
1402
1463
|
appDirRel: string,
|
|
1464
|
+
opts: ClientBuildOptions = {},
|
|
1403
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;
|
|
1404
1478
|
const routes = discoverRoutes(fs, path, cwd, appDirRel);
|
|
1405
1479
|
if (routes.length === 0) {
|
|
1406
1480
|
throw new Error(
|
|
@@ -1423,7 +1497,7 @@ export async function _doBuildInner(
|
|
|
1423
1497
|
}
|
|
1424
1498
|
}
|
|
1425
1499
|
}
|
|
1426
|
-
const outdir = path.join(
|
|
1500
|
+
const outdir = opts.outdir ?? path.join(cwd, CLIENT_BUILD_DIR);
|
|
1427
1501
|
try {
|
|
1428
1502
|
fs.rmSync(outdir, { recursive: true, force: true });
|
|
1429
1503
|
} catch {
|
|
@@ -1522,7 +1596,7 @@ export async function _doBuildInner(
|
|
|
1522
1596
|
target: "browser",
|
|
1523
1597
|
format: "esm",
|
|
1524
1598
|
minify: true,
|
|
1525
|
-
sourcemap: "none",
|
|
1599
|
+
sourcemap: sourcemap ? "linked" : "none",
|
|
1526
1600
|
splitting: true,
|
|
1527
1601
|
// The browser has no `process`. React, next-themes, sonner, and most
|
|
1528
1602
|
// npm UI deps reference `process.env.NODE_ENV` (and migrated Next code
|
|
@@ -1574,7 +1648,35 @@ export async function _doBuildInner(
|
|
|
1574
1648
|
// Manifest paths are URL paths under `public_prefix` ("/"-separated). The
|
|
1575
1649
|
// runtime's /_pylon/build/ handler rejects any request with a backslash.
|
|
1576
1650
|
const toUrlPath = (p: string) => p.replace(/\\/g, "/");
|
|
1577
|
-
const outdirRel =
|
|
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
|
+
}
|
|
1578
1680
|
const entriesByStem = new Map<
|
|
1579
1681
|
string,
|
|
1580
1682
|
{ absPath: string; relPath: string }
|
|
@@ -1583,7 +1685,7 @@ export async function _doBuildInner(
|
|
|
1583
1685
|
string,
|
|
1584
1686
|
{ absPath: string; relPath: string }
|
|
1585
1687
|
>();
|
|
1586
|
-
for (const o of
|
|
1688
|
+
for (const o of outputs) {
|
|
1587
1689
|
const absPath: string = o.path;
|
|
1588
1690
|
const relPath = toUrlPath(path.relative(outdir, absPath));
|
|
1589
1691
|
const base = path.basename(absPath);
|
|
@@ -1608,7 +1710,7 @@ export async function _doBuildInner(
|
|
|
1608
1710
|
// the binary next to the entries AND into chunks/ so the runtime URL
|
|
1609
1711
|
// resolves from either an entry or a split chunk.
|
|
1610
1712
|
try {
|
|
1611
|
-
const referencesLoroWasm =
|
|
1713
|
+
const referencesLoroWasm = outputs.some((o) => {
|
|
1612
1714
|
if (!o.path.endsWith(".js")) return false;
|
|
1613
1715
|
try {
|
|
1614
1716
|
return fs.readFileSync(o.path, "utf8").includes("loro_wasm_bg.wasm");
|
|
@@ -1682,6 +1784,7 @@ export async function _doBuildInner(
|
|
|
1682
1784
|
outdir: outdirRel,
|
|
1683
1785
|
public_prefix: "/_pylon/build/",
|
|
1684
1786
|
routes: {},
|
|
1787
|
+
...(polyfills ? { polyfills } : {}),
|
|
1685
1788
|
};
|
|
1686
1789
|
for (const r of routes) {
|
|
1687
1790
|
const slug = slugForComponent(r.component);
|
|
@@ -1735,7 +1838,7 @@ export async function _doBuildInner(
|
|
|
1735
1838
|
// pylon.manifest.json from cwd. It's generated output — commonly
|
|
1736
1839
|
// gitignored — so when it's absent the bundle still builds but
|
|
1737
1840
|
// silently loses those features. Say so, loudly, instead.
|
|
1738
|
-
if (!fs.existsSync(
|
|
1841
|
+
if (!fs.existsSync(manifestFile)) {
|
|
1739
1842
|
// eslint-disable-next-line no-console
|
|
1740
1843
|
console.warn(
|
|
1741
1844
|
"[pylon ssr] pylon.manifest.json not found — building the client " +
|
|
@@ -1750,7 +1853,14 @@ export async function _doBuildInner(
|
|
|
1750
1853
|
// array so SSR head injection emits `<link rel="stylesheet">`.
|
|
1751
1854
|
let stylesRel: string | null = null;
|
|
1752
1855
|
try {
|
|
1753
|
-
const styles = await buildTailwind(
|
|
1856
|
+
const styles = await buildTailwind(
|
|
1857
|
+
fs,
|
|
1858
|
+
path,
|
|
1859
|
+
cwd,
|
|
1860
|
+
outdir,
|
|
1861
|
+
appDirRel,
|
|
1862
|
+
compat?.cssTargets,
|
|
1863
|
+
);
|
|
1754
1864
|
if (styles) {
|
|
1755
1865
|
stylesRel = styles;
|
|
1756
1866
|
for (const r of Object.values(manifest.routes)) {
|
|
@@ -1769,6 +1879,7 @@ export async function _doBuildInner(
|
|
|
1769
1879
|
/\u001b\[[0-9;]*m/g,
|
|
1770
1880
|
"",
|
|
1771
1881
|
);
|
|
1882
|
+
if (opts.strict) throw new Error(`CSS build failed: ${cssMsg}`);
|
|
1772
1883
|
manifest.css_error = cssMsg;
|
|
1773
1884
|
// eslint-disable-next-line no-console
|
|
1774
1885
|
console.warn(`[pylon ssr] tailwind compile failed: ${cssMsg}`);
|
|
@@ -1782,12 +1893,13 @@ export async function _doBuildInner(
|
|
|
1782
1893
|
// the prebuilt artifact. A fetch/parse failure degrades to a variable-only
|
|
1783
1894
|
// entry — it never kills the build.
|
|
1784
1895
|
try {
|
|
1785
|
-
const declaredFonts = readManifestFonts(fs, path, cwd);
|
|
1896
|
+
const declaredFonts = readManifestFonts(fs, path, cwd, manifestFile);
|
|
1786
1897
|
if (declaredFonts.length > 0) {
|
|
1787
1898
|
const builtFonts = await buildFonts(fs, path, cwd, outdir, declaredFonts);
|
|
1788
1899
|
if (builtFonts) manifest.fonts = builtFonts;
|
|
1789
1900
|
}
|
|
1790
1901
|
} catch (fErr: any) {
|
|
1902
|
+
if (opts.strict) throw new Error(`font build failed: ${fErr?.message ?? fErr}`);
|
|
1791
1903
|
// eslint-disable-next-line no-console
|
|
1792
1904
|
console.warn(`[pylon ssr] font build failed: ${fErr?.message ?? fErr}`);
|
|
1793
1905
|
}
|
|
@@ -1863,7 +1975,7 @@ export async function getManifest(): Promise<PylonBundleManifest> {
|
|
|
1863
1975
|
const fs = fsMod.default ?? fsMod;
|
|
1864
1976
|
const path = pathMod.default ?? pathMod;
|
|
1865
1977
|
const cwd = process.cwd();
|
|
1866
|
-
const manifestPath = path.join(cwd,
|
|
1978
|
+
const manifestPath = path.join(cwd, clientBuildDir(), "manifest.json");
|
|
1867
1979
|
|
|
1868
1980
|
if (fs.existsSync(manifestPath)) {
|
|
1869
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"));
|