@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.
@@ -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
- if (args.polyfills) {
2281
- tail += `<script type="module"${co} src="${args.publicPrefix}${args.polyfills}"></script>`;
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
- const bucketTailBase = bucketOptIn
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
- const wantsStream = computeWantsStream(!!Loading, mod);
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 preloadManifestRoute.css) {
3954
+ for (const css of preloadAssets?.styles ?? []) {
3846
3955
  headBlob += await cssHeadTag(css, preloadPublicPrefix, inlineOverride);
3847
3956
  }
3848
- for (const chunk of preloadManifestRoute.imports) {
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
- bucketAuth: bucketable
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);