@pylonsync/functions 0.3.237 → 0.3.239
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 +106 -1
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
|
@@ -560,6 +560,72 @@ async function tryRenderBoundary(
|
|
|
560
560
|
}
|
|
561
561
|
}
|
|
562
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
|
+
|
|
563
629
|
export async function handleRenderRoute(
|
|
564
630
|
msg: RenderRouteMessage,
|
|
565
631
|
send: Send,
|
|
@@ -578,6 +644,11 @@ export async function handleRenderRoute(
|
|
|
578
644
|
let React: any = null;
|
|
579
645
|
let renderToReadableStream: any = null;
|
|
580
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> = {};
|
|
581
652
|
try {
|
|
582
653
|
// react + react-dom are USER deps. ssr-runtime.ts lives in
|
|
583
654
|
// packages/functions/src/, but the user's react install is under
|
|
@@ -636,6 +707,19 @@ export async function handleRenderRoute(
|
|
|
636
707
|
);
|
|
637
708
|
}
|
|
638
709
|
|
|
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
|
+
|
|
639
723
|
props = {
|
|
640
724
|
url: msg.url,
|
|
641
725
|
params: msg.params,
|
|
@@ -646,6 +730,8 @@ export async function handleRenderRoute(
|
|
|
646
730
|
// Response controller — a page/layout calls response.setStatus /
|
|
647
731
|
// setHeader / setCookie / redirect / notFound to shape the reply.
|
|
648
732
|
response,
|
|
733
|
+
// Read-only server data handle (see above).
|
|
734
|
+
serverData,
|
|
649
735
|
};
|
|
650
736
|
|
|
651
737
|
// SEO metadata: static `export const metadata` or dynamic
|
|
@@ -689,6 +775,19 @@ export async function handleRenderRoute(
|
|
|
689
775
|
},
|
|
690
776
|
);
|
|
691
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
|
+
|
|
692
791
|
// Headers go out before the first chunk so the host can write the
|
|
693
792
|
// response head.
|
|
694
793
|
// The shell rendered without a redirect()/notFound() throw, so the
|
|
@@ -783,10 +882,16 @@ export async function handleRenderRoute(
|
|
|
783
882
|
// to a no-hydration warning if the manifest can't be loaded
|
|
784
883
|
// (rare — usually means the bundler crashed).
|
|
785
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;
|
|
786
890
|
const hydrationPayload = {
|
|
787
891
|
component: msg.component,
|
|
788
892
|
layouts: msg.layouts ?? [],
|
|
789
|
-
props,
|
|
893
|
+
props: serializableProps,
|
|
894
|
+
ssrData: ssrValueCache,
|
|
790
895
|
};
|
|
791
896
|
const json = JSON.stringify(hydrationPayload).replaceAll("<", "\\u003c");
|
|
792
897
|
|