@pylonsync/functions 0.22.58 → 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.
- package/dist/ssr-client-bundler.d.ts +27 -0
- package/dist/ssr-client-nav.d.ts +7 -0
- package/dist/ssr-route-assets.d.ts +20 -1
- package/dist/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/runtime-db.test.ts +12 -0
- package/src/runtime.ts +22 -0
- package/src/ssr-client-bundler.ts +69 -49
- package/src/ssr-client-nav.test.ts +7 -0
- package/src/ssr-client-nav.ts +42 -1
- package/src/ssr-offline-shell.test.ts +26 -0
- package/src/ssr-route-assets.ts +24 -3
- package/src/ssr-runtime.ts +2 -1
- package/src/ssr-static-graph.test.ts +35 -0
- package/src/types.ts +20 -0
|
@@ -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 {};
|
package/dist/ssr-client-nav.d.ts
CHANGED
|
@@ -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.
|
|
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": "^
|
|
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"
|
package/src/runtime-db.test.ts
CHANGED
|
@@ -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(
|
|
1380
|
-
/\bexport\s*\{[^}]*\bmetadata\b[^}]*\}/.test(
|
|
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
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
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
|
-
//
|
|
2068
|
-
//
|
|
2069
|
-
|
|
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
|
});
|
package/src/ssr-client-nav.ts
CHANGED
|
@@ -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(
|
|
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
|
+
});
|
package/src/ssr-route-assets.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
|
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
|
|