@pylonsync/functions 0.22.50 → 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.
@@ -3,12 +3,13 @@
3
3
  // - `readManifestOffline`: the `offline` block of pylon.manifest.json.
4
4
  // - `buildOfflineWorker`: computes the service worker config for one client
5
5
  // build (the precache list comes from `routeAssetUrls`, the same list the
6
- // SSR render emits tags for) and writes `pylon-sw.js` into the build.
6
+ // SSR render emits tags for) and writes `pylon-sw.js` into the build,
7
+ // with `pylon-version.json` beside it (served at `/_pylon/version`).
7
8
  // - `offlineHeadTags`: the install tags every page's `<head>` gets.
8
9
  //
9
10
  // The worker itself is offline-sw.ts. The Rust host serves
10
- // `/manifest.webmanifest` and `/pylon-sw.js` (crates/runtime/src/frontend/
11
- // offline.rs).
11
+ // `/manifest.webmanifest`, `/pylon-sw.js` and `/_pylon/version`
12
+ // (crates/runtime/src/frontend/offline.rs).
12
13
 
13
14
  import { renderServiceWorker, type ServiceWorkerConfig } from "./offline-sw";
14
15
  import { matchRoute } from "./ssr-route-match";
@@ -37,8 +38,24 @@ export interface BundleOffline {
37
38
  routes: string[];
38
39
  /** The service worker's version (its cache names carry it). */
39
40
  version: string;
41
+ /** When this build was made (ISO 8601). Not part of `version`: two
42
+ * machines that build the same app get the same version. */
43
+ builtAt: string;
40
44
  }
41
45
 
46
+ /** `pylon-version.json` in the client build, served at `/_pylon/version`.
47
+ * `useAppUpdate` in @pylonsync/react polls it. */
48
+ export interface BuildVersionFile {
49
+ version: string;
50
+ builtAt: string;
51
+ /** Hash of what a home-screen app reads only when it is added (see
52
+ * `installIdentity`). */
53
+ install: string;
54
+ }
55
+
56
+ /** The file the runtime serves at `/_pylon/version`. */
57
+ export const VERSION_FILE = "pylon-version.json";
58
+
42
59
  /** How long a navigation to an offline route waits for the network before
43
60
  * the worker shows the kept page. Long enough for a slow but working
44
61
  * connection to win; short enough that a dead one does not leave a blank
@@ -98,10 +115,88 @@ export function isDynamicPattern(pattern: string): boolean {
98
115
  return /\[[^\]]+\]/.test(pattern);
99
116
  }
100
117
 
101
- /** The service worker config for this build. */
118
+ /**
119
+ * Hash of what iOS reads from the web app manifest and the head tags only
120
+ * when the app is added to the home screen: the name, the short name, the
121
+ * display mode, the start URL and the icons (with the bytes of each icon
122
+ * file in `public/`). When it changes, an installed app keeps the old
123
+ * values until it is removed and added again.
124
+ *
125
+ * The manifest is the one the runtime serves: `public/manifest.webmanifest`,
126
+ * then `<appDir>/manifest.webmanifest`, else the `offline` config (see
127
+ * `web_manifest_source` in crates/runtime/src/frontend/offline.rs).
128
+ */
129
+ export async function installIdentity(
130
+ fs: any,
131
+ path: any,
132
+ cwd: string,
133
+ appDirRel: string,
134
+ offline: ManifestOfflineInput,
135
+ ): Promise<string> {
136
+ let served: any = null;
137
+ for (const candidate of [
138
+ path.join(cwd, "public", "manifest.webmanifest"),
139
+ path.join(cwd, appDirRel, "manifest.webmanifest"),
140
+ ]) {
141
+ if (!fs.existsSync(candidate)) continue;
142
+ try {
143
+ served = JSON.parse(fs.readFileSync(candidate, "utf8"));
144
+ } catch {
145
+ served = { unreadable: fs.readFileSync(candidate, "utf8") };
146
+ }
147
+ break;
148
+ }
149
+ const { createHash } = await import("node:crypto");
150
+ const iconContent = (src: unknown): string | null => {
151
+ if (typeof src !== "string" || !src.startsWith("/") || src.startsWith("//")) return null;
152
+ const file = path.join(cwd, "public", src.split(/[?#]/)[0]);
153
+ if (!fs.existsSync(file)) return null;
154
+ return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
155
+ };
156
+ const icons = (list: unknown) =>
157
+ (Array.isArray(list) ? list : []).map((icon: any) => ({
158
+ src: icon?.src ?? null,
159
+ sizes: icon?.sizes ?? null,
160
+ purpose: icon?.purpose ?? null,
161
+ content: iconContent(icon?.src),
162
+ }));
163
+ const identity = served
164
+ ? {
165
+ name: served.name ?? null,
166
+ shortName: served.short_name ?? null,
167
+ display: served.display ?? null,
168
+ startUrl: served.start_url ?? null,
169
+ icons: icons(served.icons),
170
+ unreadable: served.unreadable ?? null,
171
+ }
172
+ : {
173
+ name: offline.name,
174
+ shortName: offline.shortName ?? offline.name,
175
+ display: offline.display,
176
+ startUrl: offline.startUrl,
177
+ icons: icons(offline.icons),
178
+ };
179
+ // The head tags come from the `offline` config even when the app ships its
180
+ // own manifest file.
181
+ const touch = touchIcon(offline.icons);
182
+ const head = {
183
+ title: offline.shortName ?? offline.name,
184
+ touchIcon: touch,
185
+ touchIconContent: iconContent(touch),
186
+ };
187
+ return createHash("sha256")
188
+ .update(JSON.stringify({ identity, head }))
189
+ .digest("hex")
190
+ .slice(0, 16);
191
+ }
192
+
193
+ /** The service worker config for this build. `install` is
194
+ * `installIdentity`; it is part of the version, so a manifest change
195
+ * installs a new worker (which precaches the new manifest). */
102
196
  export async function offlineWorkerConfig(
103
197
  manifest: RouteAssetManifest,
104
198
  offline: ManifestOfflineInput,
199
+ install: string = "",
105
200
  ): Promise<{ config: ServiceWorkerConfig; unknownRoutes: string[] }> {
106
201
  const prefix = manifest.public_prefix || "/_pylon/build/";
107
202
  const { matched, unknown } = resolveOfflineRoutes(manifest, offline.routes);
@@ -132,22 +227,26 @@ export async function offlineWorkerConfig(
132
227
  // changed per boot would reinstall the worker on every deploy machine.
133
228
  const { createHash } = await import("node:crypto");
134
229
  const version = createHash("sha256")
135
- .update(JSON.stringify(base))
230
+ .update(JSON.stringify({ ...base, install }))
136
231
  .digest("hex")
137
232
  .slice(0, 16);
138
233
  return { config: { version, ...base }, unknownRoutes: unknown };
139
234
  }
140
235
 
141
- /** Write `pylon-sw.js` into `outdir` and return the bundle manifest's
142
- * `offline` block. */
236
+ /** Write `pylon-sw.js` and `pylon-version.json` into `outdir` and return
237
+ * the bundle manifest's `offline` block. `cwd` and `appDirRel` locate the
238
+ * app's own manifest file and its icons. */
143
239
  export async function buildOfflineWorker(
144
240
  fs: any,
145
241
  path: any,
146
242
  outdir: string,
147
243
  manifest: RouteAssetManifest,
148
244
  offline: ManifestOfflineInput,
245
+ cwd: string,
246
+ appDirRel: string,
149
247
  ): Promise<BundleOffline> {
150
- const { config, unknownRoutes } = await offlineWorkerConfig(manifest, offline);
248
+ const install = await installIdentity(fs, path, cwd, appDirRel, offline);
249
+ const { config, unknownRoutes } = await offlineWorkerConfig(manifest, offline, install);
151
250
  if (unknownRoutes.length > 0) {
152
251
  // eslint-disable-next-line no-console
153
252
  console.warn(
@@ -156,6 +255,12 @@ export async function buildOfflineWorker(
156
255
  );
157
256
  }
158
257
  fs.writeFileSync(path.join(outdir, "pylon-sw.js"), renderServiceWorker(config), "utf8");
258
+ const versionFile: BuildVersionFile = {
259
+ version: config.version,
260
+ builtAt: new Date().toISOString(),
261
+ install,
262
+ };
263
+ fs.writeFileSync(path.join(outdir, VERSION_FILE), JSON.stringify(versionFile), "utf8");
159
264
  return {
160
265
  name: offline.name,
161
266
  ...(offline.shortName ? { shortName: offline.shortName } : {}),
@@ -164,6 +269,7 @@ export async function buildOfflineWorker(
164
269
  startUrl: offline.startUrl,
165
270
  routes: offline.routes,
166
271
  version: config.version,
272
+ builtAt: versionFile.builtAt,
167
273
  };
168
274
  }
169
275
 
@@ -0,0 +1,49 @@
1
+ // Build-time brotli and gzip copies of the client build's text files.
2
+
3
+ import { afterEach, expect, test } from "bun:test";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import zlib from "node:zlib";
8
+ import { MIN_COMPRESS_BYTES, precompressBuild } from "./ssr-precompress";
9
+
10
+ let dir = "";
11
+ afterEach(() => {
12
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
13
+ dir = "";
14
+ });
15
+
16
+ const js = "export const rows = [" + Array.from({ length: 400 }, (_, i) => `{id:${i},name:"row ${i}"}`).join(",") + "];\n";
17
+
18
+ test("writes .br and .gz copies that decompress to the file", () => {
19
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-precompress-"));
20
+ fs.mkdirSync(path.join(dir, "chunks"));
21
+ fs.writeFileSync(path.join(dir, "chunks", "chunk-abcd1234.js"), js);
22
+ fs.writeFileSync(path.join(dir, "styles-abcd1234.css"), "a{color:red}".repeat(200));
23
+
24
+ const result = precompressBuild(fs, path, zlib, dir);
25
+ expect(result.files).toBe(2);
26
+ expect(result.brotliBytes).toBeLessThan(result.bytes);
27
+
28
+ const file = path.join(dir, "chunks", "chunk-abcd1234.js");
29
+ expect(zlib.brotliDecompressSync(fs.readFileSync(file + ".br")).toString()).toBe(js);
30
+ expect(zlib.gunzipSync(fs.readFileSync(file + ".gz")).toString()).toBe(js);
31
+ expect(fs.statSync(file + ".br").size).toBeLessThan(js.length / 4);
32
+ });
33
+
34
+ test("skips small files, binary formats and copies that would not be smaller", () => {
35
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-precompress-"));
36
+ fs.writeFileSync(path.join(dir, "tiny-abcd1234.js"), "x".repeat(MIN_COMPRESS_BYTES - 1));
37
+ fs.writeFileSync(path.join(dir, "font-abcd1234.woff2"), js);
38
+ // Random bytes do not compress.
39
+ const noise = Buffer.from(Array.from({ length: 4096 }, () => Math.floor(Math.random() * 256)));
40
+ fs.writeFileSync(path.join(dir, "noise-abcd1234.js"), noise);
41
+
42
+ const result = precompressBuild(fs, path, zlib, dir);
43
+ expect(result.files).toBe(0);
44
+ expect(fs.readdirSync(dir).sort()).toEqual([
45
+ "font-abcd1234.woff2",
46
+ "noise-abcd1234.js",
47
+ "tiny-abcd1234.js",
48
+ ]);
49
+ });
@@ -0,0 +1,67 @@
1
+ // Brotli and gzip copies of the client build's text files, written at build
2
+ // time. The runtime sends `<file>.br` or `<file>.gz` with Content-Encoding
3
+ // when the browser accepts it (serve_pylon_client_bundle in
4
+ // crates/runtime/src/frontend.rs), so no request pays to compress.
5
+
6
+ /** Extensions worth compressing. Images and fonts are compressed already. */
7
+ const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".json", ".map", ".svg", ".wasm", ".txt", ".html"]);
8
+
9
+ /** Smaller files gain less than the bytes of the encoding headers. */
10
+ export const MIN_COMPRESS_BYTES = 1024;
11
+
12
+ export interface PrecompressResult {
13
+ /** Files that got at least one compressed copy. */
14
+ files: number;
15
+ /** Total bytes of those files, and of their brotli copies. */
16
+ bytes: number;
17
+ brotliBytes: number;
18
+ }
19
+
20
+ /**
21
+ * Write `<file>.br` (brotli, quality 11) and `<file>.gz` (gzip, level 9)
22
+ * next to every compressible file in `outdir` of at least
23
+ * `MIN_COMPRESS_BYTES`. A copy that is not smaller than the file is not
24
+ * written. Copies left by an earlier build are replaced.
25
+ */
26
+ export function precompressBuild(fs: any, path: any, zlib: any, outdir: string): PrecompressResult {
27
+ const result: PrecompressResult = { files: 0, bytes: 0, brotliBytes: 0 };
28
+ const walk = (dir: string) => {
29
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
30
+ const full = path.join(dir, entry.name);
31
+ if (entry.isDirectory()) {
32
+ walk(full);
33
+ continue;
34
+ }
35
+ if (!entry.isFile() || !COMPRESSIBLE.has(path.extname(entry.name))) continue;
36
+ const data: Uint8Array = fs.readFileSync(full);
37
+ if (data.length < MIN_COMPRESS_BYTES) continue;
38
+ const br: Uint8Array = zlib.brotliCompressSync(data, {
39
+ params: {
40
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 11,
41
+ [zlib.constants.BROTLI_PARAM_SIZE_HINT]: data.length,
42
+ },
43
+ });
44
+ const gz: Uint8Array = zlib.gzipSync(data, { level: 9 });
45
+ let wrote = false;
46
+ for (const [ext, copy] of [
47
+ [".br", br],
48
+ [".gz", gz],
49
+ ] as const) {
50
+ const target = full + ext;
51
+ if (copy.length < data.length) {
52
+ fs.writeFileSync(target, copy);
53
+ wrote = true;
54
+ } else if (fs.existsSync(target)) {
55
+ fs.unlinkSync(target);
56
+ }
57
+ }
58
+ if (wrote) {
59
+ result.files++;
60
+ result.bytes += data.length;
61
+ result.brotliBytes += Math.min(br.length, data.length);
62
+ }
63
+ }
64
+ };
65
+ walk(outdir);
66
+ return result;
67
+ }
@@ -2222,8 +2222,9 @@ export function buildHydrationTail(args: {
2222
2222
  // raw `auth` is replaced AFTER the live-handle strip below.
2223
2223
  bucketAuth?: { signedIn: boolean };
2224
2224
  /** The app opted into `buildManifest({ offline })`: the client registers
2225
- * the service worker (production only). */
2226
- offline?: boolean;
2225
+ * the service worker (production only), and `useAppUpdate` compares this
2226
+ * build's version with `/_pylon/version`. */
2227
+ offline?: { version: string; builtAt?: string };
2227
2228
  /** Client-side NAVIGATION payload: emit the `__PYLON_DATA__` blob and
2228
2229
  * nothing else. The client already has the runtime and resolves the route
2229
2230
  * entry from the build manifest, so the entry `<script>`, the preload tags
@@ -2289,7 +2290,13 @@ export function buildHydrationTail(args: {
2289
2290
  typeof process !== "undefined" ? process.env.PYLON_APP_NAME : undefined;
2290
2291
  if (appName) hydrationPayload.app = appName;
2291
2292
  // App-global, identity-free: safe in a shared or offline tail.
2292
- if (args.offline) hydrationPayload.offline = { dev: isDevMode() };
2293
+ if (args.offline) {
2294
+ hydrationPayload.offline = {
2295
+ dev: isDevMode(),
2296
+ version: args.offline.version,
2297
+ builtAt: args.offline.builtAt ?? null,
2298
+ };
2299
+ }
2293
2300
  if (args.kind) hydrationPayload.kind = args.kind;
2294
2301
  const json = escapeScriptJson(JSON.stringify(hydrationPayload));
2295
2302
  let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
@@ -2675,20 +2682,23 @@ export function makeServerData(
2675
2682
  // executes it exactly like ctx.runQuery). Lets a page SSR data whose safe
2676
2683
  // public projection lives in a gated query instead of open row policies.
2677
2684
  callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>,
2678
- // Fired on every serverData.fn call so the render can veto shared caching
2679
- // when the call carried a real identity (the fn may read ctx.auth, making
2680
- // its output identity-specific without the page ever touching props.auth).
2681
- 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,
2682
2691
  // Fired on every serverData read (fn calls included), so an offline-shell
2683
2692
  // render can refuse a page that needs the server to render.
2684
2693
  onRead?: () => void,
2685
2694
  ): any {
2686
2695
  const promiseCache = new Map<string, Promise<any>>();
2687
- const wrap = (r: any, prefix: string): any => {
2696
+ const wrap = (r: any, prefix: string, callerFiltered: boolean): any => {
2688
2697
  const out: any = {};
2689
2698
  for (const m of SERVER_DATA_METHODS) {
2690
2699
  out[m] = (...args: any[]) => {
2691
2700
  onRead?.();
2701
+ if (callerFiltered) onIdentityRead?.();
2692
2702
  const key = prefix + m + ":" + stableStringify(args);
2693
2703
  let p = promiseCache.get(key);
2694
2704
  if (!p) {
@@ -2703,8 +2713,8 @@ export function makeServerData(
2703
2713
  }
2704
2714
  return out;
2705
2715
  };
2706
- const sd = wrap(reader, "");
2707
- 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);
2708
2718
  if (callFn) {
2709
2719
  sd.fn = (name: string, args?: Record<string, unknown>) => {
2710
2720
  onRead?.();
@@ -2713,7 +2723,7 @@ export function makeServerData(
2713
2723
  const key = "fn:" + name + ":" + stableStringify(args ?? {});
2714
2724
  let p = promiseCache.get(key);
2715
2725
  if (!p) {
2716
- onFnCall?.();
2726
+ onIdentityRead?.();
2717
2727
  p = callFn(name, args).then((value: any) => {
2718
2728
  valueCache[key] = value;
2719
2729
  return value;
@@ -2805,11 +2815,11 @@ export function computeCacheVerdict(args: {
2805
2815
  * `authTouched` proves a bucketable body depends on nothing but the binary
2806
2816
  * session bit.
2807
2817
  * - `dynamicTouched` — reading request headers/cookies makes it request-specific.
2808
- * - `strictPolicies` — in strict mode serverData reads are auth-FILTERED, so the
2809
- * body/ssrData would vary by identity even without the page reading auth. In
2810
- * the default (non-strict) mode serverData reads bypass the policy gate and
2811
- * return identity-independent rows, so the cached body is safe to share within
2812
- * 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.
2813
2823
  * - cookieCount / forceDynamic / wantsStream / non-200 — same reasons as the
2814
2824
  * anon verdict (a Set-Cookie, a streaming head, or an error/redirect can't be
2815
2825
  * safely shared).
@@ -2874,8 +2884,11 @@ export function computeOfflineShellVerdict(args: {
2874
2884
  status: number;
2875
2885
  }): { ok: true } | { ok: false; reason: string } {
2876
2886
  if (args.status !== 200) return { ok: false, reason: `status ${args.status}` };
2877
- if (args.authTouched) return { ok: false, reason: "the page read props.auth" };
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.
2878
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" };
2879
2892
  if (args.dynamicTouched) {
2880
2893
  return { ok: false, reason: "the page read request headers or cookies" };
2881
2894
  }
@@ -3446,12 +3459,13 @@ export async function handleRenderRoute(
3446
3459
  buildDbReader(msg.call_id, true),
3447
3460
  ssrValueCache,
3448
3461
  buildSsrFnCaller(msg.call_id),
3449
- // A query function runs with the page's auth and may read ctx.auth
3450
- // internally — identity-specific output the read-tracking proxies
3451
- // can't see. An identity-carrying render that called serverData.fn
3452
- // must therefore veto shared caches exactly as if it read props.auth.
3453
- // Anonymous renders stay cacheable: the anonymous result is uniform
3454
- // 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.
3455
3469
  () => {
3456
3470
  const a = msg.auth as Record<string, unknown> | undefined;
3457
3471
  if (a && (a.user_id || a.is_admin || a.tenant_id)) {
@@ -3918,8 +3932,8 @@ export async function handleRenderRoute(
3918
3932
  let preloadPolyfills: string | undefined;
3919
3933
  // The same file list the offline service worker precaches for this route.
3920
3934
  let preloadAssets: RouteAssetFiles | null = null;
3921
- // Whether the app opted into `buildManifest({ offline })`.
3922
- let offlineApp = false;
3935
+ // The build's version when the app opted into `buildManifest({ offline })`.
3936
+ let offlineApp: { version: string; builtAt?: string } | undefined;
3923
3937
  try {
3924
3938
  const { getManifest } = await import("./ssr-client-bundler");
3925
3939
  const manifest = await getManifest();
@@ -3927,7 +3941,12 @@ export async function handleRenderRoute(
3927
3941
  preloadManifestRoute = manifest.routes[msg.component] ?? null;
3928
3942
  preloadPolyfills = manifest.polyfills;
3929
3943
  preloadAssets = routeAssetFiles(manifest, msg.component);
3930
- offlineApp = !!manifest.offline;
3944
+ if (manifest.offline) {
3945
+ offlineApp = {
3946
+ version: manifest.offline.version,
3947
+ builtAt: manifest.offline.builtAt,
3948
+ };
3949
+ }
3931
3950
  if (!preloadManifestRoute) {
3932
3951
  preloadManifestErr = `manifest has no entry for "${msg.component}"`;
3933
3952
  }
@@ -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
  /**