@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.
@@ -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
+ }
@@ -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
- if (args.polyfills) {
2281
- tail += `<script type="module"${co} src="${args.publicPrefix}${args.polyfills}"></script>`;
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 serverData.fn call so the render can veto shared caching
2655
- // when the call carried a real identity (the fn may read ctx.auth, making
2656
- // its output identity-specific without the page ever touching props.auth).
2657
- onFnCall?: () => void,
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
- onFnCall?.();
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` — in strict mode serverData reads are auth-FILTERED, so the
2780
- * body/ssrData would vary by identity even without the page reading auth. In
2781
- * the default (non-strict) mode serverData reads bypass the policy gate and
2782
- * return identity-independent rows, so the cached body is safe to share within
2783
- * the bucket. Strict mode therefore must NOT bucket.
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 runs with the page's auth and may read ctx.auth
3377
- // internally — identity-specific output the read-tracking proxies
3378
- // can't see. An identity-carrying render that called serverData.fn
3379
- // must therefore veto shared caches exactly as if it read props.auth.
3380
- // Anonymous renders stay cacheable: the anonymous result is uniform
3381
- // across every anonymous visitor.
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
- const bucketTailBase = bucketOptIn
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
- const wantsStream = computeWantsStream(!!Loading, mod);
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 preloadManifestRoute.css) {
3973
+ for (const css of preloadAssets?.styles ?? []) {
3846
3974
  headBlob += await cssHeadTag(css, preloadPublicPrefix, inlineOverride);
3847
3975
  }
3848
- for (const chunk of preloadManifestRoute.imports) {
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
- bucketAuth: bucketable
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 onFnCall notification that lets the render veto shared caching for
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("onFnCall fires once per unique call (the cache-veto hook)", async () => {
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
  /**