@pylonsync/functions 0.3.236 → 0.3.238
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/package.json +1 -1
- package/src/runtime.ts +5 -1
- package/src/ssr-client-bundler.ts +88 -2
- package/src/ssr-runtime.ts +397 -80
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -329,7 +329,11 @@ function rpc(callId: string, msg: Record<string, unknown>): Promise<unknown> {
|
|
|
329
329
|
// Context builders
|
|
330
330
|
// ---------------------------------------------------------------------------
|
|
331
331
|
|
|
332
|
-
|
|
332
|
+
// Exported so the SSR runtime (ssr-runtime.ts) can build a page-facing
|
|
333
|
+
// `serverData` read handle that reuses this module's `send` + `pendingRpcs`
|
|
334
|
+
// + reader loop. The render call_id ("r_<n>") correlates DB replies back
|
|
335
|
+
// through the shared pendingRpcs map.
|
|
336
|
+
export function buildDbReader(callId: string, unsafeOp = false): DbReader {
|
|
333
337
|
// All DB ops use rpcDb so Promise.all over ctx.db reads can run in
|
|
334
338
|
// parallel without colliding on the outer call_id key.
|
|
335
339
|
//
|
|
@@ -192,6 +192,92 @@ function buildTree(Page, Layouts, props) {
|
|
|
192
192
|
return tree;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// Deterministic stringify — MUST match stableStringify in ssr-runtime.ts so
|
|
196
|
+
// a serverData call's cache key is identical on server and client.
|
|
197
|
+
function stableStringify(v) {
|
|
198
|
+
if (v === null || v === undefined || typeof v !== "object") {
|
|
199
|
+
return JSON.stringify(v === undefined ? null : v);
|
|
200
|
+
}
|
|
201
|
+
if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
|
|
202
|
+
const keys = Object.keys(v).sort();
|
|
203
|
+
return (
|
|
204
|
+
"{" +
|
|
205
|
+
keys.map((k) => JSON.stringify(k) + ":" + stableStringify(v[k])).join(",") +
|
|
206
|
+
"}"
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const SERVER_DATA_METHODS = [
|
|
211
|
+
"get",
|
|
212
|
+
"list",
|
|
213
|
+
"lookup",
|
|
214
|
+
"query",
|
|
215
|
+
"queryGraph",
|
|
216
|
+
"paginate",
|
|
217
|
+
"search",
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
// A React-recognized fulfilled thenable: use() reads .value synchronously
|
|
221
|
+
// (status === "fulfilled") instead of suspending. Critical for hydration —
|
|
222
|
+
// the server streamed the post-Suspense content, so the client must render
|
|
223
|
+
// that content on the FIRST pass without re-suspending (no fallback flash,
|
|
224
|
+
// no mismatch).
|
|
225
|
+
function fulfilledThenable(value) {
|
|
226
|
+
return {
|
|
227
|
+
status: "fulfilled",
|
|
228
|
+
value,
|
|
229
|
+
then(onFulfilled) {
|
|
230
|
+
return onFulfilled ? onFulfilled(value) : value;
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Client stand-in for the server's serverData handle. Each method returns a
|
|
236
|
+
// pre-fulfilled thenable (cached per key) sourced from the SSR'd ssrData map,
|
|
237
|
+
// keyed identically to the server. Misses yield undefined — the page
|
|
238
|
+
// rendered with whatever the server fetched, so a hit is expected.
|
|
239
|
+
function makeClientServerData(ssrData) {
|
|
240
|
+
const cache = ssrData || {};
|
|
241
|
+
const pc = new Map();
|
|
242
|
+
const wrap = (prefix) => {
|
|
243
|
+
const out = {};
|
|
244
|
+
for (const m of SERVER_DATA_METHODS) {
|
|
245
|
+
out[m] = (...args) => {
|
|
246
|
+
const key = prefix + m + ":" + stableStringify(args);
|
|
247
|
+
if (!pc.has(key)) pc.set(key, fulfilledThenable(cache[key]));
|
|
248
|
+
return pc.get(key);
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
};
|
|
253
|
+
const sd = wrap("");
|
|
254
|
+
sd.unsafe = wrap("u:");
|
|
255
|
+
return sd;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Server-only response controller has no meaning on the client (the status/
|
|
259
|
+
// redirect/cookies already shipped). Give pages a no-op so a body that
|
|
260
|
+
// touches props.response during hydration doesn't crash.
|
|
261
|
+
function makeNoopResponse() {
|
|
262
|
+
const noop = () => {};
|
|
263
|
+
return {
|
|
264
|
+
setStatus: noop,
|
|
265
|
+
setHeader: noop,
|
|
266
|
+
setCookie: noop,
|
|
267
|
+
redirect: noop,
|
|
268
|
+
notFound: noop,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Rehydrate the live, server-only props (serverData + response) that were
|
|
273
|
+
// stripped before serialization, so the client tree matches the server's.
|
|
274
|
+
function withClientProps(data) {
|
|
275
|
+
const props = { ...(data.props || {}) };
|
|
276
|
+
props.serverData = makeClientServerData(data.ssrData);
|
|
277
|
+
props.response = makeNoopResponse();
|
|
278
|
+
return props;
|
|
279
|
+
}
|
|
280
|
+
|
|
195
281
|
function readPylonData() {
|
|
196
282
|
const dataEl = document.getElementById("__PYLON_DATA__");
|
|
197
283
|
if (!dataEl) return null;
|
|
@@ -286,7 +372,7 @@ export function hydrate(component, Page, Layouts) {
|
|
|
286
372
|
);
|
|
287
373
|
return;
|
|
288
374
|
}
|
|
289
|
-
const tree = buildTree(Page, Layouts, data
|
|
375
|
+
const tree = buildTree(Page, Layouts, withClientProps(data));
|
|
290
376
|
activeRoot = hydrateRoot(document, tree);
|
|
291
377
|
installNavHandlers();
|
|
292
378
|
return;
|
|
@@ -340,7 +426,7 @@ async function navigate(href, opts) {
|
|
|
340
426
|
return;
|
|
341
427
|
}
|
|
342
428
|
document.title = doc.title || document.title;
|
|
343
|
-
const tree = buildTree(route.Page, route.Layouts, data
|
|
429
|
+
const tree = buildTree(route.Page, route.Layouts, withClientProps(data));
|
|
344
430
|
activeRoot.render(tree);
|
|
345
431
|
if (push) {
|
|
346
432
|
history.pushState({ component: data.component }, "", url.pathname + url.search);
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -55,6 +55,14 @@ export interface RenderRouteMessage {
|
|
|
55
55
|
tenant_id: string | null;
|
|
56
56
|
roles: string[];
|
|
57
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Initial HTTP status the response controller starts at (default 200).
|
|
60
|
+
* The host sets this to 404 when dispatching a `not-found.tsx` render
|
|
61
|
+
* for an unmatched URL, so the boundary streams at 404 without the
|
|
62
|
+
* component having to call `response.setStatus`. A page can still
|
|
63
|
+
* override it via `response.setStatus`.
|
|
64
|
+
*/
|
|
65
|
+
initial_status?: number;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
type Send = (msg: Record<string, unknown>) => void;
|
|
@@ -167,7 +175,12 @@ export interface SsrResponse {
|
|
|
167
175
|
setCookie(name: string, value: string, opts?: SsrCookieOptions): void;
|
|
168
176
|
/** Throw to send a 3xx (default 307) + Location, no body. Shell-render only. */
|
|
169
177
|
redirect(url: string, status?: number): never;
|
|
170
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* Throw to send a 404. Renders the nearest `not-found.tsx` (walking up
|
|
180
|
+
* from the page's directory, wrapped in the route's layout chain), or a
|
|
181
|
+
* minimal framework body if none is defined. Shell-render only — a throw
|
|
182
|
+
* below a Suspense boundary is swallowed by React.
|
|
183
|
+
*/
|
|
171
184
|
notFound(): never;
|
|
172
185
|
}
|
|
173
186
|
|
|
@@ -364,14 +377,278 @@ async function buildLayoutTree(
|
|
|
364
377
|
return tree;
|
|
365
378
|
}
|
|
366
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Walk up from a page's directory to the nearest boundary file
|
|
382
|
+
* (not-found / error) — the same render-time, filesystem-resolved model
|
|
383
|
+
* the page + layouts already use, so no build-time manifest threading.
|
|
384
|
+
* Returns the project-relative path (no extension) or null.
|
|
385
|
+
*/
|
|
386
|
+
function findBoundary(componentPath: string, fileName: string): string | null {
|
|
387
|
+
const fs = require("node:fs");
|
|
388
|
+
const path = require("node:path");
|
|
389
|
+
const cwd = process.cwd();
|
|
390
|
+
// Component paths use "/" — walk up directory by directory.
|
|
391
|
+
let dir = componentPath.replace(/\\/g, "/");
|
|
392
|
+
dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
|
|
393
|
+
while (dir && dir !== "." && dir !== "/") {
|
|
394
|
+
for (const ext of MODULE_EXTS) {
|
|
395
|
+
if (fs.existsSync(path.join(cwd, dir, `${fileName}${ext}`))) {
|
|
396
|
+
return `${dir}/${fileName}`;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const slash = dir.lastIndexOf("/");
|
|
400
|
+
dir = slash >= 0 ? dir.slice(0, slash) : "";
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Drain a `renderToReadableStream` reader, injecting `headBlob` immediately
|
|
407
|
+
* before the first `</head>` (or, if the document has none, the blob is
|
|
408
|
+
* never emitted — fragment renders have no head). `</head>` can straddle a
|
|
409
|
+
* chunk boundary, so a small carry buffer (len("</head>") − 1 bytes) is
|
|
410
|
+
* withheld at each chunk's tail until the next read confirms the match.
|
|
411
|
+
* Each emitted slice is handed to `sendChunk` as utf-8 text.
|
|
412
|
+
*
|
|
413
|
+
* Shared by the page render and the boundary render so head injection has
|
|
414
|
+
* exactly one implementation.
|
|
415
|
+
*/
|
|
416
|
+
async function streamWithHeadInjection(
|
|
417
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
418
|
+
headBlob: string,
|
|
419
|
+
sendChunk: (text: string) => void,
|
|
420
|
+
): Promise<void> {
|
|
421
|
+
let headInjected = headBlob.length === 0;
|
|
422
|
+
let carry = "";
|
|
423
|
+
const HEAD_CLOSE = "</head>";
|
|
424
|
+
for (;;) {
|
|
425
|
+
const { value, done } = await reader.read();
|
|
426
|
+
if (done) break;
|
|
427
|
+
if (!value || value.byteLength === 0) continue;
|
|
428
|
+
const text = Buffer.from(value).toString("utf8");
|
|
429
|
+
if (!headInjected) {
|
|
430
|
+
const combined = carry + text;
|
|
431
|
+
const idx = combined.indexOf(HEAD_CLOSE);
|
|
432
|
+
if (idx >= 0) {
|
|
433
|
+
sendChunk(combined.slice(0, idx));
|
|
434
|
+
sendChunk(headBlob);
|
|
435
|
+
sendChunk(HEAD_CLOSE);
|
|
436
|
+
const after = combined.slice(idx + HEAD_CLOSE.length);
|
|
437
|
+
if (after) sendChunk(after);
|
|
438
|
+
headInjected = true;
|
|
439
|
+
carry = "";
|
|
440
|
+
} else {
|
|
441
|
+
const keep = HEAD_CLOSE.length - 1;
|
|
442
|
+
if (combined.length > keep) {
|
|
443
|
+
sendChunk(combined.slice(0, combined.length - keep));
|
|
444
|
+
carry = combined.slice(combined.length - keep);
|
|
445
|
+
} else {
|
|
446
|
+
carry = combined;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
sendChunk(text);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (carry) sendChunk(carry);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Build the <head> blob for a boundary render: the union of every route's
|
|
458
|
+
* stylesheet links from the client build manifest. Boundary modules aren't
|
|
459
|
+
* bundled as their own client entry, but they render inside the same
|
|
460
|
+
* layout/shell as pages, so without the app's global CSS a 404/500 page
|
|
461
|
+
* would look broken. Returns "" if the manifest can't be loaded — the
|
|
462
|
+
* boundary still renders (unstyled); CSS must never block the error path.
|
|
463
|
+
*/
|
|
464
|
+
async function collectBoundaryHeadBlob(): Promise<string> {
|
|
465
|
+
try {
|
|
466
|
+
const { getManifest } = await import("./ssr-client-bundler");
|
|
467
|
+
const manifest = await getManifest();
|
|
468
|
+
const prefix = manifest.public_prefix || "/_pylon/build/";
|
|
469
|
+
const seen = new Set<string>();
|
|
470
|
+
let blob = "";
|
|
471
|
+
for (const route of Object.values(manifest.routes || {}) as any[]) {
|
|
472
|
+
for (const css of (route.css || []) as string[]) {
|
|
473
|
+
if (seen.has(css)) continue;
|
|
474
|
+
seen.add(css);
|
|
475
|
+
blob += `<link rel="stylesheet" href="${prefix}${css}">`;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return blob;
|
|
479
|
+
} catch {
|
|
480
|
+
return "";
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Render a boundary (not-found/error) tree and stream it as the response
|
|
486
|
+
* body at `status`. Boundaries render server-side only (no hydration
|
|
487
|
+
* payload) — they're informational pages, consistent with the keystone's
|
|
488
|
+
* fixed 404 body that this replaces — but they DO get the app's global
|
|
489
|
+
* stylesheet injected so they match the rest of the site.
|
|
490
|
+
*/
|
|
491
|
+
async function renderBoundaryToClient(
|
|
492
|
+
React: any,
|
|
493
|
+
renderToReadableStream: any,
|
|
494
|
+
tree: any,
|
|
495
|
+
send: Send,
|
|
496
|
+
callId: string,
|
|
497
|
+
status: number,
|
|
498
|
+
headers: Record<string, string>,
|
|
499
|
+
): Promise<void> {
|
|
500
|
+
const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
|
|
501
|
+
onError(e: unknown) {
|
|
502
|
+
// eslint-disable-next-line no-console
|
|
503
|
+
console.error("[ssr] boundary render error:", e);
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
const headBlob = await collectBoundaryHeadBlob();
|
|
507
|
+
// renderToReadableStream resolved without throwing → safe to commit the
|
|
508
|
+
// head now, then drain the (already-rendered) shell, injecting CSS.
|
|
509
|
+
send({ type: "response_start", call_id: callId, status, headers });
|
|
510
|
+
const sendChunk = (text: string) => {
|
|
511
|
+
if (!text) return;
|
|
512
|
+
send({
|
|
513
|
+
type: "render_chunk",
|
|
514
|
+
call_id: callId,
|
|
515
|
+
data: Buffer.from(text, "utf8").toString("base64"),
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
|
|
519
|
+
send({ type: "render_done", call_id: callId });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Resolve + render a boundary component wrapped in the route's layout
|
|
524
|
+
* chain. Returns true if it rendered (caller returns), false to fall back.
|
|
525
|
+
*/
|
|
526
|
+
async function tryRenderBoundary(
|
|
527
|
+
opts: {
|
|
528
|
+
React: any;
|
|
529
|
+
renderToReadableStream: any;
|
|
530
|
+
cwd: string;
|
|
531
|
+
componentPath: string;
|
|
532
|
+
fileName: "not-found" | "error";
|
|
533
|
+
layouts: string[] | undefined;
|
|
534
|
+
props: any;
|
|
535
|
+
send: Send;
|
|
536
|
+
callId: string;
|
|
537
|
+
status: number;
|
|
538
|
+
headers: Record<string, string>;
|
|
539
|
+
},
|
|
540
|
+
): Promise<boolean> {
|
|
541
|
+
const { React, renderToReadableStream, cwd, componentPath, fileName, layouts, props, send, callId, status, headers } =
|
|
542
|
+
opts;
|
|
543
|
+
if (!React || !renderToReadableStream || !props) return false;
|
|
544
|
+
const rel = findBoundary(componentPath, fileName);
|
|
545
|
+
if (!rel) return false;
|
|
546
|
+
try {
|
|
547
|
+
const mod = await importModule(cwd, rel);
|
|
548
|
+
const Comp = mod.default ?? mod.Component ?? mod.NotFound ?? mod.Error;
|
|
549
|
+
if (typeof Comp !== "function") return false;
|
|
550
|
+
let tree = React.createElement(Comp, props);
|
|
551
|
+
tree = await buildLayoutTree(cwd, tree, layouts, props, React);
|
|
552
|
+
await renderBoundaryToClient(React, renderToReadableStream, tree, send, callId, status, headers);
|
|
553
|
+
return true;
|
|
554
|
+
} catch (e) {
|
|
555
|
+
// Boundary render itself failed — no tertiary fallback; let the caller
|
|
556
|
+
// emit its default (fixed 404 body / type:"error" → 500).
|
|
557
|
+
// eslint-disable-next-line no-console
|
|
558
|
+
console.error(`[ssr] ${fileName}.tsx boundary failed to render:`, e);
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Deterministic stringify (keys sorted recursively) so a `serverData` call's
|
|
565
|
+
* cache key is identical on the server (here) and on the client (the
|
|
566
|
+
* hydration shim in ssr-client-bundler's client-runtime). MUST stay in sync
|
|
567
|
+
* with `stableStringify` in that template.
|
|
568
|
+
*/
|
|
569
|
+
function stableStringify(v: any): string {
|
|
570
|
+
if (v === null || v === undefined || typeof v !== "object") {
|
|
571
|
+
return JSON.stringify(v ?? null);
|
|
572
|
+
}
|
|
573
|
+
if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
|
|
574
|
+
const keys = Object.keys(v).sort();
|
|
575
|
+
return (
|
|
576
|
+
"{" +
|
|
577
|
+
keys.map((k) => JSON.stringify(k) + ":" + stableStringify(v[k])).join(",") +
|
|
578
|
+
"}"
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// The read methods a page reaches through `serverData`. Mirrors the
|
|
583
|
+
// DbReader read surface (writes are blocked server-side). Kept in sync with
|
|
584
|
+
// the client-runtime shim's method list.
|
|
585
|
+
const SERVER_DATA_METHODS = [
|
|
586
|
+
"get",
|
|
587
|
+
"list",
|
|
588
|
+
"lookup",
|
|
589
|
+
"query",
|
|
590
|
+
"queryGraph",
|
|
591
|
+
"paginate",
|
|
592
|
+
"search",
|
|
593
|
+
] as const;
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Wrap a DbReader so each `serverData.x(...)` call returns a PROMISE CACHED
|
|
597
|
+
* by (method, args) — required for React 19 `use()`, which re-invokes the
|
|
598
|
+
* call on the post-suspense re-render and must get the same (now-resolved)
|
|
599
|
+
* promise instead of a fresh pending one (else it suspends forever). Each
|
|
600
|
+
* resolved value is also recorded into `valueCache` keyed identically, so it
|
|
601
|
+
* can be serialized into `__PYLON_DATA__.ssrData` and replayed on the client
|
|
602
|
+
* — keeping hydration free of mismatches.
|
|
603
|
+
*/
|
|
604
|
+
function makeServerData(reader: any, valueCache: Record<string, any>): any {
|
|
605
|
+
const promiseCache = new Map<string, Promise<any>>();
|
|
606
|
+
const wrap = (r: any, prefix: string): any => {
|
|
607
|
+
const out: any = {};
|
|
608
|
+
for (const m of SERVER_DATA_METHODS) {
|
|
609
|
+
out[m] = (...args: any[]) => {
|
|
610
|
+
const key = prefix + m + ":" + stableStringify(args);
|
|
611
|
+
let p = promiseCache.get(key);
|
|
612
|
+
if (!p) {
|
|
613
|
+
p = Promise.resolve(r[m](...args)).then((value: any) => {
|
|
614
|
+
valueCache[key] = value;
|
|
615
|
+
return value;
|
|
616
|
+
});
|
|
617
|
+
promiseCache.set(key, p);
|
|
618
|
+
}
|
|
619
|
+
return p;
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
};
|
|
624
|
+
const sd = wrap(reader, "");
|
|
625
|
+
if (reader.unsafe) sd.unsafe = wrap(reader.unsafe, "u:");
|
|
626
|
+
return sd;
|
|
627
|
+
}
|
|
628
|
+
|
|
367
629
|
export async function handleRenderRoute(
|
|
368
630
|
msg: RenderRouteMessage,
|
|
369
631
|
send: Send,
|
|
370
632
|
): Promise<void> {
|
|
371
633
|
// Declared OUTSIDE the try so the catch can read page-set status/
|
|
372
634
|
// cookies when turning a redirect()/notFound() throw into a response.
|
|
373
|
-
const responseState: ResponseState = {
|
|
635
|
+
const responseState: ResponseState = {
|
|
636
|
+
status: msg.initial_status ?? 200,
|
|
637
|
+
headers: {},
|
|
638
|
+
cookies: [],
|
|
639
|
+
};
|
|
374
640
|
const response = makeResponseController(responseState);
|
|
641
|
+
// Hoisted out of the try so the catch can render not-found.tsx /
|
|
642
|
+
// error.tsx boundaries (which need React + the renderer + cwd + props).
|
|
643
|
+
const cwd = process.cwd();
|
|
644
|
+
let React: any = null;
|
|
645
|
+
let renderToReadableStream: any = null;
|
|
646
|
+
let props: any = null;
|
|
647
|
+
// Accumulates the resolved results of every `serverData.*` read the page
|
|
648
|
+
// made during render, keyed identically to the client shim. Serialized
|
|
649
|
+
// into `__PYLON_DATA__.ssrData` so hydration replays the same values
|
|
650
|
+
// without a second round trip — and without a server/client mismatch.
|
|
651
|
+
const ssrValueCache: Record<string, any> = {};
|
|
375
652
|
try {
|
|
376
653
|
// react + react-dom are USER deps. ssr-runtime.ts lives in
|
|
377
654
|
// packages/functions/src/, but the user's react install is under
|
|
@@ -379,7 +656,6 @@ export async function handleRenderRoute(
|
|
|
379
656
|
// would resolve against pylon's own node_modules (which doesn't
|
|
380
657
|
// declare react), so we route through a Bun-resolveSync against
|
|
381
658
|
// the user's cwd.
|
|
382
|
-
const cwd = process.cwd();
|
|
383
659
|
const resolveFromUser = (spec: string): string =>
|
|
384
660
|
(Bun as any).resolveSync
|
|
385
661
|
? (Bun as any).resolveSync(spec, cwd)
|
|
@@ -405,8 +681,8 @@ export async function handleRenderRoute(
|
|
|
405
681
|
const reactImport = await import(
|
|
406
682
|
/* @vite-ignore */ resolveFromUser("react")
|
|
407
683
|
);
|
|
408
|
-
|
|
409
|
-
|
|
684
|
+
React = reactImport.default ?? reactImport;
|
|
685
|
+
renderToReadableStream =
|
|
410
686
|
reactDomServerImport.renderToReadableStream ??
|
|
411
687
|
reactDomServerImport.default?.renderToReadableStream;
|
|
412
688
|
if (typeof renderToReadableStream !== "function") {
|
|
@@ -431,7 +707,20 @@ export async function handleRenderRoute(
|
|
|
431
707
|
);
|
|
432
708
|
}
|
|
433
709
|
|
|
434
|
-
|
|
710
|
+
// `serverData` — a read-only DB handle the page reaches during render
|
|
711
|
+
// via React 19 `use()` + <Suspense>. Reuses runtime.ts's RPC pipe
|
|
712
|
+
// (shared `send` + pendingRpcs) keyed by this render's call_id; the
|
|
713
|
+
// Rust render loop answers the frames against the same store + policy
|
|
714
|
+
// gate as a query function's ctx.db, and rejects any write. Promise-
|
|
715
|
+
// cached so `use()` doesn't re-suspend forever; resolved values land in
|
|
716
|
+
// `ssrValueCache` for hydration replay.
|
|
717
|
+
const { buildDbReader } = await import("./runtime");
|
|
718
|
+
const serverData = makeServerData(
|
|
719
|
+
buildDbReader(msg.call_id),
|
|
720
|
+
ssrValueCache,
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
props = {
|
|
435
724
|
url: msg.url,
|
|
436
725
|
params: msg.params,
|
|
437
726
|
searchParams: msg.search_params,
|
|
@@ -441,6 +730,8 @@ export async function handleRenderRoute(
|
|
|
441
730
|
// Response controller — a page/layout calls response.setStatus /
|
|
442
731
|
// setHeader / setCookie / redirect / notFound to shape the reply.
|
|
443
732
|
response,
|
|
733
|
+
// Read-only server data handle (see above).
|
|
734
|
+
serverData,
|
|
444
735
|
};
|
|
445
736
|
|
|
446
737
|
// SEO metadata: static `export const metadata` or dynamic
|
|
@@ -484,6 +775,19 @@ export async function handleRenderRoute(
|
|
|
484
775
|
},
|
|
485
776
|
);
|
|
486
777
|
|
|
778
|
+
// Wait for ALL Suspense boundaries to resolve before emitting the body,
|
|
779
|
+
// so the HTML is fully formed — no `<!--$?-->` pending markers, no
|
|
780
|
+
// hidden fallback segments, no `$RC` reveal scripts. This is what makes
|
|
781
|
+
// `serverData` + `use()` + <Suspense> hydrate cleanly: the client
|
|
782
|
+
// hydrates a RESOLVED boundary against the SSR'd content (resolved from
|
|
783
|
+
// `ssrData`), instead of fighting React's streaming-reveal scripts +
|
|
784
|
+
// whole-document hydration (which leaves the boundary stuck on its
|
|
785
|
+
// fallback). Pages with no async data have no boundaries, so `allReady`
|
|
786
|
+
// resolves immediately — zero cost for the common case.
|
|
787
|
+
if ((stream as any).allReady) {
|
|
788
|
+
await (stream as any).allReady;
|
|
789
|
+
}
|
|
790
|
+
|
|
487
791
|
// Headers go out before the first chunk so the host can write the
|
|
488
792
|
// response head.
|
|
489
793
|
// The shell rendered without a redirect()/notFound() throw, so the
|
|
@@ -530,16 +834,25 @@ export async function handleRenderRoute(
|
|
|
530
834
|
for (const chunk of preloadManifestRoute.imports) {
|
|
531
835
|
headBlob += `<link rel="modulepreload" href="${preloadPublicPrefix}${chunk}">`;
|
|
532
836
|
}
|
|
837
|
+
} else {
|
|
838
|
+
// No per-route client entry. This is the unmatched-URL not-found
|
|
839
|
+
// dispatch (the host renders `app/not-found` by name at 404) or any
|
|
840
|
+
// other component without a hydration bundle. It still renders inside
|
|
841
|
+
// the app shell, so inject the global stylesheet(s) — otherwise the
|
|
842
|
+
// 404 page is unstyled. Hydration stays disabled (handled below).
|
|
843
|
+
headBlob += await collectBoundaryHeadBlob();
|
|
533
844
|
}
|
|
534
845
|
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
846
|
+
// The host can dispatch a boundary module (`app/not-found` / `app/error`)
|
|
847
|
+
// by name for an unmatched-URL 404. Boundaries render server-only — no
|
|
848
|
+
// hydration payload, and no "hydration disabled" warning (that warning is
|
|
849
|
+
// for a real page whose client bundle is missing).
|
|
850
|
+
const isBoundaryComponent = /(^|\/)(not-found|error)$/.test(msg.component);
|
|
851
|
+
|
|
852
|
+
// Stream-rewrite: watch for `</head>` and inject `headBlob` before it.
|
|
853
|
+
// `</head>` may straddle chunk boundaries; the shared helper keeps a
|
|
854
|
+
// small carry buffer to catch a split tag. base64 of each utf-8 slice
|
|
855
|
+
// happens in `sendChunk` (Buffer ships with Bun).
|
|
543
856
|
const sendChunk = (text: string) => {
|
|
544
857
|
if (!text) return;
|
|
545
858
|
send({
|
|
@@ -548,50 +861,7 @@ export async function handleRenderRoute(
|
|
|
548
861
|
data: Buffer.from(text, "utf8").toString("base64"),
|
|
549
862
|
});
|
|
550
863
|
};
|
|
551
|
-
|
|
552
|
-
const { value, done } = await reader.read();
|
|
553
|
-
if (done) break;
|
|
554
|
-
if (!value || value.byteLength === 0) continue;
|
|
555
|
-
let text = Buffer.from(value).toString("utf8");
|
|
556
|
-
if (!headInjected) {
|
|
557
|
-
const combined = carry + text;
|
|
558
|
-
const idx = combined.indexOf(HEAD_CLOSE);
|
|
559
|
-
if (idx >= 0) {
|
|
560
|
-
// Send everything up to the </head> position, then the
|
|
561
|
-
// headBlob, then </head>, then the remainder.
|
|
562
|
-
const before = combined.slice(0, idx);
|
|
563
|
-
const after = combined.slice(idx + HEAD_CLOSE.length);
|
|
564
|
-
// Drop the carry portion from `before` that we already
|
|
565
|
-
// emitted as part of the previous chunk's send. But since
|
|
566
|
-
// we DIDN'T emit `carry` previously (it was withheld), we
|
|
567
|
-
// can send the full `before` here.
|
|
568
|
-
sendChunk(before);
|
|
569
|
-
sendChunk(headBlob);
|
|
570
|
-
sendChunk(HEAD_CLOSE);
|
|
571
|
-
if (after) sendChunk(after);
|
|
572
|
-
headInjected = true;
|
|
573
|
-
carry = "";
|
|
574
|
-
} else {
|
|
575
|
-
// No </head> yet — emit everything except the last
|
|
576
|
-
// (HEAD_CLOSE.length - 1) bytes so a tag split across
|
|
577
|
-
// chunk boundaries still gets caught next pass.
|
|
578
|
-
const keep = HEAD_CLOSE.length - 1;
|
|
579
|
-
if (combined.length > keep) {
|
|
580
|
-
sendChunk(combined.slice(0, combined.length - keep));
|
|
581
|
-
carry = combined.slice(combined.length - keep);
|
|
582
|
-
} else {
|
|
583
|
-
carry = combined;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
} else {
|
|
587
|
-
// base64 in pure JS via Buffer (Bun ships it). For large
|
|
588
|
-
// pages this is O(n) per chunk; fine for Phase 1.
|
|
589
|
-
sendChunk(text);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
// Flush any residual carry (head close never seen — page
|
|
593
|
-
// didn't have a </head>, which is fine for fragment renders).
|
|
594
|
-
if (carry) sendChunk(carry);
|
|
864
|
+
await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
|
|
595
865
|
|
|
596
866
|
// Hydration tail. After React's stream EOFs we append the
|
|
597
867
|
// hydration markers so the browser can hydrate:
|
|
@@ -611,28 +881,36 @@ export async function handleRenderRoute(
|
|
|
611
881
|
// and `getManifest` parses with mtime-keyed caching. Falls back
|
|
612
882
|
// to a no-hydration warning if the manifest can't be loaded
|
|
613
883
|
// (rare — usually means the bundler crashed).
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
884
|
+
if (!isBoundaryComponent) {
|
|
885
|
+
// Strip live, non-serializable handles from the props that seed
|
|
886
|
+
// hydration: `serverData` (an RPC handle — the client rebuilds its
|
|
887
|
+
// own from `ssrData`) and `response` (a server-only controller — the
|
|
888
|
+
// client gets a no-op). Their resolved data rides along in `ssrData`.
|
|
889
|
+
const { serverData: _sd, response: _resp, ...serializableProps } = props;
|
|
890
|
+
const hydrationPayload = {
|
|
891
|
+
component: msg.component,
|
|
892
|
+
layouts: msg.layouts ?? [],
|
|
893
|
+
props: serializableProps,
|
|
894
|
+
ssrData: ssrValueCache,
|
|
895
|
+
};
|
|
896
|
+
const json = JSON.stringify(hydrationPayload).replaceAll("<", "\\u003c");
|
|
620
897
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
898
|
+
let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
|
|
899
|
+
if (preloadManifestRoute) {
|
|
900
|
+
// Per-route entry script comes last — it needs the inline
|
|
901
|
+
// `__PYLON_DATA__` above to have been parsed before it runs.
|
|
902
|
+
// CSS + modulepreload links were already injected into `<head>`
|
|
903
|
+
// above so they could start fetching as early as possible.
|
|
904
|
+
tail += `<script type="module" src="${preloadPublicPrefix}${preloadManifestRoute.file}"></script>`;
|
|
905
|
+
} else {
|
|
906
|
+
tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${preloadManifestErr}`)})</script>`;
|
|
907
|
+
}
|
|
908
|
+
send({
|
|
909
|
+
type: "render_chunk",
|
|
910
|
+
call_id: msg.call_id,
|
|
911
|
+
data: Buffer.from(tail, "utf8").toString("base64"),
|
|
912
|
+
});
|
|
630
913
|
}
|
|
631
|
-
send({
|
|
632
|
-
type: "render_chunk",
|
|
633
|
-
call_id: msg.call_id,
|
|
634
|
-
data: Buffer.from(tail, "utf8").toString("base64"),
|
|
635
|
-
});
|
|
636
914
|
|
|
637
915
|
send({ type: "render_done", call_id: msg.call_id });
|
|
638
916
|
} catch (err: any) {
|
|
@@ -650,7 +928,26 @@ export async function handleRenderRoute(
|
|
|
650
928
|
send({ type: "render_done", call_id: msg.call_id });
|
|
651
929
|
return;
|
|
652
930
|
}
|
|
653
|
-
// notFound() →
|
|
931
|
+
// notFound() → look for the nearest not-found.tsx walking up from the
|
|
932
|
+
// page's directory; render it (wrapped in the route's layouts) at 404.
|
|
933
|
+
// Falls back to a minimal framework body if none is defined.
|
|
934
|
+
if (
|
|
935
|
+
await tryRenderBoundary({
|
|
936
|
+
React,
|
|
937
|
+
renderToReadableStream,
|
|
938
|
+
cwd,
|
|
939
|
+
componentPath: msg.component,
|
|
940
|
+
fileName: "not-found",
|
|
941
|
+
layouts: msg.layouts,
|
|
942
|
+
props,
|
|
943
|
+
send,
|
|
944
|
+
callId: msg.call_id,
|
|
945
|
+
status: 404,
|
|
946
|
+
headers: finalizeHeaders(responseState),
|
|
947
|
+
})
|
|
948
|
+
) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
654
951
|
const body404 =
|
|
655
952
|
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>404 — Not Found</title></head><body><h1>404</h1><p>This page could not be found.</p></body></html>';
|
|
656
953
|
send({
|
|
@@ -667,7 +964,27 @@ export async function handleRenderRoute(
|
|
|
667
964
|
send({ type: "render_done", call_id: msg.call_id });
|
|
668
965
|
return;
|
|
669
966
|
}
|
|
670
|
-
// Real pre-first-chunk error →
|
|
967
|
+
// Real pre-first-chunk error → look for the nearest error.tsx walking up
|
|
968
|
+
// from the page's directory; render it (wrapped in the route's layouts)
|
|
969
|
+
// at 500 with the thrown error passed in props. Falls back to a host-level
|
|
970
|
+
// 500 (type:"error") if none is defined or the boundary itself throws.
|
|
971
|
+
if (
|
|
972
|
+
await tryRenderBoundary({
|
|
973
|
+
React,
|
|
974
|
+
renderToReadableStream,
|
|
975
|
+
cwd,
|
|
976
|
+
componentPath: msg.component,
|
|
977
|
+
fileName: "error",
|
|
978
|
+
layouts: msg.layouts,
|
|
979
|
+
props: props ? { ...props, error: err } : null,
|
|
980
|
+
send,
|
|
981
|
+
callId: msg.call_id,
|
|
982
|
+
status: 500,
|
|
983
|
+
headers: finalizeHeaders(responseState),
|
|
984
|
+
})
|
|
985
|
+
) {
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
671
988
|
send({
|
|
672
989
|
type: "error",
|
|
673
990
|
call_id: msg.call_id,
|