@pylonsync/functions 0.3.384 → 0.3.385
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/runtime.d.ts +9 -0
- package/dist/ssr-runtime.d.ts +10 -0
- package/package.json +1 -1
- package/src/runtime.ts +20 -0
- package/src/ssr-client-bundler.ts +13 -0
- package/src/ssr-runtime.ts +48 -5
- package/src/ssr-serverdata-fn.test.ts +83 -0
package/dist/runtime.d.ts
CHANGED
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { DbReader, DbWriter, Llm, Rooms } from "./types";
|
|
18
18
|
export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
|
|
19
|
+
/**
|
|
20
|
+
* Query-function caller for SSR `serverData.fn(name, args)`. Sends the same
|
|
21
|
+
* `run_fn` frame `ctx.runQuery` uses, on the render's call_id — the host's
|
|
22
|
+
* render loop executes the query with the PAGE's auth context (anonymous on
|
|
23
|
+
* public pages) and rejects anything that isn't a query. Legacy rpc() (no
|
|
24
|
+
* op_id) is correct here: concurrent calls queue settle-chained on the one
|
|
25
|
+
* call_id, matching how ctx.runQuery behaves inside a function.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildSsrFnCaller(callId: string): (name: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
19
28
|
export declare function buildDbWriter(callId: string): DbWriter;
|
|
20
29
|
/**
|
|
21
30
|
* Build the LLM client that round-trips through the host runtime.
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -394,6 +394,16 @@ export declare function buildHydrationTail(args: {
|
|
|
394
394
|
* carrying any stack content. FNV-1a over message+stack, 8 hex chars.
|
|
395
395
|
*/
|
|
396
396
|
export declare function errorDigest(err: any): string;
|
|
397
|
+
/**
|
|
398
|
+
* Wrap a DbReader so each `serverData.x(...)` call returns a PROMISE CACHED
|
|
399
|
+
* by (method, args) — required for React 19 `use()`, which re-invokes the
|
|
400
|
+
* call on the post-suspense re-render and must get the same (now-resolved)
|
|
401
|
+
* promise instead of a fresh pending one (else it suspends forever). Each
|
|
402
|
+
* resolved value is also recorded into `valueCache` keyed identically, so it
|
|
403
|
+
* can be serialized into `__PYLON_DATA__.ssrData` and replayed on the client
|
|
404
|
+
* — keeping hydration free of mismatches.
|
|
405
|
+
*/
|
|
406
|
+
export declare function makeServerData(reader: any, valueCache: Record<string, any>, callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>, onFnCall?: () => void): any;
|
|
397
407
|
/**
|
|
398
408
|
* #278: does this route STREAM (vs buffer the whole document)? Streaming is
|
|
399
409
|
* opt-in: a `loading.tsx` (route-level Suspense) or `export const streaming =
|
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -509,6 +509,26 @@ export function buildDbReader(callId: string, ssrRead = false): DbReader {
|
|
|
509
509
|
};
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Query-function caller for SSR `serverData.fn(name, args)`. Sends the same
|
|
514
|
+
* `run_fn` frame `ctx.runQuery` uses, on the render's call_id — the host's
|
|
515
|
+
* render loop executes the query with the PAGE's auth context (anonymous on
|
|
516
|
+
* public pages) and rejects anything that isn't a query. Legacy rpc() (no
|
|
517
|
+
* op_id) is correct here: concurrent calls queue settle-chained on the one
|
|
518
|
+
* call_id, matching how ctx.runQuery behaves inside a function.
|
|
519
|
+
*/
|
|
520
|
+
export function buildSsrFnCaller(
|
|
521
|
+
callId: string,
|
|
522
|
+
): (name: string, args?: Record<string, unknown>) => Promise<unknown> {
|
|
523
|
+
return (name, args) =>
|
|
524
|
+
rpc(callId, {
|
|
525
|
+
type: "run_fn",
|
|
526
|
+
fn_name: name,
|
|
527
|
+
fn_type: "query",
|
|
528
|
+
args: args ?? {},
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
512
532
|
function buildReaderOps(
|
|
513
533
|
callId: string,
|
|
514
534
|
unsafeOp: boolean,
|
|
@@ -385,6 +385,14 @@ function makeClientServerData(ssrData) {
|
|
|
385
385
|
};
|
|
386
386
|
const sd = wrap("");
|
|
387
387
|
sd.unsafe = wrap("u:");
|
|
388
|
+
// serverData.fn(name, args) — key MUST match the server's makeServerData
|
|
389
|
+
// ("fn:" + name + ":" + stableStringify(args ?? {})) so hydration replays
|
|
390
|
+
// the query function's SSR'd result without a mismatch.
|
|
391
|
+
sd.fn = (name, args) => {
|
|
392
|
+
const key = "fn:" + name + ":" + stableStringify(args ?? {});
|
|
393
|
+
if (!pc.has(key)) pc.set(key, fulfilledThenable(cache[key]));
|
|
394
|
+
return pc.get(key);
|
|
395
|
+
};
|
|
388
396
|
return sd;
|
|
389
397
|
}
|
|
390
398
|
|
|
@@ -411,6 +419,11 @@ function makePendingServerData() {
|
|
|
411
419
|
};
|
|
412
420
|
const sd = wrap("");
|
|
413
421
|
sd.unsafe = wrap("u:");
|
|
422
|
+
sd.fn = (name, args) => {
|
|
423
|
+
const key = "fn:" + name + ":" + stableStringify(args ?? {});
|
|
424
|
+
if (!pc.has(key)) pc.set(key, pending());
|
|
425
|
+
return pc.get(key);
|
|
426
|
+
};
|
|
414
427
|
return sd;
|
|
415
428
|
}
|
|
416
429
|
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -2193,7 +2193,19 @@ const SERVER_DATA_METHODS = [
|
|
|
2193
2193
|
* can be serialized into `__PYLON_DATA__.ssrData` and replayed on the client
|
|
2194
2194
|
* — keeping hydration free of mismatches.
|
|
2195
2195
|
*/
|
|
2196
|
-
function makeServerData(
|
|
2196
|
+
export function makeServerData(
|
|
2197
|
+
reader: any,
|
|
2198
|
+
valueCache: Record<string, any>,
|
|
2199
|
+
// `serverData.fn(name, args)` — runs a registered QUERY function with the
|
|
2200
|
+
// page's own auth context (the host's render loop enforces query-only and
|
|
2201
|
+
// executes it exactly like ctx.runQuery). Lets a page SSR data whose safe
|
|
2202
|
+
// public projection lives in a gated query instead of open row policies.
|
|
2203
|
+
callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>,
|
|
2204
|
+
// Fired on every serverData.fn call so the render can veto shared caching
|
|
2205
|
+
// when the call carried a real identity (the fn may read ctx.auth, making
|
|
2206
|
+
// its output identity-specific without the page ever touching props.auth).
|
|
2207
|
+
onFnCall?: () => void,
|
|
2208
|
+
): any {
|
|
2197
2209
|
const promiseCache = new Map<string, Promise<any>>();
|
|
2198
2210
|
const wrap = (r: any, prefix: string): any => {
|
|
2199
2211
|
const out: any = {};
|
|
@@ -2215,6 +2227,23 @@ function makeServerData(reader: any, valueCache: Record<string, any>): any {
|
|
|
2215
2227
|
};
|
|
2216
2228
|
const sd = wrap(reader, "");
|
|
2217
2229
|
if (reader.unsafe) sd.unsafe = wrap(reader.unsafe, "u:");
|
|
2230
|
+
if (callFn) {
|
|
2231
|
+
sd.fn = (name: string, args?: Record<string, unknown>) => {
|
|
2232
|
+
// Key MUST match the client shim's `fn` key so hydration replays
|
|
2233
|
+
// the value from ssrData without a mismatch.
|
|
2234
|
+
const key = "fn:" + name + ":" + stableStringify(args ?? {});
|
|
2235
|
+
let p = promiseCache.get(key);
|
|
2236
|
+
if (!p) {
|
|
2237
|
+
onFnCall?.();
|
|
2238
|
+
p = callFn(name, args).then((value: any) => {
|
|
2239
|
+
valueCache[key] = value;
|
|
2240
|
+
return value;
|
|
2241
|
+
});
|
|
2242
|
+
promiseCache.set(key, p);
|
|
2243
|
+
}
|
|
2244
|
+
return p;
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2218
2247
|
return sd;
|
|
2219
2248
|
}
|
|
2220
2249
|
|
|
@@ -2794,7 +2823,11 @@ export async function handleRenderRoute(
|
|
|
2794
2823
|
// gate as a query function's ctx.db, and rejects any write. Promise-
|
|
2795
2824
|
// cached so `use()` doesn't re-suspend forever; resolved values land in
|
|
2796
2825
|
// `ssrValueCache` for hydration replay.
|
|
2797
|
-
const { buildDbReader } = await import("./runtime");
|
|
2826
|
+
const { buildDbReader, buildSsrFnCaller } = await import("./runtime");
|
|
2827
|
+
// Reading auth at all (even for an anon request) opts the render OUT of
|
|
2828
|
+
// caching, because the output could differ by identity. Declared before
|
|
2829
|
+
// serverData so `serverData.fn` can flip it too (below).
|
|
2830
|
+
let authTouched = false;
|
|
2798
2831
|
// `ssrRead: true` — serverData results are serialized into the
|
|
2799
2832
|
// client-visible `__PYLON_DATA__` blob, so the host applies the same
|
|
2800
2833
|
// per-row policy filter + `server_only`/`passwordHash` projection the
|
|
@@ -2802,6 +2835,19 @@ export async function handleRenderRoute(
|
|
|
2802
2835
|
const serverData = makeServerData(
|
|
2803
2836
|
buildDbReader(msg.call_id, true),
|
|
2804
2837
|
ssrValueCache,
|
|
2838
|
+
buildSsrFnCaller(msg.call_id),
|
|
2839
|
+
// A query function runs with the page's auth and may read ctx.auth
|
|
2840
|
+
// internally — identity-specific output the read-tracking proxies
|
|
2841
|
+
// can't see. An identity-carrying render that called serverData.fn
|
|
2842
|
+
// must therefore veto shared caches exactly as if it read props.auth.
|
|
2843
|
+
// Anonymous renders stay cacheable: the anonymous result is uniform
|
|
2844
|
+
// across every anonymous visitor.
|
|
2845
|
+
() => {
|
|
2846
|
+
const a = msg.auth as Record<string, unknown> | undefined;
|
|
2847
|
+
if (a && (a.user_id || a.is_admin || a.tenant_id)) {
|
|
2848
|
+
authTouched = true;
|
|
2849
|
+
}
|
|
2850
|
+
},
|
|
2805
2851
|
);
|
|
2806
2852
|
|
|
2807
2853
|
// #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
|
|
@@ -2820,9 +2866,6 @@ export async function handleRenderRoute(
|
|
|
2820
2866
|
return proxy;
|
|
2821
2867
|
};
|
|
2822
2868
|
|
|
2823
|
-
// Reading auth at all (even for an anon request) opts the render OUT of
|
|
2824
|
-
// caching, because the output could differ by identity.
|
|
2825
|
-
let authTouched = false;
|
|
2826
2869
|
const authProxy = track(
|
|
2827
2870
|
msg.auth as Record<string, unknown> | undefined,
|
|
2828
2871
|
() => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `serverData.fn(name, args)` — SSR pages calling query functions with the
|
|
3
|
+
* page's auth context. Pins the pieces that must not drift:
|
|
4
|
+
*
|
|
5
|
+
* - the cache key shape ("fn:" + name + ":" + stableStringify(args ?? {})),
|
|
6
|
+
* which the client hydration shim replays from ssrData — a mismatch means
|
|
7
|
+
* hydration re-suspends forever;
|
|
8
|
+
* - promise caching per (name, args) so React 19 `use()` gets the SAME
|
|
9
|
+
* promise on the post-suspense re-render;
|
|
10
|
+
* - resolved values recorded into the valueCache (the ssrData blob);
|
|
11
|
+
* - the onFnCall notification that lets the render veto shared caching for
|
|
12
|
+
* identity-carrying requests;
|
|
13
|
+
* - the client runtime template carrying the matching `fn` shim.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import { makeServerData } from "./ssr-runtime";
|
|
17
|
+
|
|
18
|
+
const noopReader = {};
|
|
19
|
+
|
|
20
|
+
describe("makeServerData.fn", () => {
|
|
21
|
+
test("caches per (name, args) and records into valueCache with the wire key", async () => {
|
|
22
|
+
const valueCache: Record<string, any> = {};
|
|
23
|
+
const calls: Array<[string, any]> = [];
|
|
24
|
+
const sd = makeServerData(noopReader, valueCache, async (name, args) => {
|
|
25
|
+
calls.push([name, args]);
|
|
26
|
+
return { rows: [name, args?.x ?? null] };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const p1 = sd.fn("getPublicSchedule", { x: 1 });
|
|
30
|
+
const p2 = sd.fn("getPublicSchedule", { x: 1 });
|
|
31
|
+
expect(p1).toBe(p2); // use() re-render must see the SAME promise
|
|
32
|
+
|
|
33
|
+
const v = await p1;
|
|
34
|
+
expect(v).toEqual({ rows: ["getPublicSchedule", 1] });
|
|
35
|
+
expect(calls.length).toBe(1); // deduped
|
|
36
|
+
|
|
37
|
+
// Distinct args → distinct call, distinct cache entry.
|
|
38
|
+
await sd.fn("getPublicSchedule", { x: 2 });
|
|
39
|
+
expect(calls.length).toBe(2);
|
|
40
|
+
|
|
41
|
+
// No args normalizes to {} in the key.
|
|
42
|
+
await sd.fn("getSpeakers");
|
|
43
|
+
expect(Object.keys(valueCache).sort()).toEqual([
|
|
44
|
+
'fn:getPublicSchedule:{"x":1}',
|
|
45
|
+
'fn:getPublicSchedule:{"x":2}',
|
|
46
|
+
"fn:getSpeakers:{}",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("onFnCall fires once per unique call (the cache-veto hook)", async () => {
|
|
51
|
+
let touches = 0;
|
|
52
|
+
const sd = makeServerData(
|
|
53
|
+
noopReader,
|
|
54
|
+
{},
|
|
55
|
+
async () => null,
|
|
56
|
+
() => {
|
|
57
|
+
touches++;
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
sd.fn("a");
|
|
61
|
+
sd.fn("a"); // cached — no second touch
|
|
62
|
+
sd.fn("b");
|
|
63
|
+
expect(touches).toBe(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("no callFn → no fn method (older host pairing)", () => {
|
|
67
|
+
const sd = makeServerData(noopReader, {});
|
|
68
|
+
expect(sd.fn).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("client runtime shim parity", () => {
|
|
73
|
+
test("CLIENT_RUNTIME_SOURCE replays fn results with the identical key", async () => {
|
|
74
|
+
const src = await Bun.file(
|
|
75
|
+
new URL("./ssr-client-bundler.ts", import.meta.url).pathname,
|
|
76
|
+
).text();
|
|
77
|
+
// Both client stand-ins (hydration replay + optimistic-nav pending) must
|
|
78
|
+
// build the exact server-side key. String-level guard against drift.
|
|
79
|
+
const keyExpr = '"fn:" + name + ":" + stableStringify(args ?? {})';
|
|
80
|
+
const count = src.split(keyExpr).length - 1;
|
|
81
|
+
expect(count).toBeGreaterThanOrEqual(2);
|
|
82
|
+
});
|
|
83
|
+
});
|