@pylonsync/functions 0.22.59 → 0.22.60

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.
@@ -213,4 +213,31 @@ export declare function getManifest(): Promise<PylonBundleManifest>;
213
213
  * right tree).
214
214
  */
215
215
  export declare function handleBundleClient(msg: BundleClientMessage, send: Send): Promise<void>;
216
+ /**
217
+ * Assets JavaScript imports (a `.wasm`, an image) as absolute URLs under the
218
+ * build prefix. Bun's `file` loader exports a RELATIVE path ("assets/x.wasm"),
219
+ * and code that fetches it resolves it against the page: loro-crdt's browser
220
+ * build fetched /dashboard/orgs/acme/assets/loro_wasm_bg-….wasm and 404'd.
221
+ * Bun's `publicPath` would make it absolute, but it also rewrites imports
222
+ * between split chunks and drops their `chunks/` directory (Bun 1.3.14), so
223
+ * this plugin writes the file itself, named by its content hash, and exports
224
+ * the absolute URL. A stylesheet's `url(...)` is left to Bun: CSS cannot
225
+ * import a JavaScript module.
226
+ */
227
+ /**
228
+ * The outdir-relative chunks a built entry reaches through STATIC imports,
229
+ * nearest first. Specifiers are relative to the importing file: an entry
230
+ * imports `./chunks/<file>.js`, a chunk imports its siblings as
231
+ * `./<file>.js`. Following only the first form dropped every chunk-to-chunk
232
+ * import, so those chunks were missing from the route's modulepreload set
233
+ * and loaded in a second wave (3 of 28 files on the Veritas home page).
234
+ *
235
+ * DYNAMIC `import("Y")` is deliberately not followed. This list becomes the
236
+ * route's modulepreload set, and a chunk the author deferred behind
237
+ * `import()` must not be downloaded eagerly: that would ship every byte the
238
+ * split was meant to defer. The trailing `(` tells the forms apart; Bun's
239
+ * minified output quotes specifiers plainly, so one pattern covers
240
+ * `import X from "Y"`, `import{a}from"Y"` and bare `import "Y"`.
241
+ */
242
+ export declare function staticImportGraph(fs: any, path: any, outdir: string, entryAbsPath: string, chunkRelPaths: Set<string>): Set<string>;
216
243
  export {};
@@ -20,6 +20,13 @@ type MatchRoute = (manifest: any, pathname: string) => ClientRouteMatch | null;
20
20
  * A static scan: the bundler reads the page file without running it.
21
21
  */
22
22
  export declare function declaresClientNavigation(source: string): boolean;
23
+ /**
24
+ * `source` with `//` and `/* *\/` comments replaced by spaces, so a static
25
+ * scan sees code only (a commented-out export is not an export). Strings
26
+ * and template literals are skipped over intact, so a `//` inside one is
27
+ * not taken for a comment.
28
+ */
29
+ export declare function stripComments(source: string): string;
23
30
  /** Whether `pathname` is one of the app's offline routes. */
24
31
  export declare function isOfflinePath(manifest: ClientNavManifest | null, pathname: string, matchRoute: MatchRoute): boolean;
25
32
  /**
@@ -16,7 +16,8 @@ export interface RouteAssetManifest {
16
16
  export interface RouteAssetFiles {
17
17
  /** `<link rel="stylesheet">` (or inlined `<style>`) in the head. */
18
18
  styles: string[];
19
- /** `<link rel="modulepreload">` in the head. */
19
+ /** `<link rel="modulepreload">` in the head: the route entry, then its
20
+ * static import graph (see `modulepreloadFiles`). */
20
21
  preloads: string[];
21
22
  /** `<link rel="preload" as="font">` in the head. */
22
23
  fonts: string[];
@@ -24,6 +25,24 @@ export interface RouteAssetFiles {
24
25
  * route entry. */
25
26
  scripts: string[];
26
27
  }
28
+ /**
29
+ * The most `<link rel="modulepreload">` tags a page's head carries. Every
30
+ * file in the route's static graph is needed before hydration, so the list
31
+ * is complete for any real route; the cap keeps a pathological graph from
32
+ * bloating the head. Past it, the browser finds the rest by import.
33
+ */
34
+ export declare const MAX_MODULEPRELOADS = 64;
35
+ /**
36
+ * The files to modulepreload for a route: its entry first, then its static
37
+ * import graph (the manifest's `imports`, nearest first), capped at
38
+ * `MAX_MODULEPRELOADS`. The entry's `<script>` sits at the end of the body,
39
+ * after a streamed page's Suspense boundaries, so without its preload the
40
+ * page's own code started last.
41
+ */
42
+ export declare function modulepreloadFiles(route: {
43
+ file: string;
44
+ imports?: string[];
45
+ }): string[];
27
46
  /** The scripts the hydration tail loads for a route entry. */
28
47
  export declare function routeScriptFiles(route: {
29
48
  file: string;
package/dist/types.d.ts CHANGED
@@ -240,6 +240,24 @@ export interface DbWriter extends DbReader {
240
240
  insert(entity: string, data: Record<string, unknown>): Promise<string>;
241
241
  /** Update a row by ID. Returns true if the row existed. */
242
242
  update(entity: string, id: string, data: Record<string, unknown>): Promise<boolean>;
243
+ /**
244
+ * Insert many rows of one entity. Returns their IDs, in order. Same
245
+ * result as calling `insert` per row, with fewer database round trips:
246
+ * use it for imports and other loops that write many rows. When a row
247
+ * fails, the call throws; let the error end the mutation so it rolls
248
+ * back.
249
+ */
250
+ insertMany(entity: string, rows: Record<string, unknown>[]): Promise<string[]>;
251
+ /**
252
+ * Update many rows of one entity. Returns, per row and in order, true if
253
+ * the row existed. Same result as calling `update` per row, with fewer
254
+ * database round trips. When a row fails, the call throws; let the error
255
+ * end the mutation so it rolls back.
256
+ */
257
+ updateMany(entity: string, rows: {
258
+ id: string;
259
+ data: Record<string, unknown>;
260
+ }[]): Promise<boolean[]>;
243
261
  /** Delete a row by ID. Returns true if the row existed. */
244
262
  delete(entity: string, id: string): Promise<boolean>;
245
263
  /** Link two entities via a relation. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.22.59",
3
+ "version": "0.22.60",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -58,7 +58,7 @@
58
58
  "@types/react-dom": "^19.0.0",
59
59
  "browserslist": "^4.29.1",
60
60
  "core-js": "^3.50.0",
61
- "happy-dom": "^20.8.9",
61
+ "happy-dom": "^21.0.4",
62
62
  "lightningcss": "~1.32.0",
63
63
  "react": "^19.0.0",
64
64
  "react-dom": "^19.0.0"
@@ -42,6 +42,11 @@ reader.get("Todo", "r1").catch(() => {});
42
42
  reader.unsafe.get("Todo", "r2").catch(() => {});
43
43
  writer.update("Todo", "w1", { a: 1 }).catch(() => {});
44
44
  writer.unsafe.update("Todo", "w2", { a: 2 }).catch(() => {});
45
+ writer.insertMany("Many", [{ a: 3 }, { a: 4 }]).catch(() => {});
46
+ writer.unsafe.updateMany("Many", [{ id: "m1", data: { a: 5 } }]).catch(() => {});
47
+ // An empty batch sends nothing.
48
+ writer.insertMany("Empty", []).catch(() => {});
49
+ writer.updateMany("Empty", []).catch(() => {});
45
50
 
46
51
  setTimeout(() => process.exit(0), 300);
47
52
  `;
@@ -83,4 +88,11 @@ test("db builders: required unsafe surface, no chaining, unsafe_op flag routing"
83
88
  expect(byId("r2")).toMatchObject({ op: "get", unsafe_op: true });
84
89
  expect(byId("w1")).toMatchObject({ op: "update", unsafe_op: false });
85
90
  expect(byId("w2")).toMatchObject({ op: "update", unsafe_op: true });
91
+
92
+ const many = frames.filter((f) => f.entity === "Many");
93
+ expect(many).toEqual([
94
+ expect.objectContaining({ op: "insert_many", data: [{ a: 3 }, { a: 4 }], unsafe_op: false }),
95
+ expect.objectContaining({ op: "update_many", data: [{ id: "m1", data: { a: 5 } }], unsafe_op: true }),
96
+ ]);
97
+ expect(frames.some((f) => f.entity === "Empty")).toBe(false);
86
98
  });
package/src/runtime.ts CHANGED
@@ -886,6 +886,28 @@ function buildWriterOps(callId: string, unsafeOp: boolean): Omit<DbWriter, "unsa
886
886
  })) as { updated: boolean };
887
887
  return r.updated;
888
888
  },
889
+ async insertMany(entity, rows) {
890
+ if (rows.length === 0) return [];
891
+ const r = (await rpcDb(callId, {
892
+ type: "db",
893
+ op: "insert_many",
894
+ entity,
895
+ data: rows,
896
+ unsafe_op: unsafeOp,
897
+ })) as { ids: string[] };
898
+ return r.ids;
899
+ },
900
+ async updateMany(entity, rows) {
901
+ if (rows.length === 0) return [];
902
+ const r = (await rpcDb(callId, {
903
+ type: "db",
904
+ op: "update_many",
905
+ entity,
906
+ data: rows,
907
+ unsafe_op: unsafeOp,
908
+ })) as { updated: boolean[] };
909
+ return r.updated;
910
+ },
889
911
  async delete(entity, id) {
890
912
  const r = (await rpcDb(callId, {
891
913
  type: "db",
@@ -42,7 +42,7 @@ import {
42
42
  type ManifestBuildConfig,
43
43
  } from "./build-compat";
44
44
  import { serverBundle } from "./server-bundle";
45
- import { declaresClientNavigation } from "./ssr-client-nav";
45
+ import { declaresClientNavigation, stripComments } from "./ssr-client-nav";
46
46
  import { precompressBuild } from "./ssr-precompress";
47
47
  import { parseNamespaceBarrel, rewriteBarrelImports } from "./ssr-barrel-imports";
48
48
  import { buildOfflineWorker, readManifestOffline, type BundleOffline } from "./ssr-offline";
@@ -1375,9 +1375,10 @@ function readComponentSource(fs: any, path: any, cwd: string, component: string)
1375
1375
  * it to the runtime, which takes the title from it on a navigation with no
1376
1376
  * page fetch. A static scan, so a page without one imports nothing extra. */
1377
1377
  export function exportsStaticMetadata(source: string): boolean {
1378
+ const code = stripComments(source);
1378
1379
  return (
1379
- /\bexport\s+(?:const|let|var)\s+metadata\b/.test(source) ||
1380
- /\bexport\s*\{[^}]*\bmetadata\b[^}]*\}/.test(source)
1380
+ /\bexport\s+(?:const|let|var)\s+metadata\b/.test(code) ||
1381
+ /\bexport\s*\{[^}]*\bmetadata\b[^}]*\}/.test(code)
1381
1382
  );
1382
1383
  }
1383
1384
 
@@ -2021,36 +2022,9 @@ export async function _doBuildInner(
2021
2022
  // against; the build itself is fine.
2022
2023
  }
2023
2024
 
2024
- // Scan a built JS file for STATIC `import` literals pointing at
2025
- // `./chunks/<file>.js` and return them resolved to outdir-relative paths.
2026
- // Bun's minified output uses simple double quotes for module specifiers,
2027
- // so a `matchAll` covers both `import X from "Y"` and bare `import "Y"`.
2028
- //
2029
- // DYNAMIC `import("Y")` is deliberately NOT matched. This list becomes the
2030
- // route's modulepreload set, and a chunk the author deferred behind
2031
- // `import()` must not be downloaded eagerly anyway — that would split the
2032
- // code out of the entry (so it isn't parsed on load) while still shipping
2033
- // every byte, which is the expensive half of the cost with none of the
2034
- // saving. The trailing `(` is what distinguishes the two forms.
2035
- function scanChunkImports(jsAbsPath: string): string[] {
2036
- let src: string;
2037
- try {
2038
- src = fs.readFileSync(jsAbsPath, "utf8");
2039
- } catch {
2040
- return [];
2041
- }
2042
- const found = new Set<string>();
2043
- const matches = src.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g);
2044
- for (const m of matches) {
2045
- const spec = m[1];
2046
- if (spec.startsWith("./chunks/") || spec.startsWith("chunks/")) {
2047
- const base = path.basename(spec);
2048
- const hit = chunksByBasename.get(base);
2049
- if (hit) found.add(hit.relPath);
2050
- }
2051
- }
2052
- return Array.from(found);
2053
- }
2025
+ const chunkRelPaths = new Set(
2026
+ Array.from(chunksByBasename.values()).map((c) => c.relPath),
2027
+ );
2054
2028
 
2055
2029
  const manifest: PylonBundleManifest = {
2056
2030
  build_id: makeBuildId(),
@@ -2064,22 +2038,9 @@ export async function _doBuildInner(
2064
2038
  const stem = `client-entry-${slug}`;
2065
2039
  const entry = entriesByStem.get(stem);
2066
2040
  if (!entry) continue;
2067
- // Walk transitively in case Bun emits chunks that reference
2068
- // other chunks (rare in the single-level splitting we use,
2069
- // but free to compute).
2070
- const seen = new Set<string>();
2071
- const queue: string[] = scanChunkImports(entry.absPath);
2072
- for (const q of queue) seen.add(q);
2073
- while (queue.length > 0) {
2074
- const relChunk = queue.shift()!;
2075
- const absChunk = path.join(outdir, relChunk);
2076
- for (const nested of scanChunkImports(absChunk)) {
2077
- if (!seen.has(nested)) {
2078
- seen.add(nested);
2079
- queue.push(nested);
2080
- }
2081
- }
2082
- }
2041
+ // The SSR head modulepreloads every file in the static graph, so the
2042
+ // browser fetches them in parallel instead of level by level.
2043
+ const seen = staticImportGraph(fs, path, outdir, entry.absPath, chunkRelPaths);
2083
2044
  manifest.routes[r.component] = {
2084
2045
  file: entry.relPath,
2085
2046
  imports: Array.from(seen),
@@ -2372,6 +2333,65 @@ const ASSET_RE = /\.(wasm|png|jpe?g|gif|webp|avif|ico|svg|woff2?|ttf|otf|mp3|mp4
2372
2333
  * the absolute URL. A stylesheet's `url(...)` is left to Bun: CSS cannot
2373
2334
  * import a JavaScript module.
2374
2335
  */
2336
+ /**
2337
+ * The outdir-relative chunks a built entry reaches through STATIC imports,
2338
+ * nearest first. Specifiers are relative to the importing file: an entry
2339
+ * imports `./chunks/<file>.js`, a chunk imports its siblings as
2340
+ * `./<file>.js`. Following only the first form dropped every chunk-to-chunk
2341
+ * import, so those chunks were missing from the route's modulepreload set
2342
+ * and loaded in a second wave (3 of 28 files on the Veritas home page).
2343
+ *
2344
+ * DYNAMIC `import("Y")` is deliberately not followed. This list becomes the
2345
+ * route's modulepreload set, and a chunk the author deferred behind
2346
+ * `import()` must not be downloaded eagerly: that would ship every byte the
2347
+ * split was meant to defer. The trailing `(` tells the forms apart; Bun's
2348
+ * minified output quotes specifiers plainly, so one pattern covers
2349
+ * `import X from "Y"`, `import{a}from"Y"` and bare `import "Y"`.
2350
+ */
2351
+ export function staticImportGraph(
2352
+ fs: any,
2353
+ path: any,
2354
+ outdir: string,
2355
+ entryAbsPath: string,
2356
+ chunkRelPaths: Set<string>,
2357
+ ): Set<string> {
2358
+ const toUrlPath = (p: string) => p.replace(/\\/g, "/");
2359
+ const scan = (jsAbsPath: string): string[] => {
2360
+ let src: string;
2361
+ try {
2362
+ src = fs.readFileSync(jsAbsPath, "utf8");
2363
+ } catch {
2364
+ return [];
2365
+ }
2366
+ const found = new Set<string>();
2367
+ const dir = path.dirname(jsAbsPath);
2368
+ for (const m of src.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g)) {
2369
+ const spec = m[1]!;
2370
+ if (!spec.startsWith("./") && !spec.startsWith("../") && !spec.startsWith("chunks/")) {
2371
+ continue;
2372
+ }
2373
+ const rel = toUrlPath(
2374
+ path.relative(outdir, path.resolve(spec.startsWith("chunks/") ? outdir : dir, spec)),
2375
+ );
2376
+ if (chunkRelPaths.has(rel)) found.add(rel);
2377
+ }
2378
+ return Array.from(found);
2379
+ };
2380
+ const seen = new Set<string>();
2381
+ const queue = scan(entryAbsPath);
2382
+ for (const q of queue) seen.add(q);
2383
+ while (queue.length > 0) {
2384
+ const rel = queue.shift()!;
2385
+ for (const nested of scan(path.join(outdir, rel))) {
2386
+ if (!seen.has(nested)) {
2387
+ seen.add(nested);
2388
+ queue.push(nested);
2389
+ }
2390
+ }
2391
+ }
2392
+ return seen;
2393
+ }
2394
+
2375
2395
  /**
2376
2396
  * Rewrite named imports from namespace barrels (`radix-ui`) in app source
2377
2397
  * into imports of the re-exported packages, so a route bundles only the
@@ -35,6 +35,8 @@ describe("declaresClientNavigation", () => {
35
35
  `const navigation = "client";`,
36
36
  `export const navigationMode = "client";`,
37
37
  `// navigation = "client"`,
38
+ `// export const navigation = "client";`,
39
+ `/* export const navigation = "client"; */`,
38
40
  "",
39
41
  ]) {
40
42
  expect(declaresClientNavigation(src)).toBe(false);
@@ -42,6 +44,10 @@ describe("declaresClientNavigation", () => {
42
44
  });
43
45
  });
44
46
 
47
+ test("comments are stripped; strings that contain // are not", () => {
48
+ expect(declaresClientNavigation(`const u = "http://x"; export const navigation = "client";`)).toBe(true);
49
+ });
50
+
45
51
  describe("clientRouteFor", () => {
46
52
  test("a page that opted in", () => {
47
53
  expect(clientRouteFor(MANIFEST, "/leads", matchRoute)?.component).toBe("app/leads/page");
@@ -74,5 +80,6 @@ describe("exportsStaticMetadata", () => {
74
80
  expect(exportsStaticMetadata(`const metadata = {}; export { metadata };`)).toBe(true);
75
81
  expect(exportsStaticMetadata(`export async function generateMetadata() {}`)).toBe(false);
76
82
  expect(exportsStaticMetadata(`const metadata = {};`)).toBe(false);
83
+ expect(exportsStaticMetadata(`// export const metadata = { title: "x" };`)).toBe(false);
77
84
  });
78
85
  });
@@ -36,7 +36,48 @@ type MatchRoute = (manifest: any, pathname: string) => ClientRouteMatch | null;
36
36
  * A static scan: the bundler reads the page file without running it.
37
37
  */
38
38
  export function declaresClientNavigation(source: string): boolean {
39
- return /export\s+const\s+navigation\s*(?::[^=]+)?=\s*(["'`])client\1/.test(source);
39
+ return /export\s+const\s+navigation\s*(?::[^=]+)?=\s*(["'`])client\1/.test(
40
+ stripComments(source),
41
+ );
42
+ }
43
+
44
+ /**
45
+ * `source` with `//` and `/* *\/` comments replaced by spaces, so a static
46
+ * scan sees code only (a commented-out export is not an export). Strings
47
+ * and template literals are skipped over intact, so a `//` inside one is
48
+ * not taken for a comment.
49
+ */
50
+ export function stripComments(source: string): string {
51
+ let out = "";
52
+ let i = 0;
53
+ while (i < source.length) {
54
+ const c = source[i]!;
55
+ const next = source[i + 1];
56
+ if (c === "/" && next === "/") {
57
+ const end = source.indexOf("\n", i);
58
+ const stop = end === -1 ? source.length : end;
59
+ out += " ".repeat(stop - i);
60
+ i = stop;
61
+ } else if (c === "/" && next === "*") {
62
+ const end = source.indexOf("*/", i + 2);
63
+ const stop = end === -1 ? source.length : end + 2;
64
+ out += source.slice(i, stop).replace(/[^\n]/g, " ");
65
+ i = stop;
66
+ } else if (c === '"' || c === "'" || c === "`") {
67
+ let j = i + 1;
68
+ while (j < source.length && source[j] !== c) {
69
+ if (source[j] === "\\") j++;
70
+ else if (c !== "`" && source[j] === "\n") break;
71
+ j++;
72
+ }
73
+ out += source.slice(i, j + 1);
74
+ i = j + 1;
75
+ } else {
76
+ out += c;
77
+ i++;
78
+ }
79
+ }
80
+ return out;
40
81
  }
41
82
 
42
83
  /** Whether `pathname` is one of the app's offline routes. */
@@ -251,3 +251,29 @@ test("the worker precaches exactly the files the route's HTML loads", async () =
251
251
  expect(config.precache).toContain("/icons/512.png");
252
252
  expect(config.shells).toEqual(["/"]);
253
253
  });
254
+
255
+ test("the head modulepreloads the route entry and its whole static graph", async () => {
256
+ const dir = makeApp(SESSION_ONLY_PAGE);
257
+ const { html } = await render(dir);
258
+ const head = html.slice(0, html.indexOf("</head>"));
259
+ const preloaded = [...head.matchAll(/<link rel="modulepreload" href="([^"]+)">/g)].map((m) => m[1]);
260
+ const route = BUNDLE_MANIFEST.routes["app/page"];
261
+ expect(preloaded).toEqual([route.file, ...route.imports].map((f) => "/_pylon/build/" + f));
262
+ });
263
+
264
+ test("modulepreloads are capped for a pathological graph", async () => {
265
+ const { MAX_MODULEPRELOADS } = await import("./ssr-route-assets");
266
+ const imports = Array.from({ length: MAX_MODULEPRELOADS + 20 }, (_, i) => `chunks/c${i}-aaaa1111.js`);
267
+ const manifest = {
268
+ ...BUNDLE_MANIFEST,
269
+ routes: { "app/page": { ...BUNDLE_MANIFEST.routes["app/page"], imports } },
270
+ };
271
+ const dir = makeApp(SESSION_ONLY_PAGE, manifest);
272
+ const { html } = await render(dir);
273
+ const head = html.slice(0, html.indexOf("</head>"));
274
+ const preloaded = [...head.matchAll(/<link rel="modulepreload" href="([^"]+)">/g)];
275
+ expect(preloaded.length).toBe(MAX_MODULEPRELOADS);
276
+ // The entry comes first, then the nearest imports.
277
+ expect(preloaded[0]![1]).toBe("/_pylon/build/" + BUNDLE_MANIFEST.routes["app/page"].file);
278
+ expect(preloaded[1]![1]).toBe("/_pylon/build/chunks/c0-aaaa1111.js");
279
+ });
@@ -21,7 +21,8 @@ export interface RouteAssetManifest {
21
21
  export interface RouteAssetFiles {
22
22
  /** `<link rel="stylesheet">` (or inlined `<style>`) in the head. */
23
23
  styles: string[];
24
- /** `<link rel="modulepreload">` in the head. */
24
+ /** `<link rel="modulepreload">` in the head: the route entry, then its
25
+ * static import graph (see `modulepreloadFiles`). */
25
26
  preloads: string[];
26
27
  /** `<link rel="preload" as="font">` in the head. */
27
28
  fonts: string[];
@@ -30,6 +31,25 @@ export interface RouteAssetFiles {
30
31
  scripts: string[];
31
32
  }
32
33
 
34
+ /**
35
+ * The most `<link rel="modulepreload">` tags a page's head carries. Every
36
+ * file in the route's static graph is needed before hydration, so the list
37
+ * is complete for any real route; the cap keeps a pathological graph from
38
+ * bloating the head. Past it, the browser finds the rest by import.
39
+ */
40
+ export const MAX_MODULEPRELOADS = 64;
41
+
42
+ /**
43
+ * The files to modulepreload for a route: its entry first, then its static
44
+ * import graph (the manifest's `imports`, nearest first), capped at
45
+ * `MAX_MODULEPRELOADS`. The entry's `<script>` sits at the end of the body,
46
+ * after a streamed page's Suspense boundaries, so without its preload the
47
+ * page's own code started last.
48
+ */
49
+ export function modulepreloadFiles(route: { file: string; imports?: string[] }): string[] {
50
+ return [route.file, ...(route.imports ?? [])].slice(0, MAX_MODULEPRELOADS);
51
+ }
52
+
33
53
  /** The scripts the hydration tail loads for a route entry. */
34
54
  export function routeScriptFiles(
35
55
  route: { file: string },
@@ -48,7 +68,7 @@ export function routeAssetFiles(
48
68
  if (!route) return null;
49
69
  return {
50
70
  styles: route.css ?? [],
51
- preloads: route.imports ?? [],
71
+ preloads: modulepreloadFiles(route),
52
72
  fonts: manifest.fonts?.preload ?? [],
53
73
  scripts: routeScriptFiles(route, manifest.polyfills),
54
74
  };
@@ -62,7 +82,8 @@ export function routeAssetUrls(
62
82
  const files = routeAssetFiles(manifest, component);
63
83
  if (!files) return [];
64
84
  const prefix = manifest.public_prefix || "/_pylon/build/";
65
- return [...files.styles, ...files.preloads, ...files.fonts, ...files.scripts].map(
85
+ // The entry is both preloaded and loaded as a script: list it once.
86
+ return [...new Set([...files.styles, ...files.preloads, ...files.fonts, ...files.scripts])].map(
66
87
  (f) => prefix + f,
67
88
  );
68
89
  }
@@ -10,6 +10,7 @@
10
10
  import { withMetaSlot } from "./ssr-page-tree";
11
11
  import { moduleKey, serverBundle } from "./server-bundle";
12
12
  import {
13
+ modulepreloadFiles,
13
14
  routeAssetFiles,
14
15
  routeScriptFiles,
15
16
  type RouteAssetFiles,
@@ -2471,7 +2472,7 @@ async function renderBoundaryToClient(
2471
2472
  for (const css of manifestRoute.css) {
2472
2473
  headBlob += await cssHeadTag(css, publicPrefix);
2473
2474
  }
2474
- for (const chunk of manifestRoute.imports) {
2475
+ for (const chunk of modulepreloadFiles(manifestRoute)) {
2475
2476
  headBlob += `<link rel="modulepreload"${co} href="${publicPrefix}${chunk}">`;
2476
2477
  }
2477
2478
  } else {
@@ -0,0 +1,35 @@
1
+ // The route's modulepreload set is its whole static import graph, including
2
+ // chunk-to-chunk imports (a chunk imports its siblings as "./x.js").
3
+
4
+ import { afterEach, expect, test } from "bun:test";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { staticImportGraph } from "./ssr-client-bundler";
9
+
10
+ let dir = "";
11
+ afterEach(() => {
12
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
13
+ });
14
+
15
+ test("follows chunk-to-chunk imports, not dynamic ones", () => {
16
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-graph-"));
17
+ fs.mkdirSync(path.join(dir, "chunks"));
18
+ const write = (f: string, body: string) => fs.writeFileSync(path.join(dir, f), body);
19
+ write("client-entry-home-aaaa1111.js", `import{a}from"./chunks/a-11111111.js";import"./chunks/b-22222222.js";a();`);
20
+ write("chunks/a-11111111.js", `import{c as d}from"./c-33333333.js";export const a=()=>d();`);
21
+ write("chunks/b-22222222.js", `import"./c-33333333.js";`);
22
+ write("chunks/c-33333333.js", `import"./d-44444444.js";export const c=1;const later=()=>import("./lazy-55555555.js");`);
23
+ write("chunks/d-44444444.js", `export const d=1;`);
24
+ write("chunks/lazy-55555555.js", `export const lazy=1;`);
25
+ const chunks = new Set(
26
+ ["a-11111111", "b-22222222", "c-33333333", "d-44444444", "lazy-55555555"].map((c) => `chunks/${c}.js`),
27
+ );
28
+ const graph = staticImportGraph(fs, path, dir, path.join(dir, "client-entry-home-aaaa1111.js"), chunks);
29
+ expect([...graph]).toEqual([
30
+ "chunks/a-11111111.js",
31
+ "chunks/b-22222222.js",
32
+ "chunks/c-33333333.js",
33
+ "chunks/d-44444444.js",
34
+ ]);
35
+ });
package/src/types.ts CHANGED
@@ -284,6 +284,26 @@ export interface DbWriter extends DbReader {
284
284
  data: Record<string, unknown>
285
285
  ): Promise<boolean>;
286
286
 
287
+ /**
288
+ * Insert many rows of one entity. Returns their IDs, in order. Same
289
+ * result as calling `insert` per row, with fewer database round trips:
290
+ * use it for imports and other loops that write many rows. When a row
291
+ * fails, the call throws; let the error end the mutation so it rolls
292
+ * back.
293
+ */
294
+ insertMany(entity: string, rows: Record<string, unknown>[]): Promise<string[]>;
295
+
296
+ /**
297
+ * Update many rows of one entity. Returns, per row and in order, true if
298
+ * the row existed. Same result as calling `update` per row, with fewer
299
+ * database round trips. When a row fails, the call throws; let the error
300
+ * end the mutation so it rolls back.
301
+ */
302
+ updateMany(
303
+ entity: string,
304
+ rows: { id: string; data: Record<string, unknown> }[]
305
+ ): Promise<boolean[]>;
306
+
287
307
  /** Delete a row by ID. Returns true if the row existed. */
288
308
  delete(entity: string, id: string): Promise<boolean>;
289
309