@pylonsync/functions 0.3.237 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.237",
3
+ "version": "0.3.238",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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
- function buildDbReader(callId: string, unsafeOp = false): DbReader {
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.props);
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.props);
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);
@@ -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