@pylonsync/functions 0.22.47 → 0.22.58
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/index.d.ts +1 -1
- package/dist/offline-sw.d.ts +46 -0
- package/dist/runtime.d.ts +5 -1
- package/dist/ssr-barrel-imports.d.ts +16 -0
- package/dist/ssr-client-bundler.d.ts +50 -0
- package/dist/ssr-client-nav.d.ts +30 -0
- package/dist/ssr-offline.d.ts +93 -0
- package/dist/ssr-precompress.d.ts +16 -0
- package/dist/ssr-route-assets.d.ts +35 -0
- package/dist/ssr-runtime.d.ts +43 -6
- package/dist/types.d.ts +53 -0
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/offline-sw.test.ts +393 -0
- package/src/offline-sw.ts +343 -0
- package/src/runtime-files.test.ts +55 -0
- package/src/runtime.ts +17 -4
- package/src/ssr-barrel-imports.test.ts +70 -0
- package/src/ssr-barrel-imports.ts +104 -0
- package/src/ssr-bucket-serverdata.test.ts +167 -0
- package/src/ssr-client-bundler.test.ts +223 -0
- package/src/ssr-client-bundler.ts +424 -63
- package/src/ssr-client-nav-runtime.fixture.ts +273 -0
- package/src/ssr-client-nav-runtime.test.ts +79 -0
- package/src/ssr-client-nav.test.ts +78 -0
- package/src/ssr-client-nav.ts +70 -0
- package/src/ssr-offline-shell.test.ts +253 -0
- package/src/ssr-offline.ts +311 -0
- package/src/ssr-precompress.test.ts +49 -0
- package/src/ssr-precompress.ts +67 -0
- package/src/ssr-route-assets.ts +68 -0
- package/src/ssr-runtime.ts +159 -28
- package/src/ssr-serverdata-fn.test.ts +2 -2
- package/src/types.ts +50 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The client build files one page needs, from the bundle manifest.
|
|
2
|
+
//
|
|
3
|
+
// One list, two readers: the SSR render emits a tag for each file (stylesheet
|
|
4
|
+
// links, modulepreloads, the polyfill and entry scripts, font preloads), and
|
|
5
|
+
// the offline service worker precaches the same files for each offline route.
|
|
6
|
+
// Computing them in one place keeps the two from drifting: a file the page
|
|
7
|
+
// loads but the worker did not keep would break the page with no network.
|
|
8
|
+
|
|
9
|
+
/** The bundle-manifest fields this module reads. */
|
|
10
|
+
export interface RouteAssetManifest {
|
|
11
|
+
public_prefix?: string;
|
|
12
|
+
polyfills?: string;
|
|
13
|
+
routes: Record<
|
|
14
|
+
string,
|
|
15
|
+
{ file: string; imports?: string[]; css?: string[]; path?: string } | undefined
|
|
16
|
+
>;
|
|
17
|
+
fonts?: { preload?: string[] } | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Outdir-relative files for one route, in the order the page loads them. */
|
|
21
|
+
export interface RouteAssetFiles {
|
|
22
|
+
/** `<link rel="stylesheet">` (or inlined `<style>`) in the head. */
|
|
23
|
+
styles: string[];
|
|
24
|
+
/** `<link rel="modulepreload">` in the head. */
|
|
25
|
+
preloads: string[];
|
|
26
|
+
/** `<link rel="preload" as="font">` in the head. */
|
|
27
|
+
fonts: string[];
|
|
28
|
+
/** `<script type="module">` in the body tail: the polyfills, then the
|
|
29
|
+
* route entry. */
|
|
30
|
+
scripts: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The scripts the hydration tail loads for a route entry. */
|
|
34
|
+
export function routeScriptFiles(
|
|
35
|
+
route: { file: string },
|
|
36
|
+
polyfills: string | undefined,
|
|
37
|
+
): string[] {
|
|
38
|
+
return polyfills ? [polyfills, route.file] : [route.file];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Every client-build file the page for `component` loads, or null when the
|
|
42
|
+
* build has no entry for it. */
|
|
43
|
+
export function routeAssetFiles(
|
|
44
|
+
manifest: RouteAssetManifest,
|
|
45
|
+
component: string,
|
|
46
|
+
): RouteAssetFiles | null {
|
|
47
|
+
const route = manifest.routes[component];
|
|
48
|
+
if (!route) return null;
|
|
49
|
+
return {
|
|
50
|
+
styles: route.css ?? [],
|
|
51
|
+
preloads: route.imports ?? [],
|
|
52
|
+
fonts: manifest.fonts?.preload ?? [],
|
|
53
|
+
scripts: routeScriptFiles(route, manifest.polyfills),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** All of a route's files as URLs under the build's public prefix. */
|
|
58
|
+
export function routeAssetUrls(
|
|
59
|
+
manifest: RouteAssetManifest,
|
|
60
|
+
component: string,
|
|
61
|
+
): string[] {
|
|
62
|
+
const files = routeAssetFiles(manifest, component);
|
|
63
|
+
if (!files) return [];
|
|
64
|
+
const prefix = manifest.public_prefix || "/_pylon/build/";
|
|
65
|
+
return [...files.styles, ...files.preloads, ...files.fonts, ...files.scripts].map(
|
|
66
|
+
(f) => prefix + f,
|
|
67
|
+
);
|
|
68
|
+
}
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
import { withMetaSlot } from "./ssr-page-tree";
|
|
11
11
|
import { moduleKey, serverBundle } from "./server-bundle";
|
|
12
|
+
import {
|
|
13
|
+
routeAssetFiles,
|
|
14
|
+
routeScriptFiles,
|
|
15
|
+
type RouteAssetFiles,
|
|
16
|
+
} from "./ssr-route-assets";
|
|
12
17
|
|
|
13
18
|
// Bun runtime global. This module runs under Bun but is type-checked by
|
|
14
19
|
// consuming apps under node/DOM (no `Bun` global) — declare the surface used.
|
|
@@ -2126,6 +2131,7 @@ async function collectBoundaryHeadBlob(): Promise<string> {
|
|
|
2126
2131
|
// Fonts first: the @font-face faces + preloads must be discoverable before
|
|
2127
2132
|
// the app stylesheet that references the font family.
|
|
2128
2133
|
let blob = await buildFontHeadBlob();
|
|
2134
|
+
blob += await buildOfflineHeadBlob();
|
|
2129
2135
|
for (const route of Object.values(manifest.routes || {}) as any[]) {
|
|
2130
2136
|
for (const css of (route.css || []) as string[]) {
|
|
2131
2137
|
if (seen.has(css)) continue;
|
|
@@ -2167,6 +2173,20 @@ async function buildFontHeadBlob(): Promise<string> {
|
|
|
2167
2173
|
}
|
|
2168
2174
|
}
|
|
2169
2175
|
|
|
2176
|
+
/** The install tags (web app manifest link, theme color, home-screen icon)
|
|
2177
|
+
* for an app that opted into `buildManifest({ offline })`; "" otherwise. */
|
|
2178
|
+
async function buildOfflineHeadBlob(): Promise<string> {
|
|
2179
|
+
try {
|
|
2180
|
+
const { getManifest } = await import("./ssr-client-bundler");
|
|
2181
|
+
const manifest = await getManifest();
|
|
2182
|
+
if (!manifest.offline) return "";
|
|
2183
|
+
const { offlineHeadTags } = await import("./ssr-offline");
|
|
2184
|
+
return offlineHeadTags(manifest.offline);
|
|
2185
|
+
} catch {
|
|
2186
|
+
return "";
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2170
2190
|
/**
|
|
2171
2191
|
* Build the hydration tail appended after React's stream EOFs: the
|
|
2172
2192
|
* `__PYLON_DATA__` JSON blob (props + ssrData) + the per-route entry
|
|
@@ -2201,6 +2221,10 @@ export function buildHydrationTail(args: {
|
|
|
2201
2221
|
// user's identity to another (the #277 leak class, at the body level). The
|
|
2202
2222
|
// raw `auth` is replaced AFTER the live-handle strip below.
|
|
2203
2223
|
bucketAuth?: { signedIn: boolean };
|
|
2224
|
+
/** The app opted into `buildManifest({ offline })`: the client registers
|
|
2225
|
+
* the service worker (production only), and `useAppUpdate` compares this
|
|
2226
|
+
* build's version with `/_pylon/version`. */
|
|
2227
|
+
offline?: { version: string; builtAt?: string };
|
|
2204
2228
|
/** Client-side NAVIGATION payload: emit the `__PYLON_DATA__` blob and
|
|
2205
2229
|
* nothing else. The client already has the runtime and resolves the route
|
|
2206
2230
|
* entry from the build manifest, so the entry `<script>`, the preload tags
|
|
@@ -2265,6 +2289,14 @@ export function buildHydrationTail(args: {
|
|
|
2265
2289
|
const appName =
|
|
2266
2290
|
typeof process !== "undefined" ? process.env.PYLON_APP_NAME : undefined;
|
|
2267
2291
|
if (appName) hydrationPayload.app = appName;
|
|
2292
|
+
// App-global, identity-free: safe in a shared or offline tail.
|
|
2293
|
+
if (args.offline) {
|
|
2294
|
+
hydrationPayload.offline = {
|
|
2295
|
+
dev: isDevMode(),
|
|
2296
|
+
version: args.offline.version,
|
|
2297
|
+
builtAt: args.offline.builtAt ?? null,
|
|
2298
|
+
};
|
|
2299
|
+
}
|
|
2268
2300
|
if (args.kind) hydrationPayload.kind = args.kind;
|
|
2269
2301
|
const json = escapeScriptJson(JSON.stringify(hydrationPayload));
|
|
2270
2302
|
let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
|
|
@@ -2277,10 +2309,9 @@ export function buildHydrationTail(args: {
|
|
|
2277
2309
|
const co = /^https?:\/\//i.test(args.publicPrefix) ? " crossorigin" : "";
|
|
2278
2310
|
// Module scripts run in document order, so the polyfills are in place
|
|
2279
2311
|
// before the entry (and the shared chunk it imports) runs.
|
|
2280
|
-
|
|
2281
|
-
tail += `<script type="module"${co} src="${args.publicPrefix}${
|
|
2312
|
+
for (const script of routeScriptFiles(args.manifestRoute, args.polyfills)) {
|
|
2313
|
+
tail += `<script type="module"${co} src="${args.publicPrefix}${script}"></script>`;
|
|
2282
2314
|
}
|
|
2283
|
-
tail += `<script type="module"${co} src="${args.publicPrefix}${args.manifestRoute.file}"></script>`;
|
|
2284
2315
|
} else {
|
|
2285
2316
|
// Executable script → the serialized message MUST be script-escaped
|
|
2286
2317
|
// (`manifestErr` is server-derived today, but this is the one script sink
|
|
@@ -2651,16 +2682,23 @@ export function makeServerData(
|
|
|
2651
2682
|
// executes it exactly like ctx.runQuery). Lets a page SSR data whose safe
|
|
2652
2683
|
// public projection lives in a gated query instead of open row policies.
|
|
2653
2684
|
callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>,
|
|
2654
|
-
// Fired on every
|
|
2655
|
-
//
|
|
2656
|
-
//
|
|
2657
|
-
|
|
2685
|
+
// Fired on every call whose result can depend on the caller: serverData.fn
|
|
2686
|
+
// (the fn may read ctx.auth) and every policy-filtered read (the host applies
|
|
2687
|
+
// the caller's read policies to `ssrRead` reads). The render vetoes shared
|
|
2688
|
+
// caching when it carried a real identity. `serverData.unsafe.*` skips
|
|
2689
|
+
// policies, so its rows are the same for every caller and it does not fire.
|
|
2690
|
+
onIdentityRead?: () => void,
|
|
2691
|
+
// Fired on every serverData read (fn calls included), so an offline-shell
|
|
2692
|
+
// render can refuse a page that needs the server to render.
|
|
2693
|
+
onRead?: () => void,
|
|
2658
2694
|
): any {
|
|
2659
2695
|
const promiseCache = new Map<string, Promise<any>>();
|
|
2660
|
-
const wrap = (r: any, prefix: string): any => {
|
|
2696
|
+
const wrap = (r: any, prefix: string, callerFiltered: boolean): any => {
|
|
2661
2697
|
const out: any = {};
|
|
2662
2698
|
for (const m of SERVER_DATA_METHODS) {
|
|
2663
2699
|
out[m] = (...args: any[]) => {
|
|
2700
|
+
onRead?.();
|
|
2701
|
+
if (callerFiltered) onIdentityRead?.();
|
|
2664
2702
|
const key = prefix + m + ":" + stableStringify(args);
|
|
2665
2703
|
let p = promiseCache.get(key);
|
|
2666
2704
|
if (!p) {
|
|
@@ -2675,16 +2713,17 @@ export function makeServerData(
|
|
|
2675
2713
|
}
|
|
2676
2714
|
return out;
|
|
2677
2715
|
};
|
|
2678
|
-
const sd = wrap(reader, "");
|
|
2679
|
-
if (reader.unsafe) sd.unsafe = wrap(reader.unsafe, "u:");
|
|
2716
|
+
const sd = wrap(reader, "", true);
|
|
2717
|
+
if (reader.unsafe) sd.unsafe = wrap(reader.unsafe, "u:", false);
|
|
2680
2718
|
if (callFn) {
|
|
2681
2719
|
sd.fn = (name: string, args?: Record<string, unknown>) => {
|
|
2720
|
+
onRead?.();
|
|
2682
2721
|
// Key MUST match the client shim's `fn` key so hydration replays
|
|
2683
2722
|
// the value from ssrData without a mismatch.
|
|
2684
2723
|
const key = "fn:" + name + ":" + stableStringify(args ?? {});
|
|
2685
2724
|
let p = promiseCache.get(key);
|
|
2686
2725
|
if (!p) {
|
|
2687
|
-
|
|
2726
|
+
onIdentityRead?.();
|
|
2688
2727
|
p = callFn(name, args).then((value: any) => {
|
|
2689
2728
|
valueCache[key] = value;
|
|
2690
2729
|
return value;
|
|
@@ -2776,11 +2815,11 @@ export function computeCacheVerdict(args: {
|
|
|
2776
2815
|
* `authTouched` proves a bucketable body depends on nothing but the binary
|
|
2777
2816
|
* session bit.
|
|
2778
2817
|
* - `dynamicTouched` — reading request headers/cookies makes it request-specific.
|
|
2779
|
-
* - `strictPolicies` —
|
|
2780
|
-
*
|
|
2781
|
-
*
|
|
2782
|
-
*
|
|
2783
|
-
*
|
|
2818
|
+
* - `strictPolicies` — kept as a veto. Safe serverData reads are filtered by
|
|
2819
|
+
* the caller's read policies in EVERY mode (not only strict), so an
|
|
2820
|
+
* identity-carrying render that reads serverData sets `authTouched` itself
|
|
2821
|
+
* (makeServerData's onIdentityRead); that, not this flag, is what keeps a
|
|
2822
|
+
* signed-in render with policy-filtered rows out of the bucket.
|
|
2784
2823
|
* - cookieCount / forceDynamic / wantsStream / non-200 — same reasons as the
|
|
2785
2824
|
* anon verdict (a Set-Cookie, a streaming head, or an error/redirect can't be
|
|
2786
2825
|
* safely shared).
|
|
@@ -2813,6 +2852,50 @@ export function computeBucketVerdict(args: {
|
|
|
2813
2852
|
);
|
|
2814
2853
|
}
|
|
2815
2854
|
|
|
2855
|
+
/** Whether the host forwarded the service worker's offline-shell request
|
|
2856
|
+
* header (`X-Pylon-Offline-Shell: 1`). */
|
|
2857
|
+
export function isOfflineShellRequest(
|
|
2858
|
+
headers: Record<string, unknown> | undefined,
|
|
2859
|
+
): boolean {
|
|
2860
|
+
const v = headers?.["x-pylon-offline-shell"];
|
|
2861
|
+
return typeof v === "string" && v.trim() === "1";
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
/**
|
|
2865
|
+
* May the offline service worker keep this render as the route's shell? The
|
|
2866
|
+
* shell is stored on the device and shown, with no network, to whoever is
|
|
2867
|
+
* signed in at the time, so its HTML must not depend on who rendered it:
|
|
2868
|
+
*
|
|
2869
|
+
* - `authTouched`: reading `props.auth` (or a `serverData.fn` call with an
|
|
2870
|
+
* identity) makes the HTML one user's. Read `props.session.exists` for a
|
|
2871
|
+
* signed-in check; the shell keeps that bit and nothing else.
|
|
2872
|
+
* - `dynamicTouched`: request headers and cookies are one request's.
|
|
2873
|
+
* - `serverDataTouched`: offline there is no server; an offline route
|
|
2874
|
+
* renders its data from the replica with `db.useQuery`.
|
|
2875
|
+
* - a Set-Cookie, or any status but 200 (a redirect to sign-in, an error).
|
|
2876
|
+
*
|
|
2877
|
+
* Pure and exported for the leak-class tests.
|
|
2878
|
+
*/
|
|
2879
|
+
export function computeOfflineShellVerdict(args: {
|
|
2880
|
+
authTouched: boolean;
|
|
2881
|
+
dynamicTouched: boolean;
|
|
2882
|
+
serverDataTouched: boolean;
|
|
2883
|
+
cookieCount: number;
|
|
2884
|
+
status: number;
|
|
2885
|
+
}): { ok: true } | { ok: false; reason: string } {
|
|
2886
|
+
if (args.status !== 200) return { ok: false, reason: `status ${args.status}` };
|
|
2887
|
+
// serverData before auth: a signed-in serverData read also sets authTouched
|
|
2888
|
+
// (it returns the caller's rows), and "read serverData" is the actionable
|
|
2889
|
+
// reason.
|
|
2890
|
+
if (args.serverDataTouched) return { ok: false, reason: "the page read serverData" };
|
|
2891
|
+
if (args.authTouched) return { ok: false, reason: "the page read props.auth" };
|
|
2892
|
+
if (args.dynamicTouched) {
|
|
2893
|
+
return { ok: false, reason: "the page read request headers or cookies" };
|
|
2894
|
+
}
|
|
2895
|
+
if (args.cookieCount > 0) return { ok: false, reason: "the render set a cookie" };
|
|
2896
|
+
return { ok: true };
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2816
2899
|
/**
|
|
2817
2900
|
* Dev HUD: explain the cache verdict for a finished render in human terms — the
|
|
2818
2901
|
* verdict, its TTL, and (when dynamic) the SINGLE reason it isn't cached. Pure so
|
|
@@ -3365,6 +3448,9 @@ export async function handleRenderRoute(
|
|
|
3365
3448
|
// caching, because the output could differ by identity. Declared before
|
|
3366
3449
|
// serverData so `serverData.fn` can flip it too (below).
|
|
3367
3450
|
let authTouched = false;
|
|
3451
|
+
// Whether the render read `serverData` at all. An offline shell may not:
|
|
3452
|
+
// offline there is no server, so the page must render from the replica.
|
|
3453
|
+
let serverDataTouched = false;
|
|
3368
3454
|
// `ssrRead: true` — serverData results are serialized into the
|
|
3369
3455
|
// client-visible `__PYLON_DATA__` blob, so the host applies the same
|
|
3370
3456
|
// per-row policy filter + `server_only`/`passwordHash` projection the
|
|
@@ -3373,18 +3459,22 @@ export async function handleRenderRoute(
|
|
|
3373
3459
|
buildDbReader(msg.call_id, true),
|
|
3374
3460
|
ssrValueCache,
|
|
3375
3461
|
buildSsrFnCaller(msg.call_id),
|
|
3376
|
-
// A query function
|
|
3377
|
-
//
|
|
3378
|
-
// can't see. An identity-carrying render that
|
|
3379
|
-
//
|
|
3380
|
-
//
|
|
3381
|
-
// across every anonymous
|
|
3462
|
+
// A query function may read ctx.auth, and a safe read returns only the
|
|
3463
|
+
// rows the caller's read policies allow: identity-specific output the
|
|
3464
|
+
// read-tracking proxies can't see. An identity-carrying render that
|
|
3465
|
+
// called serverData.fn or made a safe read must therefore veto shared
|
|
3466
|
+
// caches exactly as if it read props.auth. Anonymous renders stay
|
|
3467
|
+
// cacheable: the anonymous result is uniform across every anonymous
|
|
3468
|
+
// visitor.
|
|
3382
3469
|
() => {
|
|
3383
3470
|
const a = msg.auth as Record<string, unknown> | undefined;
|
|
3384
3471
|
if (a && (a.user_id || a.is_admin || a.tenant_id)) {
|
|
3385
3472
|
authTouched = true;
|
|
3386
3473
|
}
|
|
3387
3474
|
},
|
|
3475
|
+
() => {
|
|
3476
|
+
serverDataTouched = true;
|
|
3477
|
+
},
|
|
3388
3478
|
);
|
|
3389
3479
|
|
|
3390
3480
|
// #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
|
|
@@ -3468,7 +3558,11 @@ export async function handleRenderRoute(
|
|
|
3468
3558
|
// a shallow allowlist would copy that mutation by reference into the shared
|
|
3469
3559
|
// tail. Captured only for opted-in pages (zero cost otherwise).
|
|
3470
3560
|
const bucketOptIn = (mod as any).cache === "auth-bucketed";
|
|
3471
|
-
|
|
3561
|
+
// The offline service worker asked for this route's HTML to keep (see
|
|
3562
|
+
// `computeOfflineShellVerdict`). Read off the raw headers: the framework
|
|
3563
|
+
// reading it is not the page reading a request input.
|
|
3564
|
+
const offlineShell = isOfflineShellRequest(msg.headers as Record<string, unknown>);
|
|
3565
|
+
const bucketTailBase = bucketOptIn || offlineShell
|
|
3472
3566
|
? {
|
|
3473
3567
|
pathname: msg.url,
|
|
3474
3568
|
url: msg.url,
|
|
@@ -3556,7 +3650,10 @@ export async function handleRenderRoute(
|
|
|
3556
3650
|
// with `export const streaming = true` (inner-boundary streaming, Stage 2).
|
|
3557
3651
|
// Every un-annotated page keeps the byte-identical BUFFERED path (allReady)
|
|
3558
3652
|
// that 100% of today's prod traffic rides — this is opt-in, never default.
|
|
3559
|
-
|
|
3653
|
+
// An offline shell is always buffered: its verdict reads `authTouched`
|
|
3654
|
+
// and friends, which a streaming render has not finished setting when its
|
|
3655
|
+
// head goes out.
|
|
3656
|
+
const wantsStream = !offlineShell && computeWantsStream(!!Loading, mod);
|
|
3560
3657
|
|
|
3561
3658
|
// Resolve the layout chain. Each layout module exports a default
|
|
3562
3659
|
// function that accepts the same props + `children`. Walk leaf →
|
|
@@ -3705,6 +3802,16 @@ export async function handleRenderRoute(
|
|
|
3705
3802
|
wantsStream,
|
|
3706
3803
|
status: responseState.status,
|
|
3707
3804
|
});
|
|
3805
|
+
const shellVerdict = offlineShell
|
|
3806
|
+
? computeOfflineShellVerdict({
|
|
3807
|
+
authTouched,
|
|
3808
|
+
dynamicTouched,
|
|
3809
|
+
serverDataTouched,
|
|
3810
|
+
cookieCount: responseState.cookies.length,
|
|
3811
|
+
status: responseState.status,
|
|
3812
|
+
})
|
|
3813
|
+
: null;
|
|
3814
|
+
const shellOk = shellVerdict?.ok === true;
|
|
3708
3815
|
// Dev diagnostics (the agent + HUD signal): the cache verdict + the single
|
|
3709
3816
|
// actionable reason, computed ONCE here and reused for the dev HUD blob, the
|
|
3710
3817
|
// `x-pylon-dev` header (→ the host's diagnostics ring + `pylon diagnostics`),
|
|
@@ -3743,7 +3850,7 @@ export async function handleRenderRoute(
|
|
|
3743
3850
|
headers: msg.headers,
|
|
3744
3851
|
cookies: msg.cookies,
|
|
3745
3852
|
session: { exists: msg.session_present === true },
|
|
3746
|
-
...(bucketable && bucketTailBase ? bucketTailBase : {}),
|
|
3853
|
+
...((bucketable || shellOk) && bucketTailBase ? bucketTailBase : {}),
|
|
3747
3854
|
}
|
|
3748
3855
|
: props;
|
|
3749
3856
|
// #278: on a STREAMING render the head commits NOW, before suspended
|
|
@@ -3781,6 +3888,15 @@ export async function handleRenderRoute(
|
|
|
3781
3888
|
// 406, and the `<path>.md` URL 404s. Trusted channel, stripped by the
|
|
3782
3889
|
// host, so a page can't forge the inverse and force a conversion.
|
|
3783
3890
|
...((mod as any).markdown === false ? { "x-pylon-md": "0" } : {}),
|
|
3891
|
+
// Offline shell verdict. The host re-emits it as `Pylon-Offline-Shell`
|
|
3892
|
+
// for the service worker, which keeps the HTML only on "1".
|
|
3893
|
+
...(shellVerdict
|
|
3894
|
+
? {
|
|
3895
|
+
"x-pylon-offline-shell": shellVerdict.ok
|
|
3896
|
+
? "1"
|
|
3897
|
+
: `refused; ${shellVerdict.reason}`,
|
|
3898
|
+
}
|
|
3899
|
+
: {}),
|
|
3784
3900
|
// Dev-only: the host parses this into its diagnostics ring (served at
|
|
3785
3901
|
// /_pylon/dev/diagnostics + `pylon diagnostics`). Single-line JSON, no
|
|
3786
3902
|
// newlines. Stripped before the client like every x-pylon-* header.
|
|
@@ -3814,12 +3930,23 @@ export async function handleRenderRoute(
|
|
|
3814
3930
|
let preloadManifestErr: string | null = null;
|
|
3815
3931
|
let preloadPublicPrefix = "/_pylon/build/";
|
|
3816
3932
|
let preloadPolyfills: string | undefined;
|
|
3933
|
+
// The same file list the offline service worker precaches for this route.
|
|
3934
|
+
let preloadAssets: RouteAssetFiles | null = null;
|
|
3935
|
+
// The build's version when the app opted into `buildManifest({ offline })`.
|
|
3936
|
+
let offlineApp: { version: string; builtAt?: string } | undefined;
|
|
3817
3937
|
try {
|
|
3818
3938
|
const { getManifest } = await import("./ssr-client-bundler");
|
|
3819
3939
|
const manifest = await getManifest();
|
|
3820
3940
|
preloadPublicPrefix = manifest.public_prefix || preloadPublicPrefix;
|
|
3821
3941
|
preloadManifestRoute = manifest.routes[msg.component] ?? null;
|
|
3822
3942
|
preloadPolyfills = manifest.polyfills;
|
|
3943
|
+
preloadAssets = routeAssetFiles(manifest, msg.component);
|
|
3944
|
+
if (manifest.offline) {
|
|
3945
|
+
offlineApp = {
|
|
3946
|
+
version: manifest.offline.version,
|
|
3947
|
+
builtAt: manifest.offline.builtAt,
|
|
3948
|
+
};
|
|
3949
|
+
}
|
|
3823
3950
|
if (!preloadManifestRoute) {
|
|
3824
3951
|
preloadManifestErr = `manifest has no entry for "${msg.component}"`;
|
|
3825
3952
|
}
|
|
@@ -3839,13 +3966,14 @@ export async function handleRenderRoute(
|
|
|
3839
3966
|
);
|
|
3840
3967
|
} else if (preloadManifestRoute) {
|
|
3841
3968
|
headBlob += await buildFontHeadBlob();
|
|
3969
|
+
headBlob += await buildOfflineHeadBlob();
|
|
3842
3970
|
const co = /^https?:\/\//i.test(preloadPublicPrefix) ? " crossorigin" : "";
|
|
3843
3971
|
const inlineOverride =
|
|
3844
3972
|
typeof mod?.inlineCss === "boolean" ? mod.inlineCss : undefined;
|
|
3845
|
-
for (const css of
|
|
3973
|
+
for (const css of preloadAssets?.styles ?? []) {
|
|
3846
3974
|
headBlob += await cssHeadTag(css, preloadPublicPrefix, inlineOverride);
|
|
3847
3975
|
}
|
|
3848
|
-
for (const chunk of
|
|
3976
|
+
for (const chunk of preloadAssets?.preloads ?? []) {
|
|
3849
3977
|
headBlob += `<link rel="modulepreload"${co} href="${preloadPublicPrefix}${chunk}">`;
|
|
3850
3978
|
}
|
|
3851
3979
|
} else {
|
|
@@ -3991,9 +4119,12 @@ export async function handleRenderRoute(
|
|
|
3991
4119
|
: undefined,
|
|
3992
4120
|
// A bucketed render is stored shared → its tail must carry ONLY the
|
|
3993
4121
|
// binary signed-in bit, never this request's real identity.
|
|
3994
|
-
|
|
4122
|
+
// An offline shell is kept on the device and replayed to whoever is
|
|
4123
|
+
// signed in later, so it gets the same identity-free tail.
|
|
4124
|
+
bucketAuth: bucketable || shellOk
|
|
3995
4125
|
? { signedIn: msg.session_present === true }
|
|
3996
4126
|
: undefined,
|
|
4127
|
+
offline: offlineApp,
|
|
3997
4128
|
dataOnly: navRender,
|
|
3998
4129
|
});
|
|
3999
4130
|
sendChunk(tail);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - promise caching per (name, args) so React 19 `use()` gets the SAME
|
|
9
9
|
* promise on the post-suspense re-render;
|
|
10
10
|
* - resolved values recorded into the valueCache (the ssrData blob);
|
|
11
|
-
* - the
|
|
11
|
+
* - the onIdentityRead notification that lets the render veto shared caching for
|
|
12
12
|
* identity-carrying requests;
|
|
13
13
|
* - the client runtime template carrying the matching `fn` shim.
|
|
14
14
|
*/
|
|
@@ -47,7 +47,7 @@ describe("makeServerData.fn", () => {
|
|
|
47
47
|
]);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
test("
|
|
50
|
+
test("onIdentityRead fires once per unique fn call (the cache-veto hook)", async () => {
|
|
51
51
|
let touches = 0;
|
|
52
52
|
const sd = makeServerData(
|
|
53
53
|
noopReader,
|
package/src/types.ts
CHANGED
|
@@ -832,7 +832,21 @@ export type LlmContentBlock =
|
|
|
832
832
|
tool_use_id: string;
|
|
833
833
|
content: string;
|
|
834
834
|
is_error?: boolean;
|
|
835
|
-
}
|
|
835
|
+
}
|
|
836
|
+
/** An image the model reads: png, jpeg, gif or webp, as base64. */
|
|
837
|
+
| { type: "image"; source: LlmMediaSource }
|
|
838
|
+
/**
|
|
839
|
+
* A document the model reads: a PDF or plain text, as base64. Anthropic
|
|
840
|
+
* only: the OpenAI provider refuses it (LLM_UNSUPPORTED_CONTENT).
|
|
841
|
+
*/
|
|
842
|
+
| { type: "document"; source: LlmMediaSource; title?: string };
|
|
843
|
+
|
|
844
|
+
/** The bytes of an image or document block. */
|
|
845
|
+
export interface LlmMediaSource {
|
|
846
|
+
type: "base64";
|
|
847
|
+
media_type: string;
|
|
848
|
+
data: string;
|
|
849
|
+
}
|
|
836
850
|
|
|
837
851
|
export interface LlmTool {
|
|
838
852
|
name: string;
|
|
@@ -1075,6 +1089,35 @@ export interface Files {
|
|
|
1075
1089
|
* so keep lifetimes short.
|
|
1076
1090
|
*/
|
|
1077
1091
|
signedUrl(fileId: string, opts?: { ttlSecs?: number }): Promise<string>;
|
|
1092
|
+
/**
|
|
1093
|
+
* What the host knows of a stored file, in any function. On a backend
|
|
1094
|
+
* that tracks owners (local disk, a private S3 bucket) `ownerId` is who
|
|
1095
|
+
* uploaded or stored it, and a file that does not exist is null. A
|
|
1096
|
+
* backend that does not (`ownerTracked: false`, the Stack0 CDN) knows no
|
|
1097
|
+
* owner: there a file id is a capability, so check who may use it
|
|
1098
|
+
* yourself, for example by recording uploads in your own rows.
|
|
1099
|
+
*/
|
|
1100
|
+
info(fileId: string): Promise<FileInfo | null>;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** {@link Files.info}. */
|
|
1104
|
+
export interface FileInfo {
|
|
1105
|
+
id: string;
|
|
1106
|
+
/** Recorded at upload, when the backend keeps it. */
|
|
1107
|
+
contentType: string | null;
|
|
1108
|
+
/** The uploader, or null when the backend does not track it or the system stored it. */
|
|
1109
|
+
ownerId: string | null;
|
|
1110
|
+
public: boolean;
|
|
1111
|
+
/** The backend records owners, so `ownerId` and a null answer can be trusted. */
|
|
1112
|
+
ownerTracked: boolean;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** {@link ActionFiles.read}. */
|
|
1116
|
+
export interface ReadFile {
|
|
1117
|
+
id: string;
|
|
1118
|
+
contentType: string | null;
|
|
1119
|
+
size: number;
|
|
1120
|
+
data: Uint8Array;
|
|
1078
1121
|
}
|
|
1079
1122
|
|
|
1080
1123
|
/** Options for {@link ActionFiles.store}. */
|
|
@@ -1117,6 +1160,12 @@ export interface ActionFiles extends Files {
|
|
|
1117
1160
|
): Promise<StoredFile>;
|
|
1118
1161
|
/** Delete a stored file. `deleted` is false when it did not exist. */
|
|
1119
1162
|
delete(fileId: string): Promise<{ deleted: boolean }>;
|
|
1163
|
+
/**
|
|
1164
|
+
* A stored file's bytes, with server trust: the function decides who may
|
|
1165
|
+
* read it. At most the host's upload limit (25 MiB, or
|
|
1166
|
+
* `PYLON_MAX_UPLOAD_BYTES`).
|
|
1167
|
+
*/
|
|
1168
|
+
read(fileId: string): Promise<ReadFile>;
|
|
1120
1169
|
}
|
|
1121
1170
|
|
|
1122
1171
|
/**
|