@pylonsync/functions 0.11.6 → 0.13.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 +75 -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 +3 -0
- package/src/production-build.test.ts +269 -0
- package/src/production-build.ts +590 -0
- package/src/runtime-shard-ticket.test.ts +137 -0
- package/src/runtime.ts +83 -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 +81 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ctx.shards` — the frames the runtime sends to the host (`ticket`,
|
|
3
|
+
* `create`, `stop`, `get`, `list`) and the values it returns. The host signs (it holds the secret); this
|
|
4
|
+
* checks the TS half. Same child-process NDJSON harness as
|
|
5
|
+
* runtime-email.test.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { expect, test } from "bun:test";
|
|
8
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const RUNTIME = join(import.meta.dir, "runtime.ts");
|
|
13
|
+
|
|
14
|
+
function parseFrames(text: string): Record<string, unknown>[] {
|
|
15
|
+
return text
|
|
16
|
+
.split("\n")
|
|
17
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
18
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Run one function in a real runtime child; `host` answers its frames. */
|
|
22
|
+
async function runFunction(
|
|
23
|
+
source: string,
|
|
24
|
+
host: (frame: Record<string, unknown>) => unknown,
|
|
25
|
+
hostFrameTypes: string[],
|
|
26
|
+
fnType: "mutation" | "action" = "mutation",
|
|
27
|
+
): Promise<{ value: unknown; hostFrames: Record<string, unknown>[] }> {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "pylon-fn-shards-"));
|
|
29
|
+
mkdirSync(join(dir, "functions"));
|
|
30
|
+
writeFileSync(join(dir, "functions", "run.ts"), source);
|
|
31
|
+
const proc = Bun.spawn([process.execPath, RUNTIME, "./functions"], {
|
|
32
|
+
cwd: dir,
|
|
33
|
+
stdin: "pipe",
|
|
34
|
+
stdout: "pipe",
|
|
35
|
+
stderr: "pipe",
|
|
36
|
+
});
|
|
37
|
+
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
let buffered = "";
|
|
40
|
+
let consumed = 0;
|
|
41
|
+
const next = async (): Promise<Record<string, unknown>> => {
|
|
42
|
+
for (;;) {
|
|
43
|
+
const frames = parseFrames(buffered);
|
|
44
|
+
if (frames.length > consumed) return frames[consumed++];
|
|
45
|
+
const { done, value } = await reader.read();
|
|
46
|
+
if (done) throw new Error("runtime exited");
|
|
47
|
+
buffered += decoder.decode(value, { stream: true });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const send = (msg: Record<string, unknown>) =>
|
|
51
|
+
(proc.stdin as import("bun").FileSink).write(JSON.stringify(msg) + "\n");
|
|
52
|
+
|
|
53
|
+
while ((await next()).type !== "ready");
|
|
54
|
+
send({
|
|
55
|
+
type: "call",
|
|
56
|
+
call_id: "c_1",
|
|
57
|
+
fn_name: "run",
|
|
58
|
+
fn_type: fnType,
|
|
59
|
+
args: {},
|
|
60
|
+
auth: { user_id: "u1", is_admin: false, tenant_id: null },
|
|
61
|
+
});
|
|
62
|
+
const hostFrames: Record<string, unknown>[] = [];
|
|
63
|
+
try {
|
|
64
|
+
for (;;) {
|
|
65
|
+
const frame = await next();
|
|
66
|
+
if (frame.type === "return") return { value: frame.value, hostFrames };
|
|
67
|
+
if (frame.type === "error") throw new Error(JSON.stringify(frame));
|
|
68
|
+
if (hostFrameTypes.includes(frame.type as string)) {
|
|
69
|
+
hostFrames.push(frame);
|
|
70
|
+
send({ type: "result", call_id: "c_1", data: host(frame) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
proc.kill();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test("ctx.shards.ticket sends sign_shard_ticket and returns the host's ticket", async () => {
|
|
79
|
+
const { value, hostFrames } = await runFunction(
|
|
80
|
+
`export default {
|
|
81
|
+
type: "mutation",
|
|
82
|
+
handler: async (ctx) => {
|
|
83
|
+
const ticket = await ctx.shards.ticket("zone-3", {
|
|
84
|
+
subscriberId: "char_12",
|
|
85
|
+
claims: { realm: "north" },
|
|
86
|
+
ttlSecs: 30,
|
|
87
|
+
});
|
|
88
|
+
return { ticket };
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
`,
|
|
92
|
+
() => "v1.payload.sig",
|
|
93
|
+
["sign_shard_ticket"],
|
|
94
|
+
);
|
|
95
|
+
expect(hostFrames[0]).toMatchObject({
|
|
96
|
+
call_id: "c_1",
|
|
97
|
+
shard: "zone-3",
|
|
98
|
+
subscriber_id: "char_12",
|
|
99
|
+
claims: { realm: "north" },
|
|
100
|
+
ttl_secs: 30,
|
|
101
|
+
});
|
|
102
|
+
expect(value).toEqual({ ticket: "v1.payload.sig" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("ctx.shards.create/get/list/stop in an action send shard_op frames", async () => {
|
|
106
|
+
const info = { id: "m1", kind: "arena", tick: 0, subscribers: 0, running: true };
|
|
107
|
+
const { value, hostFrames } = await runFunction(
|
|
108
|
+
`export default {
|
|
109
|
+
type: "action",
|
|
110
|
+
handler: async (ctx) => {
|
|
111
|
+
const created = await ctx.shards.create("arena", "m1", { fog: true });
|
|
112
|
+
const got = await ctx.shards.get("m1");
|
|
113
|
+
const all = await ctx.shards.list();
|
|
114
|
+
const stopped = await ctx.shards.stop("m1");
|
|
115
|
+
return { created, got, all, stopped };
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
`,
|
|
119
|
+
(frame) => {
|
|
120
|
+
switch (frame.op) {
|
|
121
|
+
case "create":
|
|
122
|
+
case "get":
|
|
123
|
+
return info;
|
|
124
|
+
case "list":
|
|
125
|
+
return [info];
|
|
126
|
+
default:
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
["shard_op"],
|
|
131
|
+
"action",
|
|
132
|
+
);
|
|
133
|
+
expect(hostFrames.map((f) => f.op)).toEqual(["create", "get", "list", "stop"]);
|
|
134
|
+
expect(hostFrames[0]).toMatchObject({ kind: "arena", id: "m1", params: { fog: true } });
|
|
135
|
+
expect(hostFrames[3]).toMatchObject({ id: "m1" });
|
|
136
|
+
expect(value).toEqual({ created: info, got: info, all: [info], stopped: true });
|
|
137
|
+
});
|
package/src/runtime.ts
CHANGED
|
@@ -22,6 +22,8 @@ import type {
|
|
|
22
22
|
EmailOptions,
|
|
23
23
|
EmailSender,
|
|
24
24
|
Files,
|
|
25
|
+
Shards,
|
|
26
|
+
ShardInfo,
|
|
25
27
|
Stream,
|
|
26
28
|
Scheduler,
|
|
27
29
|
Llm,
|
|
@@ -46,6 +48,8 @@ import { normalizeAuthClaims } from "./auth";
|
|
|
46
48
|
import { makeRequireMember } from "./member";
|
|
47
49
|
import { isDevMode } from "./ssr-runtime";
|
|
48
50
|
import { validateArgs } from "./validators";
|
|
51
|
+
import { serverBundle } from "./server-bundle";
|
|
52
|
+
import { fenceStdout } from "./stdout-fence";
|
|
49
53
|
import { readdirSync } from "fs";
|
|
50
54
|
import { join, basename } from "path";
|
|
51
55
|
|
|
@@ -111,61 +115,6 @@ function send(msg: Record<string, unknown>): void {
|
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
117
|
|
|
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
118
|
// ---------------------------------------------------------------------------
|
|
170
119
|
// Single reader + dispatcher
|
|
171
120
|
// ---------------------------------------------------------------------------
|
|
@@ -575,6 +524,39 @@ export function buildDbReader(callId: string, ssrRead = false): DbReader {
|
|
|
575
524
|
* op_id) is correct here: concurrent calls queue settle-chained on the one
|
|
576
525
|
* call_id, matching how ctx.runQuery behaves inside a function.
|
|
577
526
|
*/
|
|
527
|
+
/** `ctx.shards` — shard tickets, signed host-side. */
|
|
528
|
+
function buildShards(callId: string): Shards {
|
|
529
|
+
return {
|
|
530
|
+
async ticket(shardId, opts) {
|
|
531
|
+
return rpc(callId, {
|
|
532
|
+
type: "sign_shard_ticket",
|
|
533
|
+
shard: shardId,
|
|
534
|
+
subscriber_id: opts?.subscriberId,
|
|
535
|
+
claims: opts?.claims ?? {},
|
|
536
|
+
ttl_secs: opts?.ttlSecs,
|
|
537
|
+
}) as Promise<string>;
|
|
538
|
+
},
|
|
539
|
+
async create(kind, shardId, params) {
|
|
540
|
+
return rpc(callId, {
|
|
541
|
+
type: "shard_op",
|
|
542
|
+
op: "create",
|
|
543
|
+
kind,
|
|
544
|
+
id: shardId,
|
|
545
|
+
params: params ?? {},
|
|
546
|
+
}) as Promise<ShardInfo>;
|
|
547
|
+
},
|
|
548
|
+
async stop(shardId) {
|
|
549
|
+
return rpc(callId, { type: "shard_op", op: "stop", id: shardId }) as Promise<boolean>;
|
|
550
|
+
},
|
|
551
|
+
async get(shardId) {
|
|
552
|
+
return rpc(callId, { type: "shard_op", op: "get", id: shardId }) as Promise<ShardInfo | null>;
|
|
553
|
+
},
|
|
554
|
+
async list() {
|
|
555
|
+
return rpc(callId, { type: "shard_op", op: "list" }) as Promise<ShardInfo[]>;
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
578
560
|
/** `ctx.files` — signed download URLs, minted host-side. */
|
|
579
561
|
function buildFiles(callId: string): Files {
|
|
580
562
|
return {
|
|
@@ -1192,6 +1174,7 @@ function buildActionCtx(
|
|
|
1192
1174
|
return err;
|
|
1193
1175
|
},
|
|
1194
1176
|
files: buildFiles(callId),
|
|
1177
|
+
shards: buildShards(callId),
|
|
1195
1178
|
// Actions have no ctx.db; read membership via the built-in internal query.
|
|
1196
1179
|
requireMember: makeRequireMember(auth.userId, (entity, filter) =>
|
|
1197
1180
|
rpc(callId, {
|
|
@@ -1344,6 +1327,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1344
1327
|
reader.query(entity, { ...filter, $limit: 1 }),
|
|
1345
1328
|
),
|
|
1346
1329
|
files: buildFiles(msg.call_id),
|
|
1330
|
+
shards: buildShards(msg.call_id),
|
|
1347
1331
|
};
|
|
1348
1332
|
break;
|
|
1349
1333
|
}
|
|
@@ -1368,6 +1352,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1368
1352
|
writer.query(entity, { ...filter, $limit: 1 }),
|
|
1369
1353
|
),
|
|
1370
1354
|
files: buildFiles(msg.call_id),
|
|
1355
|
+
shards: buildShards(msg.call_id),
|
|
1371
1356
|
};
|
|
1372
1357
|
break;
|
|
1373
1358
|
}
|
|
@@ -1474,6 +1459,30 @@ function firstStackFrame(err: unknown): string {
|
|
|
1474
1459
|
// Startup: scan functions dir, send ready, then start reader loop
|
|
1475
1460
|
// ---------------------------------------------------------------------------
|
|
1476
1461
|
|
|
1462
|
+
/**
|
|
1463
|
+
* The `.ts` / `.js` modules directly inside `dir`, each with its name (file
|
|
1464
|
+
* name without extension) and a loader. A missing directory yields none: a
|
|
1465
|
+
* pure-SSR app has no `functions/`, and most apps have no `workflows/`. The
|
|
1466
|
+
* runner must still send `ready` and serve renders in that case.
|
|
1467
|
+
*/
|
|
1468
|
+
function listModuleFiles(
|
|
1469
|
+
dir: string,
|
|
1470
|
+
): Array<{ name: string; file: string; load: () => Promise<any> }> {
|
|
1471
|
+
let files: string[];
|
|
1472
|
+
try {
|
|
1473
|
+
files = readdirSync(dir).filter(
|
|
1474
|
+
(f) => f.endsWith(".ts") || f.endsWith(".js"),
|
|
1475
|
+
);
|
|
1476
|
+
} catch {
|
|
1477
|
+
return [];
|
|
1478
|
+
}
|
|
1479
|
+
return files.map((file) => ({
|
|
1480
|
+
name: basename(file, file.endsWith(".ts") ? ".ts" : ".js"),
|
|
1481
|
+
file,
|
|
1482
|
+
load: () => import(join(dir, file)),
|
|
1483
|
+
}));
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1477
1486
|
async function main() {
|
|
1478
1487
|
// Fence user `console.*` away from stdout BEFORE any user code is
|
|
1479
1488
|
// imported — the import side-effects alone could print a stray line
|
|
@@ -1481,27 +1490,21 @@ async function main() {
|
|
|
1481
1490
|
fenceStdout();
|
|
1482
1491
|
|
|
1483
1492
|
const fnDir = process.argv[2] || "./functions";
|
|
1493
|
+
const bundle = serverBundle();
|
|
1484
1494
|
|
|
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
|
-
}
|
|
1495
|
+
const fnSources = bundle
|
|
1496
|
+
? Object.keys(bundle.functions).map((name) => ({
|
|
1497
|
+
name,
|
|
1498
|
+
file: `${name} (bundled)`,
|
|
1499
|
+
load: bundle.functions[name],
|
|
1500
|
+
}))
|
|
1501
|
+
: listModuleFiles(join(process.cwd(), fnDir));
|
|
1498
1502
|
|
|
1499
1503
|
const { isAgentDefinition, AGENT_MARKER } = await import("./agent");
|
|
1500
1504
|
let agentsPresent = false;
|
|
1501
|
-
for (const file of
|
|
1502
|
-
const name = basename(file, file.endsWith(".ts") ? ".ts" : ".js");
|
|
1505
|
+
for (const { name, file, load } of fnSources) {
|
|
1503
1506
|
try {
|
|
1504
|
-
const mod = await
|
|
1507
|
+
const mod = await load();
|
|
1505
1508
|
const def = mod.default as FnDefinition | undefined;
|
|
1506
1509
|
// Runtime shape check — a misnamed/malformed export should
|
|
1507
1510
|
// log + skip, not crash the loader. TS narrows `def.handler`
|
|
@@ -1547,18 +1550,16 @@ async function main() {
|
|
|
1547
1550
|
);
|
|
1548
1551
|
type WorkflowDefinition = import("./workflows").WorkflowDefinition;
|
|
1549
1552
|
const workflowRegistry = new Map<string, WorkflowDefinition>();
|
|
1550
|
-
const
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
}
|
|
1559
|
-
for (const file of wfFiles) {
|
|
1553
|
+
const wfSources = bundle
|
|
1554
|
+
? Object.keys(bundle.workflows).map((name) => ({
|
|
1555
|
+
name,
|
|
1556
|
+
file: `${name} (bundled)`,
|
|
1557
|
+
load: bundle.workflows[name],
|
|
1558
|
+
}))
|
|
1559
|
+
: listModuleFiles(join(process.cwd(), "workflows"));
|
|
1560
|
+
for (const { file, load } of wfSources) {
|
|
1560
1561
|
try {
|
|
1561
|
-
const mod = await
|
|
1562
|
+
const mod = await load();
|
|
1562
1563
|
const def = mod.default;
|
|
1563
1564
|
if (isWorkflowDefinition(def)) {
|
|
1564
1565
|
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
|
+
}
|