@pylonsync/functions 0.22.50 → 0.22.59
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/runtime.d.ts +5 -1
- package/dist/ssr-barrel-imports.d.ts +16 -0
- package/dist/ssr-client-bundler.d.ts +44 -0
- package/dist/ssr-client-nav.d.ts +30 -0
- package/dist/ssr-offline.d.ts +34 -5
- package/dist/ssr-precompress.d.ts +16 -0
- package/dist/ssr-runtime.d.ts +12 -8
- package/dist/types.d.ts +53 -0
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/offline-sw.test.ts +36 -0
- package/src/offline-sw.ts +15 -2
- 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 +160 -0
- package/src/ssr-client-bundler.ts +315 -73
- 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 +7 -2
- package/src/ssr-offline.ts +114 -8
- package/src/ssr-precompress.test.ts +49 -0
- package/src/ssr-precompress.ts +67 -0
- package/src/ssr-runtime.ts +45 -26
- package/src/ssr-serverdata-fn.test.ts +2 -2
- package/src/types.ts +50 -1
package/src/ssr-offline.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
2679
|
-
//
|
|
2680
|
-
//
|
|
2681
|
-
|
|
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
|
-
|
|
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` —
|
|
2809
|
-
*
|
|
2810
|
-
*
|
|
2811
|
-
*
|
|
2812
|
-
*
|
|
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
|
-
|
|
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
|
|
3450
|
-
//
|
|
3451
|
-
// can't see. An identity-carrying render that
|
|
3452
|
-
//
|
|
3453
|
-
//
|
|
3454
|
-
// 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.
|
|
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
|
-
//
|
|
3922
|
-
let offlineApp
|
|
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
|
-
|
|
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
|
|
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
|
/**
|