@moku-labs/web 2.4.11 → 2.4.13

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.
@@ -1998,6 +1998,12 @@ interface DataState {
1998
1998
  lastWrite: DataWriteSummary | null;
1999
1999
  /** Per-path fetched data, cached after the first `at(path)` (browser). */
2000
2000
  cache: Map<string, unknown>;
2001
+ /**
2002
+ * Per-path fetches still in flight (browser). A second `at(path)` awaits the
2003
+ * same promise instead of starting another request. The entry is removed when
2004
+ * the fetch settles, on success and on failure, so a failure is never cached.
2005
+ */
2006
+ pending: Map<string, Promise<unknown | null>>;
2001
2007
  }
2002
2008
  /**
2003
2009
  * Public API mounted at `app.data` — the agnostic data provider. `write()` is the
package/dist/browser.mjs CHANGED
@@ -2367,6 +2367,30 @@ async function loadJson(pathOrUrl) {
2367
2367
  * the isomorphic `loadJson` (whose Node branch is itself lazy).
2368
2368
  */
2369
2369
  /**
2370
+ * Fetches one path's data, caches it on success, and always clears the path's
2371
+ * in-flight entry. Never rejects: a failed fetch or JSON parse resolves `null`
2372
+ * and caches nothing, so a later `at(path)` retries.
2373
+ *
2374
+ * @param ctx - The data plugin context.
2375
+ * @param path - The page URL path (e.g. `/en/hello/`).
2376
+ * @returns The page's raw data, or `null` on failure.
2377
+ * @example
2378
+ * ```ts
2379
+ * const raw = await fetchAndCache(ctx, "/en/hello/");
2380
+ * ```
2381
+ */
2382
+ async function fetchAndCache(ctx, path) {
2383
+ try {
2384
+ const data = await loadJson(`${ctx.config.baseUrl}${dataSuffix(path)}`);
2385
+ ctx.state.cache.set(path, data);
2386
+ return data;
2387
+ } catch {
2388
+ return null;
2389
+ } finally {
2390
+ ctx.state.pending.delete(path);
2391
+ }
2392
+ }
2393
+ /**
2370
2394
  * Builds the data provider — the agnostic bridge. `write()` is the Node persist
2371
2395
  * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
2372
2396
  * convention. No `onStart`/`onStop` (holds no long-lived resource).
@@ -2384,6 +2408,7 @@ function dataApi(ctx) {
2384
2408
  return {
2385
2409
  /**
2386
2410
  * READ (browser) — fetch (and cache) the persisted data for a page path.
2411
+ * Concurrent calls for one path share one in-flight request.
2387
2412
  * Returns the raw JSON as `unknown`, which the route uses directly as `ctx.data`
2388
2413
  * (no route `.parse()`); returns `null` if the fetch or JSON parse fails (so
2389
2414
  * `spa` can fall back to HTML).
@@ -2397,13 +2422,11 @@ function dataApi(ctx) {
2397
2422
  */
2398
2423
  async at(path) {
2399
2424
  if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
2400
- try {
2401
- const data = await loadJson(`${ctx.config.baseUrl}${dataSuffix(path)}`);
2402
- ctx.state.cache.set(path, data);
2403
- return data;
2404
- } catch {
2405
- return null;
2406
- }
2425
+ const inFlight = ctx.state.pending.get(path);
2426
+ if (inFlight) return inFlight;
2427
+ const request = fetchAndCache(ctx, path);
2428
+ ctx.state.pending.set(path, request);
2429
+ return request;
2407
2430
  },
2408
2431
  /**
2409
2432
  * WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
@@ -2472,13 +2495,13 @@ const defaultDataConfig = {
2472
2495
  //#region src/plugins/data/state.ts
2473
2496
  /**
2474
2497
  * Creates initial data state: a null `lastWrite` slot (populated by the Node
2475
- * `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
2476
- * side on first fetch).
2498
+ * `write()` side), an empty `cache` (populated lazily by the browser `at(path)`
2499
+ * side on first fetch) and an empty `pending` map of in-flight fetches.
2477
2500
  *
2478
2501
  * @param _ctx - Minimal context with global and config.
2479
2502
  * @param _ctx.global - Global framework configuration.
2480
2503
  * @param _ctx.config - Resolved plugin configuration.
2481
- * @returns Fresh data state with no recorded write and an empty per-path cache.
2504
+ * @returns Fresh data state with no recorded write and an empty per-path cache and no pending fetch.
2482
2505
  * @example
2483
2506
  * ```ts
2484
2507
  * const state = createDataState({ global: {}, config });
@@ -2487,7 +2510,8 @@ const defaultDataConfig = {
2487
2510
  function createDataState(_ctx) {
2488
2511
  return {
2489
2512
  lastWrite: null,
2490
- cache: /* @__PURE__ */ new Map()
2513
+ cache: /* @__PURE__ */ new Map(),
2514
+ pending: /* @__PURE__ */ new Map()
2491
2515
  };
2492
2516
  }
2493
2517
  //#endregion
package/dist/index.cjs CHANGED
@@ -2299,7 +2299,8 @@ const headPlugin = createPlugin$1("head", {
2299
2299
  /**
2300
2300
  * @file build phase 1 — bundle. Runs `Bun.build` for CSS and JS separately into
2301
2301
  * outDir (honoring `config.minify`) with content-hashed output naming; caches the
2302
- * fingerprinted asset paths for the pages phase and the complete output list for
2302
+ * fingerprinted asset paths for the pages phase, the chunks the JS entry imports
2303
+ * statically (for `<link rel="modulepreload">`), and the complete output list for
2303
2304
  * the cache-headers phase.
2304
2305
  */
2305
2306
  /** Conventional CSS entry candidates (project-relative). */
@@ -2358,10 +2359,11 @@ const CSS_EXTERNAL_FONT_GLOBS = [
2358
2359
  * @param options.naming.asset - Naming template for additional emitted assets.
2359
2360
  * @param options.external - Import/url() globs left unresolved in the output.
2360
2361
  * @param options.define - Global names replaced with constant expressions.
2362
+ * @param options.metafile - Whether to return the build metafile.
2361
2363
  * @returns The structural build result.
2362
2364
  * @example
2363
2365
  * ```ts
2364
- * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING, external: [], define: {} });
2366
+ * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING, external: [], define: {}, metafile: false });
2365
2367
  * ```
2366
2368
  */
2367
2369
  async function defaultRunner(options) {
@@ -2456,6 +2458,73 @@ function normalizeAssetPath(absolutePath, outDir) {
2456
2458
  return node_path$1.default.relative(node_path$1.default.resolve(outDir), node_path$1.default.resolve(absolutePath)).split(node_path$1.default.sep).join("/");
2457
2459
  }
2458
2460
  /**
2461
+ * Parse the runner's metafile into the structural view (object or JSON string).
2462
+ *
2463
+ * @param metafile - The metafile as the runner returned it (possibly absent).
2464
+ * @returns The parsed metafile, or `undefined` when absent or unreadable.
2465
+ * @example
2466
+ * ```ts
2467
+ * parseMetafile('{"outputs":{}}'); // { outputs: {} }
2468
+ * ```
2469
+ */
2470
+ function parseMetafile(metafile) {
2471
+ if (typeof metafile !== "string") return metafile;
2472
+ try {
2473
+ return JSON.parse(metafile);
2474
+ } catch {
2475
+ return;
2476
+ }
2477
+ }
2478
+ /**
2479
+ * Normalize an outdir-relative output path into one graph key ("./a.js" and "a.js"
2480
+ * are the same file; Windows separators become "/").
2481
+ *
2482
+ * @param file - An outdir-relative output path.
2483
+ * @returns The normalized POSIX key.
2484
+ * @example
2485
+ * ```ts
2486
+ * toKey("./chunk-1.js"); // "chunk-1.js"
2487
+ * ```
2488
+ */
2489
+ function toKey(file) {
2490
+ return node_path$1.default.posix.normalize(file.split(node_path$1.default.sep).join("/"));
2491
+ }
2492
+ /**
2493
+ * Collect the chunks the JS entry points import STATICALLY, transitively, from the
2494
+ * metafile import graph. Only `import-statement` edges are followed: a chunk reached
2495
+ * only through a dynamic `import()` stays lazy and is never preloaded. These are the
2496
+ * files the browser would otherwise discover only after the entry finished
2497
+ * downloading (a request waterfall); the pages phase emits one
2498
+ * `<link rel="modulepreload">` per path so they load in parallel with the entry.
2499
+ * The walk starts from the `entry-point` artifacts, not from the metafile: Bun also
2500
+ * tags each lazy chunk with an `entryPoint`, and a lazy chunk may import the entry.
2501
+ *
2502
+ * @param result - The JS pass result (artifacts + metafile; no metafile → no preloads).
2503
+ * @param outdir - The bundler output directory the metafile paths are relative to.
2504
+ * @param outDir - The publish root the web paths are made relative to.
2505
+ * @returns The publish-root-relative paths of the static chunks (entries excluded).
2506
+ * @example
2507
+ * ```ts
2508
+ * staticEntryChunks(result, "dist/assets", "dist"); // ["assets/chunk-9f8e.js"]
2509
+ * ```
2510
+ */
2511
+ function staticEntryChunks(result, outdir, outDir) {
2512
+ const metafile = parseMetafile(result.metafile);
2513
+ if (!metafile) return [];
2514
+ const graph = new Map(Object.entries(metafile.outputs).map(([file, output]) => [toKey(file), (output.imports ?? []).filter((edge) => edge.kind === "import-statement").map((edge) => toKey(edge.path))]));
2515
+ const entries = result.outputs.filter((output) => output.kind === "entry-point" && output.path.endsWith(".js")).map((output) => toKey(node_path$1.default.relative(node_path$1.default.resolve(outdir), node_path$1.default.resolve(output.path))));
2516
+ const seen = new Set(entries);
2517
+ const queue = [...entries];
2518
+ const chunks = [];
2519
+ for (let file = queue.shift(); file !== void 0; file = queue.shift()) for (const target of graph.get(file) ?? []) {
2520
+ if (seen.has(target)) continue;
2521
+ seen.add(target);
2522
+ queue.push(target);
2523
+ chunks.push(normalizeAssetPath(node_path$1.default.join(outdir, target), outDir));
2524
+ }
2525
+ return chunks;
2526
+ }
2527
+ /**
2459
2528
  * Run one bundler pass for a single asset kind and record the hashed output
2460
2529
  * paths under `state.buildCache` keyed by the original entry basename. Lazy
2461
2530
  * split chunks are emitted to disk but excluded from the recorded manifest
@@ -2484,7 +2553,8 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify, de
2484
2553
  target: "browser",
2485
2554
  naming: FINGERPRINT_NAMING,
2486
2555
  external: kind === "css" ? [...CSS_EXTERNAL_FONT_GLOBS] : [],
2487
- define
2556
+ define,
2557
+ metafile: kind === "js"
2488
2558
  });
2489
2559
  if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
2490
2560
  const hashed = {};
@@ -2494,6 +2564,7 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify, de
2494
2564
  }
2495
2565
  ctx.state.buildCache.set(kind, hashed);
2496
2566
  ctx.state.buildCache.set(`${kind}:outputs`, result.outputs.map((output) => normalizeAssetPath(output.path, outDir)));
2567
+ if (kind === "js") ctx.state.buildCache.set("js:preload", staticEntryChunks(result, outdir, outDir));
2497
2568
  ctx.log.debug("build:bundle", {
2498
2569
  kind,
2499
2570
  count: result.outputs.length
@@ -2570,6 +2641,22 @@ function readBundleOutputs(ctx, kind) {
2570
2641
  return Array.isArray(entry) ? entry : [];
2571
2642
  }
2572
2643
  /**
2644
+ * Read the chunks the JS entry imports statically (publish-root-relative web paths),
2645
+ * recorded by the bundle phase under `js:preload`. Dynamic-import-only chunks are
2646
+ * never in this list.
2647
+ *
2648
+ * @param ctx - Plugin context (provides `state`).
2649
+ * @returns The static chunk paths, or an empty array when absent.
2650
+ * @example
2651
+ * ```ts
2652
+ * readPreloads(ctx); // ["assets/chunk-9f8e.js"]
2653
+ * ```
2654
+ */
2655
+ function readPreloads(ctx) {
2656
+ const entry = ctx.state.buildCache.get("js:preload");
2657
+ return Array.isArray(entry) ? entry : [];
2658
+ }
2659
+ /**
2573
2660
  * Render the stylesheet `<link>` tags for the fingerprinted CSS manifest.
2574
2661
  *
2575
2662
  * @param ctx - Plugin context (provides `state`).
@@ -2583,17 +2670,23 @@ function buildCssTags(ctx) {
2583
2670
  return Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`).join("");
2584
2671
  }
2585
2672
  /**
2586
- * Render the module `<script>` tags for the fingerprinted JS manifest.
2673
+ * Render the module `<script>` tags for the fingerprinted JS manifest, followed by
2674
+ * one `<link rel="modulepreload">` per chunk the entry imports statically. Without
2675
+ * the preloads the browser finds those chunks only after the entry has downloaded
2676
+ * and parsed, a request waterfall that delays hydration.
2587
2677
  *
2588
2678
  * @param ctx - Plugin context (provides `state`).
2589
- * @returns The concatenated `<script type="module">` tags (possibly `""`).
2679
+ * @returns The concatenated `<script type="module">` and modulepreload tags (possibly `""`).
2590
2680
  * @example
2591
2681
  * ```ts
2592
- * buildJsTags(ctx); // '<script type="module" src="/assets/spa-abc123.js"><\/script>'
2682
+ * buildJsTags(ctx);
2683
+ * // '<script type="module" src="/assets/spa-abc123.js"><\/script><link rel="modulepreload" href="/assets/chunk-9f8e.js">'
2593
2684
  * ```
2594
2685
  */
2595
2686
  function buildJsTags(ctx) {
2596
- return Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`).join("");
2687
+ const scripts = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`).join("");
2688
+ if (scripts === "") return "";
2689
+ return scripts + readPreloads(ctx).map((href) => `<link rel="modulepreload" href="/${href}">`).join("");
2597
2690
  }
2598
2691
  /**
2599
2692
  * Build the asset tag block from the fingerprinted manifests — both kinds by
@@ -4532,6 +4625,30 @@ async function loadJson(pathOrUrl) {
4532
4625
  * the isomorphic `loadJson` (whose Node branch is itself lazy).
4533
4626
  */
4534
4627
  /**
4628
+ * Fetches one path's data, caches it on success, and always clears the path's
4629
+ * in-flight entry. Never rejects: a failed fetch or JSON parse resolves `null`
4630
+ * and caches nothing, so a later `at(path)` retries.
4631
+ *
4632
+ * @param ctx - The data plugin context.
4633
+ * @param path - The page URL path (e.g. `/en/hello/`).
4634
+ * @returns The page's raw data, or `null` on failure.
4635
+ * @example
4636
+ * ```ts
4637
+ * const raw = await fetchAndCache(ctx, "/en/hello/");
4638
+ * ```
4639
+ */
4640
+ async function fetchAndCache(ctx, path) {
4641
+ try {
4642
+ const data = await loadJson(`${ctx.config.baseUrl}${require_convention.dataSuffix(path)}`);
4643
+ ctx.state.cache.set(path, data);
4644
+ return data;
4645
+ } catch {
4646
+ return null;
4647
+ } finally {
4648
+ ctx.state.pending.delete(path);
4649
+ }
4650
+ }
4651
+ /**
4535
4652
  * Builds the data provider — the agnostic bridge. `write()` is the Node persist
4536
4653
  * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
4537
4654
  * convention. No `onStart`/`onStop` (holds no long-lived resource).
@@ -4549,6 +4666,7 @@ function dataApi(ctx) {
4549
4666
  return {
4550
4667
  /**
4551
4668
  * READ (browser) — fetch (and cache) the persisted data for a page path.
4669
+ * Concurrent calls for one path share one in-flight request.
4552
4670
  * Returns the raw JSON as `unknown`, which the route uses directly as `ctx.data`
4553
4671
  * (no route `.parse()`); returns `null` if the fetch or JSON parse fails (so
4554
4672
  * `spa` can fall back to HTML).
@@ -4562,13 +4680,11 @@ function dataApi(ctx) {
4562
4680
  */
4563
4681
  async at(path) {
4564
4682
  if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
4565
- try {
4566
- const data = await loadJson(`${ctx.config.baseUrl}${require_convention.dataSuffix(path)}`);
4567
- ctx.state.cache.set(path, data);
4568
- return data;
4569
- } catch {
4570
- return null;
4571
- }
4683
+ const inFlight = ctx.state.pending.get(path);
4684
+ if (inFlight) return inFlight;
4685
+ const request = fetchAndCache(ctx, path);
4686
+ ctx.state.pending.set(path, request);
4687
+ return request;
4572
4688
  },
4573
4689
  /**
4574
4690
  * WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
@@ -4637,13 +4753,13 @@ const defaultDataConfig = {
4637
4753
  //#region src/plugins/data/state.ts
4638
4754
  /**
4639
4755
  * Creates initial data state: a null `lastWrite` slot (populated by the Node
4640
- * `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
4641
- * side on first fetch).
4756
+ * `write()` side), an empty `cache` (populated lazily by the browser `at(path)`
4757
+ * side on first fetch) and an empty `pending` map of in-flight fetches.
4642
4758
  *
4643
4759
  * @param _ctx - Minimal context with global and config.
4644
4760
  * @param _ctx.global - Global framework configuration.
4645
4761
  * @param _ctx.config - Resolved plugin configuration.
4646
- * @returns Fresh data state with no recorded write and an empty per-path cache.
4762
+ * @returns Fresh data state with no recorded write and an empty per-path cache and no pending fetch.
4647
4763
  * @example
4648
4764
  * ```ts
4649
4765
  * const state = createDataState({ global: {}, config });
@@ -4652,7 +4768,8 @@ const defaultDataConfig = {
4652
4768
  function createDataState(_ctx) {
4653
4769
  return {
4654
4770
  lastWrite: null,
4655
- cache: /* @__PURE__ */ new Map()
4771
+ cache: /* @__PURE__ */ new Map(),
4772
+ pending: /* @__PURE__ */ new Map()
4656
4773
  };
4657
4774
  }
4658
4775
  //#endregion
package/dist/index.d.cts CHANGED
@@ -3467,6 +3467,12 @@ interface DataState {
3467
3467
  lastWrite: DataWriteSummary | null;
3468
3468
  /** Per-path fetched data, cached after the first `at(path)` (browser). */
3469
3469
  cache: Map<string, unknown>;
3470
+ /**
3471
+ * Per-path fetches still in flight (browser). A second `at(path)` awaits the
3472
+ * same promise instead of starting another request. The entry is removed when
3473
+ * the fetch settles, on success and on failure, so a failure is never cached.
3474
+ */
3475
+ pending: Map<string, Promise<unknown | null>>;
3470
3476
  }
3471
3477
  /**
3472
3478
  * Public API mounted at `app.data` — the agnostic data provider. `write()` is the
package/dist/index.d.mts CHANGED
@@ -3465,6 +3465,12 @@ interface DataState {
3465
3465
  lastWrite: DataWriteSummary | null;
3466
3466
  /** Per-path fetched data, cached after the first `at(path)` (browser). */
3467
3467
  cache: Map<string, unknown>;
3468
+ /**
3469
+ * Per-path fetches still in flight (browser). A second `at(path)` awaits the
3470
+ * same promise instead of starting another request. The entry is removed when
3471
+ * the fetch settles, on success and on failure, so a failure is never cached.
3472
+ */
3473
+ pending: Map<string, Promise<unknown | null>>;
3468
3474
  }
3469
3475
  /**
3470
3476
  * Public API mounted at `app.data` — the agnostic data provider. `write()` is the
package/dist/index.mjs CHANGED
@@ -2255,7 +2255,8 @@ const headPlugin = createPlugin$1("head", {
2255
2255
  /**
2256
2256
  * @file build phase 1 — bundle. Runs `Bun.build` for CSS and JS separately into
2257
2257
  * outDir (honoring `config.minify`) with content-hashed output naming; caches the
2258
- * fingerprinted asset paths for the pages phase and the complete output list for
2258
+ * fingerprinted asset paths for the pages phase, the chunks the JS entry imports
2259
+ * statically (for `<link rel="modulepreload">`), and the complete output list for
2259
2260
  * the cache-headers phase.
2260
2261
  */
2261
2262
  /** Conventional CSS entry candidates (project-relative). */
@@ -2314,10 +2315,11 @@ const CSS_EXTERNAL_FONT_GLOBS = [
2314
2315
  * @param options.naming.asset - Naming template for additional emitted assets.
2315
2316
  * @param options.external - Import/url() globs left unresolved in the output.
2316
2317
  * @param options.define - Global names replaced with constant expressions.
2318
+ * @param options.metafile - Whether to return the build metafile.
2317
2319
  * @returns The structural build result.
2318
2320
  * @example
2319
2321
  * ```ts
2320
- * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING, external: [], define: {} });
2322
+ * await defaultRunner({ entrypoints: ["a.css"], outdir: "dist", minify: true, splitting: true, target: "browser", naming: FINGERPRINT_NAMING, external: [], define: {}, metafile: false });
2321
2323
  * ```
2322
2324
  */
2323
2325
  async function defaultRunner(options) {
@@ -2412,6 +2414,73 @@ function normalizeAssetPath(absolutePath, outDir) {
2412
2414
  return path.relative(path.resolve(outDir), path.resolve(absolutePath)).split(path.sep).join("/");
2413
2415
  }
2414
2416
  /**
2417
+ * Parse the runner's metafile into the structural view (object or JSON string).
2418
+ *
2419
+ * @param metafile - The metafile as the runner returned it (possibly absent).
2420
+ * @returns The parsed metafile, or `undefined` when absent or unreadable.
2421
+ * @example
2422
+ * ```ts
2423
+ * parseMetafile('{"outputs":{}}'); // { outputs: {} }
2424
+ * ```
2425
+ */
2426
+ function parseMetafile(metafile) {
2427
+ if (typeof metafile !== "string") return metafile;
2428
+ try {
2429
+ return JSON.parse(metafile);
2430
+ } catch {
2431
+ return;
2432
+ }
2433
+ }
2434
+ /**
2435
+ * Normalize an outdir-relative output path into one graph key ("./a.js" and "a.js"
2436
+ * are the same file; Windows separators become "/").
2437
+ *
2438
+ * @param file - An outdir-relative output path.
2439
+ * @returns The normalized POSIX key.
2440
+ * @example
2441
+ * ```ts
2442
+ * toKey("./chunk-1.js"); // "chunk-1.js"
2443
+ * ```
2444
+ */
2445
+ function toKey(file) {
2446
+ return path.posix.normalize(file.split(path.sep).join("/"));
2447
+ }
2448
+ /**
2449
+ * Collect the chunks the JS entry points import STATICALLY, transitively, from the
2450
+ * metafile import graph. Only `import-statement` edges are followed: a chunk reached
2451
+ * only through a dynamic `import()` stays lazy and is never preloaded. These are the
2452
+ * files the browser would otherwise discover only after the entry finished
2453
+ * downloading (a request waterfall); the pages phase emits one
2454
+ * `<link rel="modulepreload">` per path so they load in parallel with the entry.
2455
+ * The walk starts from the `entry-point` artifacts, not from the metafile: Bun also
2456
+ * tags each lazy chunk with an `entryPoint`, and a lazy chunk may import the entry.
2457
+ *
2458
+ * @param result - The JS pass result (artifacts + metafile; no metafile → no preloads).
2459
+ * @param outdir - The bundler output directory the metafile paths are relative to.
2460
+ * @param outDir - The publish root the web paths are made relative to.
2461
+ * @returns The publish-root-relative paths of the static chunks (entries excluded).
2462
+ * @example
2463
+ * ```ts
2464
+ * staticEntryChunks(result, "dist/assets", "dist"); // ["assets/chunk-9f8e.js"]
2465
+ * ```
2466
+ */
2467
+ function staticEntryChunks(result, outdir, outDir) {
2468
+ const metafile = parseMetafile(result.metafile);
2469
+ if (!metafile) return [];
2470
+ const graph = new Map(Object.entries(metafile.outputs).map(([file, output]) => [toKey(file), (output.imports ?? []).filter((edge) => edge.kind === "import-statement").map((edge) => toKey(edge.path))]));
2471
+ const entries = result.outputs.filter((output) => output.kind === "entry-point" && output.path.endsWith(".js")).map((output) => toKey(path.relative(path.resolve(outdir), path.resolve(output.path))));
2472
+ const seen = new Set(entries);
2473
+ const queue = [...entries];
2474
+ const chunks = [];
2475
+ for (let file = queue.shift(); file !== void 0; file = queue.shift()) for (const target of graph.get(file) ?? []) {
2476
+ if (seen.has(target)) continue;
2477
+ seen.add(target);
2478
+ queue.push(target);
2479
+ chunks.push(normalizeAssetPath(path.join(outdir, target), outDir));
2480
+ }
2481
+ return chunks;
2482
+ }
2483
+ /**
2415
2484
  * Run one bundler pass for a single asset kind and record the hashed output
2416
2485
  * paths under `state.buildCache` keyed by the original entry basename. Lazy
2417
2486
  * split chunks are emitted to disk but excluded from the recorded manifest
@@ -2440,7 +2509,8 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify, de
2440
2509
  target: "browser",
2441
2510
  naming: FINGERPRINT_NAMING,
2442
2511
  external: kind === "css" ? [...CSS_EXTERNAL_FONT_GLOBS] : [],
2443
- define
2512
+ define,
2513
+ metafile: kind === "js"
2444
2514
  });
2445
2515
  if (!result.success) throw new Error(`[web] build.bundle ${kind} build failed`);
2446
2516
  const hashed = {};
@@ -2450,6 +2520,7 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify, de
2450
2520
  }
2451
2521
  ctx.state.buildCache.set(kind, hashed);
2452
2522
  ctx.state.buildCache.set(`${kind}:outputs`, result.outputs.map((output) => normalizeAssetPath(output.path, outDir)));
2523
+ if (kind === "js") ctx.state.buildCache.set("js:preload", staticEntryChunks(result, outdir, outDir));
2453
2524
  ctx.log.debug("build:bundle", {
2454
2525
  kind,
2455
2526
  count: result.outputs.length
@@ -2526,6 +2597,22 @@ function readBundleOutputs(ctx, kind) {
2526
2597
  return Array.isArray(entry) ? entry : [];
2527
2598
  }
2528
2599
  /**
2600
+ * Read the chunks the JS entry imports statically (publish-root-relative web paths),
2601
+ * recorded by the bundle phase under `js:preload`. Dynamic-import-only chunks are
2602
+ * never in this list.
2603
+ *
2604
+ * @param ctx - Plugin context (provides `state`).
2605
+ * @returns The static chunk paths, or an empty array when absent.
2606
+ * @example
2607
+ * ```ts
2608
+ * readPreloads(ctx); // ["assets/chunk-9f8e.js"]
2609
+ * ```
2610
+ */
2611
+ function readPreloads(ctx) {
2612
+ const entry = ctx.state.buildCache.get("js:preload");
2613
+ return Array.isArray(entry) ? entry : [];
2614
+ }
2615
+ /**
2529
2616
  * Render the stylesheet `<link>` tags for the fingerprinted CSS manifest.
2530
2617
  *
2531
2618
  * @param ctx - Plugin context (provides `state`).
@@ -2539,17 +2626,23 @@ function buildCssTags(ctx) {
2539
2626
  return Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`).join("");
2540
2627
  }
2541
2628
  /**
2542
- * Render the module `<script>` tags for the fingerprinted JS manifest.
2629
+ * Render the module `<script>` tags for the fingerprinted JS manifest, followed by
2630
+ * one `<link rel="modulepreload">` per chunk the entry imports statically. Without
2631
+ * the preloads the browser finds those chunks only after the entry has downloaded
2632
+ * and parsed, a request waterfall that delays hydration.
2543
2633
  *
2544
2634
  * @param ctx - Plugin context (provides `state`).
2545
- * @returns The concatenated `<script type="module">` tags (possibly `""`).
2635
+ * @returns The concatenated `<script type="module">` and modulepreload tags (possibly `""`).
2546
2636
  * @example
2547
2637
  * ```ts
2548
- * buildJsTags(ctx); // '<script type="module" src="/assets/spa-abc123.js"><\/script>'
2638
+ * buildJsTags(ctx);
2639
+ * // '<script type="module" src="/assets/spa-abc123.js"><\/script><link rel="modulepreload" href="/assets/chunk-9f8e.js">'
2549
2640
  * ```
2550
2641
  */
2551
2642
  function buildJsTags(ctx) {
2552
- return Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`).join("");
2643
+ const scripts = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`).join("");
2644
+ if (scripts === "") return "";
2645
+ return scripts + readPreloads(ctx).map((href) => `<link rel="modulepreload" href="/${href}">`).join("");
2553
2646
  }
2554
2647
  /**
2555
2648
  * Build the asset tag block from the fingerprinted manifests — both kinds by
@@ -4488,6 +4581,30 @@ async function loadJson(pathOrUrl) {
4488
4581
  * the isomorphic `loadJson` (whose Node branch is itself lazy).
4489
4582
  */
4490
4583
  /**
4584
+ * Fetches one path's data, caches it on success, and always clears the path's
4585
+ * in-flight entry. Never rejects: a failed fetch or JSON parse resolves `null`
4586
+ * and caches nothing, so a later `at(path)` retries.
4587
+ *
4588
+ * @param ctx - The data plugin context.
4589
+ * @param path - The page URL path (e.g. `/en/hello/`).
4590
+ * @returns The page's raw data, or `null` on failure.
4591
+ * @example
4592
+ * ```ts
4593
+ * const raw = await fetchAndCache(ctx, "/en/hello/");
4594
+ * ```
4595
+ */
4596
+ async function fetchAndCache(ctx, path) {
4597
+ try {
4598
+ const data = await loadJson(`${ctx.config.baseUrl}${dataSuffix(path)}`);
4599
+ ctx.state.cache.set(path, data);
4600
+ return data;
4601
+ } catch {
4602
+ return null;
4603
+ } finally {
4604
+ ctx.state.pending.delete(path);
4605
+ }
4606
+ }
4607
+ /**
4491
4608
  * Builds the data provider — the agnostic bridge. `write()` is the Node persist
4492
4609
  * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
4493
4610
  * convention. No `onStart`/`onStop` (holds no long-lived resource).
@@ -4505,6 +4622,7 @@ function dataApi(ctx) {
4505
4622
  return {
4506
4623
  /**
4507
4624
  * READ (browser) — fetch (and cache) the persisted data for a page path.
4625
+ * Concurrent calls for one path share one in-flight request.
4508
4626
  * Returns the raw JSON as `unknown`, which the route uses directly as `ctx.data`
4509
4627
  * (no route `.parse()`); returns `null` if the fetch or JSON parse fails (so
4510
4628
  * `spa` can fall back to HTML).
@@ -4518,13 +4636,11 @@ function dataApi(ctx) {
4518
4636
  */
4519
4637
  async at(path) {
4520
4638
  if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
4521
- try {
4522
- const data = await loadJson(`${ctx.config.baseUrl}${dataSuffix(path)}`);
4523
- ctx.state.cache.set(path, data);
4524
- return data;
4525
- } catch {
4526
- return null;
4527
- }
4639
+ const inFlight = ctx.state.pending.get(path);
4640
+ if (inFlight) return inFlight;
4641
+ const request = fetchAndCache(ctx, path);
4642
+ ctx.state.pending.set(path, request);
4643
+ return request;
4528
4644
  },
4529
4645
  /**
4530
4646
  * WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
@@ -4593,13 +4709,13 @@ const defaultDataConfig = {
4593
4709
  //#region src/plugins/data/state.ts
4594
4710
  /**
4595
4711
  * Creates initial data state: a null `lastWrite` slot (populated by the Node
4596
- * `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
4597
- * side on first fetch).
4712
+ * `write()` side), an empty `cache` (populated lazily by the browser `at(path)`
4713
+ * side on first fetch) and an empty `pending` map of in-flight fetches.
4598
4714
  *
4599
4715
  * @param _ctx - Minimal context with global and config.
4600
4716
  * @param _ctx.global - Global framework configuration.
4601
4717
  * @param _ctx.config - Resolved plugin configuration.
4602
- * @returns Fresh data state with no recorded write and an empty per-path cache.
4718
+ * @returns Fresh data state with no recorded write and an empty per-path cache and no pending fetch.
4603
4719
  * @example
4604
4720
  * ```ts
4605
4721
  * const state = createDataState({ global: {}, config });
@@ -4608,7 +4724,8 @@ const defaultDataConfig = {
4608
4724
  function createDataState(_ctx) {
4609
4725
  return {
4610
4726
  lastWrite: null,
4611
- cache: /* @__PURE__ */ new Map()
4727
+ cache: /* @__PURE__ */ new Map(),
4728
+ pending: /* @__PURE__ */ new Map()
4612
4729
  };
4613
4730
  }
4614
4731
  //#endregion
package/package.json CHANGED
@@ -139,5 +139,5 @@
139
139
  "typescript-eslint": "8.58.0",
140
140
  "vitest": "4.0.18"
141
141
  },
142
- "version": "2.4.11"
142
+ "version": "2.4.13"
143
143
  }