@pylonsync/functions 0.22.46 → 0.22.50
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/offline-sw.d.ts +46 -0
- package/dist/ssr-client-bundler.d.ts +6 -0
- package/dist/ssr-offline.d.ts +64 -0
- package/dist/ssr-route-assets.d.ts +35 -0
- package/dist/ssr-runtime.d.ts +34 -1
- package/package.json +1 -1
- package/src/offline-sw.test.ts +357 -0
- package/src/offline-sw.ts +330 -0
- package/src/ssr-client-bundler.test.ts +63 -0
- package/src/ssr-client-bundler.ts +120 -1
- package/src/ssr-offline-shell.test.ts +248 -0
- package/src/ssr-offline.ts +205 -0
- package/src/ssr-route-assets.ts +68 -0
- package/src/ssr-runtime.ts +121 -9
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,9 @@ 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). */
|
|
2226
|
+
offline?: boolean;
|
|
2204
2227
|
/** Client-side NAVIGATION payload: emit the `__PYLON_DATA__` blob and
|
|
2205
2228
|
* nothing else. The client already has the runtime and resolves the route
|
|
2206
2229
|
* entry from the build manifest, so the entry `<script>`, the preload tags
|
|
@@ -2265,6 +2288,8 @@ export function buildHydrationTail(args: {
|
|
|
2265
2288
|
const appName =
|
|
2266
2289
|
typeof process !== "undefined" ? process.env.PYLON_APP_NAME : undefined;
|
|
2267
2290
|
if (appName) hydrationPayload.app = appName;
|
|
2291
|
+
// App-global, identity-free: safe in a shared or offline tail.
|
|
2292
|
+
if (args.offline) hydrationPayload.offline = { dev: isDevMode() };
|
|
2268
2293
|
if (args.kind) hydrationPayload.kind = args.kind;
|
|
2269
2294
|
const json = escapeScriptJson(JSON.stringify(hydrationPayload));
|
|
2270
2295
|
let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
|
|
@@ -2277,10 +2302,9 @@ export function buildHydrationTail(args: {
|
|
|
2277
2302
|
const co = /^https?:\/\//i.test(args.publicPrefix) ? " crossorigin" : "";
|
|
2278
2303
|
// Module scripts run in document order, so the polyfills are in place
|
|
2279
2304
|
// before the entry (and the shared chunk it imports) runs.
|
|
2280
|
-
|
|
2281
|
-
tail += `<script type="module"${co} src="${args.publicPrefix}${
|
|
2305
|
+
for (const script of routeScriptFiles(args.manifestRoute, args.polyfills)) {
|
|
2306
|
+
tail += `<script type="module"${co} src="${args.publicPrefix}${script}"></script>`;
|
|
2282
2307
|
}
|
|
2283
|
-
tail += `<script type="module"${co} src="${args.publicPrefix}${args.manifestRoute.file}"></script>`;
|
|
2284
2308
|
} else {
|
|
2285
2309
|
// Executable script → the serialized message MUST be script-escaped
|
|
2286
2310
|
// (`manifestErr` is server-derived today, but this is the one script sink
|
|
@@ -2655,12 +2679,16 @@ export function makeServerData(
|
|
|
2655
2679
|
// when the call carried a real identity (the fn may read ctx.auth, making
|
|
2656
2680
|
// its output identity-specific without the page ever touching props.auth).
|
|
2657
2681
|
onFnCall?: () => void,
|
|
2682
|
+
// Fired on every serverData read (fn calls included), so an offline-shell
|
|
2683
|
+
// render can refuse a page that needs the server to render.
|
|
2684
|
+
onRead?: () => void,
|
|
2658
2685
|
): any {
|
|
2659
2686
|
const promiseCache = new Map<string, Promise<any>>();
|
|
2660
2687
|
const wrap = (r: any, prefix: string): any => {
|
|
2661
2688
|
const out: any = {};
|
|
2662
2689
|
for (const m of SERVER_DATA_METHODS) {
|
|
2663
2690
|
out[m] = (...args: any[]) => {
|
|
2691
|
+
onRead?.();
|
|
2664
2692
|
const key = prefix + m + ":" + stableStringify(args);
|
|
2665
2693
|
let p = promiseCache.get(key);
|
|
2666
2694
|
if (!p) {
|
|
@@ -2679,6 +2707,7 @@ export function makeServerData(
|
|
|
2679
2707
|
if (reader.unsafe) sd.unsafe = wrap(reader.unsafe, "u:");
|
|
2680
2708
|
if (callFn) {
|
|
2681
2709
|
sd.fn = (name: string, args?: Record<string, unknown>) => {
|
|
2710
|
+
onRead?.();
|
|
2682
2711
|
// Key MUST match the client shim's `fn` key so hydration replays
|
|
2683
2712
|
// the value from ssrData without a mismatch.
|
|
2684
2713
|
const key = "fn:" + name + ":" + stableStringify(args ?? {});
|
|
@@ -2813,6 +2842,47 @@ export function computeBucketVerdict(args: {
|
|
|
2813
2842
|
);
|
|
2814
2843
|
}
|
|
2815
2844
|
|
|
2845
|
+
/** Whether the host forwarded the service worker's offline-shell request
|
|
2846
|
+
* header (`X-Pylon-Offline-Shell: 1`). */
|
|
2847
|
+
export function isOfflineShellRequest(
|
|
2848
|
+
headers: Record<string, unknown> | undefined,
|
|
2849
|
+
): boolean {
|
|
2850
|
+
const v = headers?.["x-pylon-offline-shell"];
|
|
2851
|
+
return typeof v === "string" && v.trim() === "1";
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
/**
|
|
2855
|
+
* May the offline service worker keep this render as the route's shell? The
|
|
2856
|
+
* shell is stored on the device and shown, with no network, to whoever is
|
|
2857
|
+
* signed in at the time, so its HTML must not depend on who rendered it:
|
|
2858
|
+
*
|
|
2859
|
+
* - `authTouched`: reading `props.auth` (or a `serverData.fn` call with an
|
|
2860
|
+
* identity) makes the HTML one user's. Read `props.session.exists` for a
|
|
2861
|
+
* signed-in check; the shell keeps that bit and nothing else.
|
|
2862
|
+
* - `dynamicTouched`: request headers and cookies are one request's.
|
|
2863
|
+
* - `serverDataTouched`: offline there is no server; an offline route
|
|
2864
|
+
* renders its data from the replica with `db.useQuery`.
|
|
2865
|
+
* - a Set-Cookie, or any status but 200 (a redirect to sign-in, an error).
|
|
2866
|
+
*
|
|
2867
|
+
* Pure and exported for the leak-class tests.
|
|
2868
|
+
*/
|
|
2869
|
+
export function computeOfflineShellVerdict(args: {
|
|
2870
|
+
authTouched: boolean;
|
|
2871
|
+
dynamicTouched: boolean;
|
|
2872
|
+
serverDataTouched: boolean;
|
|
2873
|
+
cookieCount: number;
|
|
2874
|
+
status: number;
|
|
2875
|
+
}): { ok: true } | { ok: false; reason: string } {
|
|
2876
|
+
if (args.status !== 200) return { ok: false, reason: `status ${args.status}` };
|
|
2877
|
+
if (args.authTouched) return { ok: false, reason: "the page read props.auth" };
|
|
2878
|
+
if (args.serverDataTouched) return { ok: false, reason: "the page read serverData" };
|
|
2879
|
+
if (args.dynamicTouched) {
|
|
2880
|
+
return { ok: false, reason: "the page read request headers or cookies" };
|
|
2881
|
+
}
|
|
2882
|
+
if (args.cookieCount > 0) return { ok: false, reason: "the render set a cookie" };
|
|
2883
|
+
return { ok: true };
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2816
2886
|
/**
|
|
2817
2887
|
* Dev HUD: explain the cache verdict for a finished render in human terms — the
|
|
2818
2888
|
* verdict, its TTL, and (when dynamic) the SINGLE reason it isn't cached. Pure so
|
|
@@ -3365,6 +3435,9 @@ export async function handleRenderRoute(
|
|
|
3365
3435
|
// caching, because the output could differ by identity. Declared before
|
|
3366
3436
|
// serverData so `serverData.fn` can flip it too (below).
|
|
3367
3437
|
let authTouched = false;
|
|
3438
|
+
// Whether the render read `serverData` at all. An offline shell may not:
|
|
3439
|
+
// offline there is no server, so the page must render from the replica.
|
|
3440
|
+
let serverDataTouched = false;
|
|
3368
3441
|
// `ssrRead: true` — serverData results are serialized into the
|
|
3369
3442
|
// client-visible `__PYLON_DATA__` blob, so the host applies the same
|
|
3370
3443
|
// per-row policy filter + `server_only`/`passwordHash` projection the
|
|
@@ -3385,6 +3458,9 @@ export async function handleRenderRoute(
|
|
|
3385
3458
|
authTouched = true;
|
|
3386
3459
|
}
|
|
3387
3460
|
},
|
|
3461
|
+
() => {
|
|
3462
|
+
serverDataTouched = true;
|
|
3463
|
+
},
|
|
3388
3464
|
);
|
|
3389
3465
|
|
|
3390
3466
|
// #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
|
|
@@ -3468,7 +3544,11 @@ export async function handleRenderRoute(
|
|
|
3468
3544
|
// a shallow allowlist would copy that mutation by reference into the shared
|
|
3469
3545
|
// tail. Captured only for opted-in pages (zero cost otherwise).
|
|
3470
3546
|
const bucketOptIn = (mod as any).cache === "auth-bucketed";
|
|
3471
|
-
|
|
3547
|
+
// The offline service worker asked for this route's HTML to keep (see
|
|
3548
|
+
// `computeOfflineShellVerdict`). Read off the raw headers: the framework
|
|
3549
|
+
// reading it is not the page reading a request input.
|
|
3550
|
+
const offlineShell = isOfflineShellRequest(msg.headers as Record<string, unknown>);
|
|
3551
|
+
const bucketTailBase = bucketOptIn || offlineShell
|
|
3472
3552
|
? {
|
|
3473
3553
|
pathname: msg.url,
|
|
3474
3554
|
url: msg.url,
|
|
@@ -3556,7 +3636,10 @@ export async function handleRenderRoute(
|
|
|
3556
3636
|
// with `export const streaming = true` (inner-boundary streaming, Stage 2).
|
|
3557
3637
|
// Every un-annotated page keeps the byte-identical BUFFERED path (allReady)
|
|
3558
3638
|
// that 100% of today's prod traffic rides — this is opt-in, never default.
|
|
3559
|
-
|
|
3639
|
+
// An offline shell is always buffered: its verdict reads `authTouched`
|
|
3640
|
+
// and friends, which a streaming render has not finished setting when its
|
|
3641
|
+
// head goes out.
|
|
3642
|
+
const wantsStream = !offlineShell && computeWantsStream(!!Loading, mod);
|
|
3560
3643
|
|
|
3561
3644
|
// Resolve the layout chain. Each layout module exports a default
|
|
3562
3645
|
// function that accepts the same props + `children`. Walk leaf →
|
|
@@ -3705,6 +3788,16 @@ export async function handleRenderRoute(
|
|
|
3705
3788
|
wantsStream,
|
|
3706
3789
|
status: responseState.status,
|
|
3707
3790
|
});
|
|
3791
|
+
const shellVerdict = offlineShell
|
|
3792
|
+
? computeOfflineShellVerdict({
|
|
3793
|
+
authTouched,
|
|
3794
|
+
dynamicTouched,
|
|
3795
|
+
serverDataTouched,
|
|
3796
|
+
cookieCount: responseState.cookies.length,
|
|
3797
|
+
status: responseState.status,
|
|
3798
|
+
})
|
|
3799
|
+
: null;
|
|
3800
|
+
const shellOk = shellVerdict?.ok === true;
|
|
3708
3801
|
// Dev diagnostics (the agent + HUD signal): the cache verdict + the single
|
|
3709
3802
|
// actionable reason, computed ONCE here and reused for the dev HUD blob, the
|
|
3710
3803
|
// `x-pylon-dev` header (→ the host's diagnostics ring + `pylon diagnostics`),
|
|
@@ -3743,7 +3836,7 @@ export async function handleRenderRoute(
|
|
|
3743
3836
|
headers: msg.headers,
|
|
3744
3837
|
cookies: msg.cookies,
|
|
3745
3838
|
session: { exists: msg.session_present === true },
|
|
3746
|
-
...(bucketable && bucketTailBase ? bucketTailBase : {}),
|
|
3839
|
+
...((bucketable || shellOk) && bucketTailBase ? bucketTailBase : {}),
|
|
3747
3840
|
}
|
|
3748
3841
|
: props;
|
|
3749
3842
|
// #278: on a STREAMING render the head commits NOW, before suspended
|
|
@@ -3781,6 +3874,15 @@ export async function handleRenderRoute(
|
|
|
3781
3874
|
// 406, and the `<path>.md` URL 404s. Trusted channel, stripped by the
|
|
3782
3875
|
// host, so a page can't forge the inverse and force a conversion.
|
|
3783
3876
|
...((mod as any).markdown === false ? { "x-pylon-md": "0" } : {}),
|
|
3877
|
+
// Offline shell verdict. The host re-emits it as `Pylon-Offline-Shell`
|
|
3878
|
+
// for the service worker, which keeps the HTML only on "1".
|
|
3879
|
+
...(shellVerdict
|
|
3880
|
+
? {
|
|
3881
|
+
"x-pylon-offline-shell": shellVerdict.ok
|
|
3882
|
+
? "1"
|
|
3883
|
+
: `refused; ${shellVerdict.reason}`,
|
|
3884
|
+
}
|
|
3885
|
+
: {}),
|
|
3784
3886
|
// Dev-only: the host parses this into its diagnostics ring (served at
|
|
3785
3887
|
// /_pylon/dev/diagnostics + `pylon diagnostics`). Single-line JSON, no
|
|
3786
3888
|
// newlines. Stripped before the client like every x-pylon-* header.
|
|
@@ -3814,12 +3916,18 @@ export async function handleRenderRoute(
|
|
|
3814
3916
|
let preloadManifestErr: string | null = null;
|
|
3815
3917
|
let preloadPublicPrefix = "/_pylon/build/";
|
|
3816
3918
|
let preloadPolyfills: string | undefined;
|
|
3919
|
+
// The same file list the offline service worker precaches for this route.
|
|
3920
|
+
let preloadAssets: RouteAssetFiles | null = null;
|
|
3921
|
+
// Whether the app opted into `buildManifest({ offline })`.
|
|
3922
|
+
let offlineApp = false;
|
|
3817
3923
|
try {
|
|
3818
3924
|
const { getManifest } = await import("./ssr-client-bundler");
|
|
3819
3925
|
const manifest = await getManifest();
|
|
3820
3926
|
preloadPublicPrefix = manifest.public_prefix || preloadPublicPrefix;
|
|
3821
3927
|
preloadManifestRoute = manifest.routes[msg.component] ?? null;
|
|
3822
3928
|
preloadPolyfills = manifest.polyfills;
|
|
3929
|
+
preloadAssets = routeAssetFiles(manifest, msg.component);
|
|
3930
|
+
offlineApp = !!manifest.offline;
|
|
3823
3931
|
if (!preloadManifestRoute) {
|
|
3824
3932
|
preloadManifestErr = `manifest has no entry for "${msg.component}"`;
|
|
3825
3933
|
}
|
|
@@ -3839,13 +3947,14 @@ export async function handleRenderRoute(
|
|
|
3839
3947
|
);
|
|
3840
3948
|
} else if (preloadManifestRoute) {
|
|
3841
3949
|
headBlob += await buildFontHeadBlob();
|
|
3950
|
+
headBlob += await buildOfflineHeadBlob();
|
|
3842
3951
|
const co = /^https?:\/\//i.test(preloadPublicPrefix) ? " crossorigin" : "";
|
|
3843
3952
|
const inlineOverride =
|
|
3844
3953
|
typeof mod?.inlineCss === "boolean" ? mod.inlineCss : undefined;
|
|
3845
|
-
for (const css of
|
|
3954
|
+
for (const css of preloadAssets?.styles ?? []) {
|
|
3846
3955
|
headBlob += await cssHeadTag(css, preloadPublicPrefix, inlineOverride);
|
|
3847
3956
|
}
|
|
3848
|
-
for (const chunk of
|
|
3957
|
+
for (const chunk of preloadAssets?.preloads ?? []) {
|
|
3849
3958
|
headBlob += `<link rel="modulepreload"${co} href="${preloadPublicPrefix}${chunk}">`;
|
|
3850
3959
|
}
|
|
3851
3960
|
} else {
|
|
@@ -3991,9 +4100,12 @@ export async function handleRenderRoute(
|
|
|
3991
4100
|
: undefined,
|
|
3992
4101
|
// A bucketed render is stored shared → its tail must carry ONLY the
|
|
3993
4102
|
// binary signed-in bit, never this request's real identity.
|
|
3994
|
-
|
|
4103
|
+
// An offline shell is kept on the device and replayed to whoever is
|
|
4104
|
+
// signed in later, so it gets the same identity-free tail.
|
|
4105
|
+
bucketAuth: bucketable || shellOk
|
|
3995
4106
|
? { signedIn: msg.session_present === true }
|
|
3996
4107
|
: undefined,
|
|
4108
|
+
offline: offlineApp,
|
|
3997
4109
|
dataOnly: navRender,
|
|
3998
4110
|
});
|
|
3999
4111
|
sendChunk(tail);
|