@moku-labs/web 2.4.13 → 2.4.14

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.
@@ -1931,7 +1931,7 @@ declare const spaPlugin: import("@moku-labs/core").PluginInstance<"spa", SpaConf
1931
1931
  };
1932
1932
  }> & Record<never, never>;
1933
1933
  declare namespace types_d_exports$2 {
1934
- export { DataConfig, DataEntry, DataProvider, DataState, DataWriteSummary };
1934
+ export { DataConfig, DataEntry, DataPreload, DataProvider, DataState, DataWriteSummary };
1935
1935
  }
1936
1936
  /**
1937
1937
  * @file data plugin — type definitions (Standard tier).
@@ -1951,6 +1951,12 @@ declare namespace types_d_exports$2 {
1951
1951
  * inside `write()`, so composing `data` in a browser app keeps the bundle free of
1952
1952
  * `node:*`.
1953
1953
  */
1954
+ /**
1955
+ * How the browser loads page data. `"none"` fetches one page file per navigation
1956
+ * (lazy). `"all"` also loads every page's data once, from one bundle file, after
1957
+ * the app starts and the main thread is idle.
1958
+ */
1959
+ type DataPreload = "none" | "all";
1954
1960
  /**
1955
1961
  * Configuration for {@link dataPlugin}. All fields have defaults (see `./config`).
1956
1962
  *
@@ -1971,6 +1977,13 @@ type DataConfig = {
1971
1977
  * path); keep them consistent (`"/" + trim(outputDir) + "/"`). Default `"/_data/"`.
1972
1978
  */
1973
1979
  baseUrl: string;
1980
+ /**
1981
+ * BOTH sides. `"all"`: the Node `write()` also writes one bundle file
1982
+ * `<outputDir>/_all.json` (`{ "<page path>": data }`), and the browser fetches
1983
+ * it once when idle and fills the `at()` cache. Set the same value in the Node
1984
+ * app and the browser app. Default `"none"`: no bundle, lazy per-page fetches.
1985
+ */
1986
+ preload?: DataPreload;
1974
1987
  };
1975
1988
  /** One page's data to persist — `build` produces these from its route expansion. */
1976
1989
  interface DataEntry {
@@ -1987,6 +2000,11 @@ interface DataWriteSummary {
1987
2000
  bytes: number;
1988
2001
  /** The written file paths, relative to the build `outDir`. */
1989
2002
  files: string[];
2003
+ /** The preload bundle, present only when `preload` is `"all"`. Not counted in `fileCount`/`bytes`. */
2004
+ bundle?: {
2005
+ file: string;
2006
+ bytes: number;
2007
+ };
1990
2008
  }
1991
2009
  /**
1992
2010
  * Internal data state. `lastWrite` records the most recent `write()` (Node);
@@ -2004,6 +2022,14 @@ interface DataState {
2004
2022
  * the fetch settles, on success and on failure, so a failure is never cached.
2005
2023
  */
2006
2024
  pending: Map<string, Promise<unknown | null>>;
2025
+ /**
2026
+ * The preload bundle fetch while it is in flight (browser, `preload: "all"`).
2027
+ * `at()` awaits it before fetching a single file. Never rejects. `null` when no
2028
+ * bundle fetch is running.
2029
+ */
2030
+ bundle: Promise<void> | null;
2031
+ /** Cancels the scheduled idle callback or aborts the bundle fetch. `null` when nothing is scheduled. */
2032
+ cancelPreload: (() => void) | null;
2007
2033
  }
2008
2034
  /**
2009
2035
  * Public API mounted at `app.data` — the agnostic data provider. `write()` is the
package/dist/browser.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
2
- import { n as relativeDataFile, t as dataSuffix } from "./convention-Dp650o3y.mjs";
2
+ import { n as dataSuffix, r as relativeDataFile, t as BUNDLE_FILE } from "./convention-BtVf429P.mjs";
3
3
  import { envPlugin as envPlugin$1, logPlugin as logPlugin$1 } from "@moku-labs/common";
4
4
  import { createCoreConfig } from "@moku-labs/core";
5
5
  import { browserEnv, browserEnv as browserEnv$1, envPlugin, logPlugin } from "@moku-labs/common/browser";
@@ -2393,7 +2393,7 @@ async function fetchAndCache(ctx, path) {
2393
2393
  /**
2394
2394
  * Builds the data provider — the agnostic bridge. `write()` is the Node persist
2395
2395
  * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
2396
- * convention. No `onStart`/`onStop` (holds no long-lived resource).
2396
+ * convention. The optional preload lives in `./preload` (started by `onStart`).
2397
2397
  *
2398
2398
  * @param ctx - The data plugin context.
2399
2399
  * @returns The {@link DataProvider} mounted at `app.data`.
@@ -2408,7 +2408,9 @@ function dataApi(ctx) {
2408
2408
  return {
2409
2409
  /**
2410
2410
  * READ (browser) — fetch (and cache) the persisted data for a page path.
2411
- * Concurrent calls for one path share one in-flight request.
2411
+ * Concurrent calls for one path share one in-flight request. While a preload
2412
+ * bundle is in flight, it waits for the bundle and fetches the single file
2413
+ * only if the bundle did not bring the path.
2412
2414
  * Returns the raw JSON as `unknown`, which the route uses directly as `ctx.data`
2413
2415
  * (no route `.parse()`); returns `null` if the fetch or JSON parse fails (so
2414
2416
  * `spa` can fall back to HTML).
@@ -2422,6 +2424,10 @@ function dataApi(ctx) {
2422
2424
  */
2423
2425
  async at(path) {
2424
2426
  if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
2427
+ if (ctx.state.bundle) {
2428
+ await ctx.state.bundle;
2429
+ if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
2430
+ }
2425
2431
  const inFlight = ctx.state.pending.get(path);
2426
2432
  if (inFlight) return inFlight;
2427
2433
  const request = fetchAndCache(ctx, path);
@@ -2443,7 +2449,7 @@ function dataApi(ctx) {
2443
2449
  * ```
2444
2450
  */
2445
2451
  async write(entries, options) {
2446
- const { writeData } = await import("./writer-CaoyORyZ.mjs");
2452
+ const { writeData } = await import("./writer-Dwd02T9h.mjs");
2447
2453
  return writeData(ctx, entries, options);
2448
2454
  },
2449
2455
  /**
@@ -2480,7 +2486,7 @@ function dataApi(ctx) {
2480
2486
  * Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
2481
2487
  * (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
2482
2488
  * (site-root-relative) the browser fetches from — the defaults agree
2483
- * (`"_data"` ↔ `"/_data/"`).
2489
+ * (`"_data"` ↔ `"/_data/"`). `preload` is `"none"`: lazy per-page fetches only.
2484
2490
  *
2485
2491
  * @example
2486
2492
  * ```ts
@@ -2489,14 +2495,77 @@ function dataApi(ctx) {
2489
2495
  */
2490
2496
  const defaultDataConfig = {
2491
2497
  outputDir: "_data",
2492
- baseUrl: "/_data/"
2498
+ baseUrl: "/_data/",
2499
+ preload: "none"
2493
2500
  };
2494
2501
  //#endregion
2502
+ //#region src/plugins/data/preload.ts
2503
+ /**
2504
+ * Fetches the bundle and caches every path not already cached. Never rejects:
2505
+ * a failed fetch leaves lazy `at()` working. Clears the preload slots when done.
2506
+ *
2507
+ * @param ctx - The data plugin context.
2508
+ * @param signal - Aborts the fetch on stop.
2509
+ * @returns Resolves when the fill is done or failed.
2510
+ * @example
2511
+ * ```ts
2512
+ * await fill(ctx, signal);
2513
+ * ```
2514
+ */
2515
+ async function fill(ctx, signal) {
2516
+ try {
2517
+ const response = await fetch(`${ctx.config.baseUrl}${BUNDLE_FILE}`, { signal });
2518
+ const bundle = response.ok ? await response.json() : {};
2519
+ for (const [path, data] of Object.entries(bundle)) if (!signal.aborted && !ctx.state.cache.has(path)) ctx.state.cache.set(path, data);
2520
+ } catch {} finally {
2521
+ ctx.state.bundle = ctx.state.cancelPreload = null;
2522
+ }
2523
+ }
2524
+ /**
2525
+ * From `onStart`: when idle, fetch `<baseUrl>_all.json` once and fill the cache.
2526
+ * No-op on Node, with `preload: "none"`, with save-data on, or if already scheduled.
2527
+ *
2528
+ * @param ctx - The data plugin context.
2529
+ * @example
2530
+ * ```ts
2531
+ * startPreload(ctx);
2532
+ * ```
2533
+ */
2534
+ function startPreload(ctx) {
2535
+ const nav = globalThis.navigator;
2536
+ if (ctx.config.preload !== "all" || typeof document === "undefined" || ctx.state.cancelPreload !== null || nav?.connection?.saveData === true) return;
2537
+ const controller = new AbortController();
2538
+ const run = () => {
2539
+ ctx.state.bundle = fill(ctx, controller.signal);
2540
+ };
2541
+ const idle = typeof requestIdleCallback === "function";
2542
+ const handle = idle ? requestIdleCallback(run, { timeout: 2e3 }) : setTimeout(run, 0);
2543
+ ctx.state.cancelPreload = () => {
2544
+ if (idle) cancelIdleCallback(handle);
2545
+ else clearTimeout(handle);
2546
+ controller.abort();
2547
+ };
2548
+ }
2549
+ /**
2550
+ * From `onStop`: cancel the idle callback or abort the bundle fetch.
2551
+ *
2552
+ * @param ctx - The data plugin context.
2553
+ * @example
2554
+ * ```ts
2555
+ * stopPreload(ctx);
2556
+ * ```
2557
+ */
2558
+ function stopPreload(ctx) {
2559
+ ctx.state.cancelPreload?.();
2560
+ ctx.state.cancelPreload = null;
2561
+ }
2562
+ //#endregion
2495
2563
  //#region src/plugins/data/state.ts
2496
2564
  /**
2497
2565
  * Creates initial data state: a null `lastWrite` slot (populated by the Node
2498
2566
  * `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.
2567
+ * side on first fetch), an empty `pending` map of in-flight fetches, and no
2568
+ * preload bundle in flight or scheduled.
2500
2569
  *
2501
2570
  * @param _ctx - Minimal context with global and config.
2502
2571
  * @param _ctx.global - Global framework configuration.
@@ -2511,7 +2580,9 @@ function createDataState(_ctx) {
2511
2580
  return {
2512
2581
  lastWrite: null,
2513
2582
  cache: /* @__PURE__ */ new Map(),
2514
- pending: /* @__PURE__ */ new Map()
2583
+ pending: /* @__PURE__ */ new Map(),
2584
+ bundle: null,
2585
+ cancelPreload: null
2515
2586
  };
2516
2587
  }
2517
2588
  //#endregion
@@ -2533,10 +2604,10 @@ function isInvalidBaseUrl(baseUrl) {
2533
2604
  }
2534
2605
  /**
2535
2606
  * Validates the resolved data config: the browser `baseUrl` must be a non-empty,
2536
- * site-root-relative URL path.
2607
+ * site-root-relative URL path, and `preload` must be `"none"` or `"all"`.
2537
2608
  *
2538
2609
  * @param config - The resolved plugin configuration.
2539
- * @throws {Error} If `baseUrl` is empty or not a rooted URL path.
2610
+ * @throws {Error} If `baseUrl` is empty or not a rooted URL path, or `preload` is unknown.
2540
2611
  * @example
2541
2612
  * ```ts
2542
2613
  * validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
@@ -2544,6 +2615,8 @@ function isInvalidBaseUrl(baseUrl) {
2544
2615
  */
2545
2616
  function validateDataConfig(config) {
2546
2617
  if (isInvalidBaseUrl(config.baseUrl)) throw new Error(`[web] data.baseUrl: must be a site-root-relative URL path starting with "/" (e.g. "/_data/").`);
2618
+ const { preload } = config;
2619
+ if (preload !== void 0 && preload !== "none" && preload !== "all") throw new Error(`[web] data.preload: must be "none" or "all".\n Set it to "all" in both apps, or remove it.`);
2547
2620
  }
2548
2621
  //#endregion
2549
2622
  //#region src/plugins/data/index.ts
@@ -2560,7 +2633,8 @@ function validateDataConfig(config) {
2560
2633
  * **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
2561
2634
  * a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
2562
2635
  * writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
2563
- * No `onStart`/`onStop`.
2636
+ * `onStart` schedules the optional `preload: "all"` bundle fetch (browser, idle);
2637
+ * `onStop` cancels it.
2564
2638
  * @see README.md
2565
2639
  */
2566
2640
  /**
@@ -2586,7 +2660,9 @@ const dataPlugin = createPlugin$1("data", {
2586
2660
  config: defaultDataConfig,
2587
2661
  createState: createDataState,
2588
2662
  onInit: (ctx) => validateDataConfig(ctx.config),
2589
- api: dataApi
2663
+ api: dataApi,
2664
+ onStart: (ctx) => startPreload(ctx),
2665
+ onStop: (ctx) => stopPreload(ctx)
2590
2666
  });
2591
2667
  //#endregion
2592
2668
  //#region src/plugins/spa/head.ts
@@ -70,7 +70,15 @@ function decodeSuffix(suffix) {
70
70
  function relativeDataFile(outputDir, path) {
71
71
  return `${outputDir.endsWith("/") ? outputDir.slice(0, -1) : outputDir}/${decodeSuffix(dataSuffix(path))}`;
72
72
  }
73
+ /** Preload bundle file name. Page files end in `/index.json`, so it never collides. */
74
+ const BUNDLE_FILE = "_all.json";
73
75
  //#endregion
76
+ Object.defineProperty(exports, "BUNDLE_FILE", {
77
+ enumerable: true,
78
+ get: function() {
79
+ return BUNDLE_FILE;
80
+ }
81
+ });
74
82
  Object.defineProperty(exports, "dataSuffix", {
75
83
  enumerable: true,
76
84
  get: function() {
@@ -70,5 +70,7 @@ function decodeSuffix(suffix) {
70
70
  function relativeDataFile(outputDir, path) {
71
71
  return `${outputDir.endsWith("/") ? outputDir.slice(0, -1) : outputDir}/${decodeSuffix(dataSuffix(path))}`;
72
72
  }
73
+ /** Preload bundle file name. Page files end in `/index.json`, so it never collides. */
74
+ const BUNDLE_FILE = "_all.json";
73
75
  //#endregion
74
- export { relativeDataFile as n, dataSuffix as t };
76
+ export { dataSuffix as n, relativeDataFile as r, BUNDLE_FILE as t };
package/dist/index.cjs CHANGED
@@ -30,7 +30,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
30
30
  enumerable: true
31
31
  }) : target, mod));
32
32
  //#endregion
33
- const require_convention = require("./convention-CLIDd9k-.cjs");
33
+ const require_convention = require("./convention-BO-VoFLO.cjs");
34
34
  const require_convention$1 = require("./convention-CsJw7CML.cjs");
35
35
  let _moku_labs_common = require("@moku-labs/common");
36
36
  let _moku_labs_core = require("@moku-labs/core");
@@ -4651,7 +4651,7 @@ async function fetchAndCache(ctx, path) {
4651
4651
  /**
4652
4652
  * Builds the data provider — the agnostic bridge. `write()` is the Node persist
4653
4653
  * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
4654
- * convention. No `onStart`/`onStop` (holds no long-lived resource).
4654
+ * convention. The optional preload lives in `./preload` (started by `onStart`).
4655
4655
  *
4656
4656
  * @param ctx - The data plugin context.
4657
4657
  * @returns The {@link DataProvider} mounted at `app.data`.
@@ -4666,7 +4666,9 @@ function dataApi(ctx) {
4666
4666
  return {
4667
4667
  /**
4668
4668
  * READ (browser) — fetch (and cache) the persisted data for a page path.
4669
- * Concurrent calls for one path share one in-flight request.
4669
+ * Concurrent calls for one path share one in-flight request. While a preload
4670
+ * bundle is in flight, it waits for the bundle and fetches the single file
4671
+ * only if the bundle did not bring the path.
4670
4672
  * Returns the raw JSON as `unknown`, which the route uses directly as `ctx.data`
4671
4673
  * (no route `.parse()`); returns `null` if the fetch or JSON parse fails (so
4672
4674
  * `spa` can fall back to HTML).
@@ -4680,6 +4682,10 @@ function dataApi(ctx) {
4680
4682
  */
4681
4683
  async at(path) {
4682
4684
  if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
4685
+ if (ctx.state.bundle) {
4686
+ await ctx.state.bundle;
4687
+ if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
4688
+ }
4683
4689
  const inFlight = ctx.state.pending.get(path);
4684
4690
  if (inFlight) return inFlight;
4685
4691
  const request = fetchAndCache(ctx, path);
@@ -4701,7 +4707,7 @@ function dataApi(ctx) {
4701
4707
  * ```
4702
4708
  */
4703
4709
  async write(entries, options) {
4704
- const { writeData } = await Promise.resolve().then(() => require("./writer-BEM-Pbgb.cjs"));
4710
+ const { writeData } = await Promise.resolve().then(() => require("./writer-ryba09yV.cjs"));
4705
4711
  return writeData(ctx, entries, options);
4706
4712
  },
4707
4713
  /**
@@ -4738,7 +4744,7 @@ function dataApi(ctx) {
4738
4744
  * Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
4739
4745
  * (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
4740
4746
  * (site-root-relative) the browser fetches from — the defaults agree
4741
- * (`"_data"` ↔ `"/_data/"`).
4747
+ * (`"_data"` ↔ `"/_data/"`). `preload` is `"none"`: lazy per-page fetches only.
4742
4748
  *
4743
4749
  * @example
4744
4750
  * ```ts
@@ -4747,14 +4753,77 @@ function dataApi(ctx) {
4747
4753
  */
4748
4754
  const defaultDataConfig = {
4749
4755
  outputDir: "_data",
4750
- baseUrl: "/_data/"
4756
+ baseUrl: "/_data/",
4757
+ preload: "none"
4751
4758
  };
4752
4759
  //#endregion
4760
+ //#region src/plugins/data/preload.ts
4761
+ /**
4762
+ * Fetches the bundle and caches every path not already cached. Never rejects:
4763
+ * a failed fetch leaves lazy `at()` working. Clears the preload slots when done.
4764
+ *
4765
+ * @param ctx - The data plugin context.
4766
+ * @param signal - Aborts the fetch on stop.
4767
+ * @returns Resolves when the fill is done or failed.
4768
+ * @example
4769
+ * ```ts
4770
+ * await fill(ctx, signal);
4771
+ * ```
4772
+ */
4773
+ async function fill(ctx, signal) {
4774
+ try {
4775
+ const response = await fetch(`${ctx.config.baseUrl}${require_convention.BUNDLE_FILE}`, { signal });
4776
+ const bundle = response.ok ? await response.json() : {};
4777
+ for (const [path, data] of Object.entries(bundle)) if (!signal.aborted && !ctx.state.cache.has(path)) ctx.state.cache.set(path, data);
4778
+ } catch {} finally {
4779
+ ctx.state.bundle = ctx.state.cancelPreload = null;
4780
+ }
4781
+ }
4782
+ /**
4783
+ * From `onStart`: when idle, fetch `<baseUrl>_all.json` once and fill the cache.
4784
+ * No-op on Node, with `preload: "none"`, with save-data on, or if already scheduled.
4785
+ *
4786
+ * @param ctx - The data plugin context.
4787
+ * @example
4788
+ * ```ts
4789
+ * startPreload(ctx);
4790
+ * ```
4791
+ */
4792
+ function startPreload(ctx) {
4793
+ const nav = globalThis.navigator;
4794
+ if (ctx.config.preload !== "all" || typeof document === "undefined" || ctx.state.cancelPreload !== null || nav?.connection?.saveData === true) return;
4795
+ const controller = new AbortController();
4796
+ const run = () => {
4797
+ ctx.state.bundle = fill(ctx, controller.signal);
4798
+ };
4799
+ const idle = typeof requestIdleCallback === "function";
4800
+ const handle = idle ? requestIdleCallback(run, { timeout: 2e3 }) : setTimeout(run, 0);
4801
+ ctx.state.cancelPreload = () => {
4802
+ if (idle) cancelIdleCallback(handle);
4803
+ else clearTimeout(handle);
4804
+ controller.abort();
4805
+ };
4806
+ }
4807
+ /**
4808
+ * From `onStop`: cancel the idle callback or abort the bundle fetch.
4809
+ *
4810
+ * @param ctx - The data plugin context.
4811
+ * @example
4812
+ * ```ts
4813
+ * stopPreload(ctx);
4814
+ * ```
4815
+ */
4816
+ function stopPreload(ctx) {
4817
+ ctx.state.cancelPreload?.();
4818
+ ctx.state.cancelPreload = null;
4819
+ }
4820
+ //#endregion
4753
4821
  //#region src/plugins/data/state.ts
4754
4822
  /**
4755
4823
  * Creates initial data state: a null `lastWrite` slot (populated by the Node
4756
4824
  * `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.
4825
+ * side on first fetch), an empty `pending` map of in-flight fetches, and no
4826
+ * preload bundle in flight or scheduled.
4758
4827
  *
4759
4828
  * @param _ctx - Minimal context with global and config.
4760
4829
  * @param _ctx.global - Global framework configuration.
@@ -4769,7 +4838,9 @@ function createDataState(_ctx) {
4769
4838
  return {
4770
4839
  lastWrite: null,
4771
4840
  cache: /* @__PURE__ */ new Map(),
4772
- pending: /* @__PURE__ */ new Map()
4841
+ pending: /* @__PURE__ */ new Map(),
4842
+ bundle: null,
4843
+ cancelPreload: null
4773
4844
  };
4774
4845
  }
4775
4846
  //#endregion
@@ -4791,10 +4862,10 @@ function isInvalidBaseUrl$1(baseUrl) {
4791
4862
  }
4792
4863
  /**
4793
4864
  * Validates the resolved data config: the browser `baseUrl` must be a non-empty,
4794
- * site-root-relative URL path.
4865
+ * site-root-relative URL path, and `preload` must be `"none"` or `"all"`.
4795
4866
  *
4796
4867
  * @param config - The resolved plugin configuration.
4797
- * @throws {Error} If `baseUrl` is empty or not a rooted URL path.
4868
+ * @throws {Error} If `baseUrl` is empty or not a rooted URL path, or `preload` is unknown.
4798
4869
  * @example
4799
4870
  * ```ts
4800
4871
  * validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
@@ -4802,6 +4873,8 @@ function isInvalidBaseUrl$1(baseUrl) {
4802
4873
  */
4803
4874
  function validateDataConfig(config) {
4804
4875
  if (isInvalidBaseUrl$1(config.baseUrl)) throw new Error(`[web] data.baseUrl: must be a site-root-relative URL path starting with "/" (e.g. "/_data/").`);
4876
+ const { preload } = config;
4877
+ if (preload !== void 0 && preload !== "none" && preload !== "all") throw new Error(`[web] data.preload: must be "none" or "all".\n Set it to "all" in both apps, or remove it.`);
4805
4878
  }
4806
4879
  //#endregion
4807
4880
  //#region src/plugins/data/index.ts
@@ -4818,7 +4891,8 @@ function validateDataConfig(config) {
4818
4891
  * **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
4819
4892
  * a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
4820
4893
  * writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
4821
- * No `onStart`/`onStop`.
4894
+ * `onStart` schedules the optional `preload: "all"` bundle fetch (browser, idle);
4895
+ * `onStop` cancels it.
4822
4896
  * @see README.md
4823
4897
  */
4824
4898
  /**
@@ -4844,7 +4918,9 @@ const dataPlugin = createPlugin$1("data", {
4844
4918
  config: defaultDataConfig,
4845
4919
  createState: createDataState,
4846
4920
  onInit: (ctx) => validateDataConfig(ctx.config),
4847
- api: dataApi
4921
+ api: dataApi,
4922
+ onStart: (ctx) => startPreload(ctx),
4923
+ onStop: (ctx) => stopPreload(ctx)
4848
4924
  });
4849
4925
  //#endregion
4850
4926
  //#region src/plugins/build/phases/pages.tsx
package/dist/index.d.cts CHANGED
@@ -3400,7 +3400,7 @@ declare const contentPlugin: import("@moku-labs/core").PluginInstance<"content",
3400
3400
  };
3401
3401
  }> & Record<never, never>;
3402
3402
  declare namespace types_d_exports$4 {
3403
- export { DataConfig, DataEntry, DataProvider, DataState, DataWriteSummary };
3403
+ export { DataConfig, DataEntry, DataPreload, DataProvider, DataState, DataWriteSummary };
3404
3404
  }
3405
3405
  /**
3406
3406
  * @file data plugin — type definitions (Standard tier).
@@ -3420,6 +3420,12 @@ declare namespace types_d_exports$4 {
3420
3420
  * inside `write()`, so composing `data` in a browser app keeps the bundle free of
3421
3421
  * `node:*`.
3422
3422
  */
3423
+ /**
3424
+ * How the browser loads page data. `"none"` fetches one page file per navigation
3425
+ * (lazy). `"all"` also loads every page's data once, from one bundle file, after
3426
+ * the app starts and the main thread is idle.
3427
+ */
3428
+ type DataPreload = "none" | "all";
3423
3429
  /**
3424
3430
  * Configuration for {@link dataPlugin}. All fields have defaults (see `./config`).
3425
3431
  *
@@ -3440,6 +3446,13 @@ type DataConfig = {
3440
3446
  * path); keep them consistent (`"/" + trim(outputDir) + "/"`). Default `"/_data/"`.
3441
3447
  */
3442
3448
  baseUrl: string;
3449
+ /**
3450
+ * BOTH sides. `"all"`: the Node `write()` also writes one bundle file
3451
+ * `<outputDir>/_all.json` (`{ "<page path>": data }`), and the browser fetches
3452
+ * it once when idle and fills the `at()` cache. Set the same value in the Node
3453
+ * app and the browser app. Default `"none"`: no bundle, lazy per-page fetches.
3454
+ */
3455
+ preload?: DataPreload;
3443
3456
  };
3444
3457
  /** One page's data to persist — `build` produces these from its route expansion. */
3445
3458
  interface DataEntry {
@@ -3456,6 +3469,11 @@ interface DataWriteSummary {
3456
3469
  bytes: number;
3457
3470
  /** The written file paths, relative to the build `outDir`. */
3458
3471
  files: string[];
3472
+ /** The preload bundle, present only when `preload` is `"all"`. Not counted in `fileCount`/`bytes`. */
3473
+ bundle?: {
3474
+ file: string;
3475
+ bytes: number;
3476
+ };
3459
3477
  }
3460
3478
  /**
3461
3479
  * Internal data state. `lastWrite` records the most recent `write()` (Node);
@@ -3473,6 +3491,14 @@ interface DataState {
3473
3491
  * the fetch settles, on success and on failure, so a failure is never cached.
3474
3492
  */
3475
3493
  pending: Map<string, Promise<unknown | null>>;
3494
+ /**
3495
+ * The preload bundle fetch while it is in flight (browser, `preload: "all"`).
3496
+ * `at()` awaits it before fetching a single file. Never rejects. `null` when no
3497
+ * bundle fetch is running.
3498
+ */
3499
+ bundle: Promise<void> | null;
3500
+ /** Cancels the scheduled idle callback or aborts the bundle fetch. `null` when nothing is scheduled. */
3501
+ cancelPreload: (() => void) | null;
3476
3502
  }
3477
3503
  /**
3478
3504
  * Public API mounted at `app.data` — the agnostic data provider. `write()` is the
package/dist/index.d.mts CHANGED
@@ -3398,7 +3398,7 @@ declare const contentPlugin: import("@moku-labs/core").PluginInstance<"content",
3398
3398
  };
3399
3399
  }> & Record<never, never>;
3400
3400
  declare namespace types_d_exports$4 {
3401
- export { DataConfig, DataEntry, DataProvider, DataState, DataWriteSummary };
3401
+ export { DataConfig, DataEntry, DataPreload, DataProvider, DataState, DataWriteSummary };
3402
3402
  }
3403
3403
  /**
3404
3404
  * @file data plugin — type definitions (Standard tier).
@@ -3418,6 +3418,12 @@ declare namespace types_d_exports$4 {
3418
3418
  * inside `write()`, so composing `data` in a browser app keeps the bundle free of
3419
3419
  * `node:*`.
3420
3420
  */
3421
+ /**
3422
+ * How the browser loads page data. `"none"` fetches one page file per navigation
3423
+ * (lazy). `"all"` also loads every page's data once, from one bundle file, after
3424
+ * the app starts and the main thread is idle.
3425
+ */
3426
+ type DataPreload = "none" | "all";
3421
3427
  /**
3422
3428
  * Configuration for {@link dataPlugin}. All fields have defaults (see `./config`).
3423
3429
  *
@@ -3438,6 +3444,13 @@ type DataConfig = {
3438
3444
  * path); keep them consistent (`"/" + trim(outputDir) + "/"`). Default `"/_data/"`.
3439
3445
  */
3440
3446
  baseUrl: string;
3447
+ /**
3448
+ * BOTH sides. `"all"`: the Node `write()` also writes one bundle file
3449
+ * `<outputDir>/_all.json` (`{ "<page path>": data }`), and the browser fetches
3450
+ * it once when idle and fills the `at()` cache. Set the same value in the Node
3451
+ * app and the browser app. Default `"none"`: no bundle, lazy per-page fetches.
3452
+ */
3453
+ preload?: DataPreload;
3441
3454
  };
3442
3455
  /** One page's data to persist — `build` produces these from its route expansion. */
3443
3456
  interface DataEntry {
@@ -3454,6 +3467,11 @@ interface DataWriteSummary {
3454
3467
  bytes: number;
3455
3468
  /** The written file paths, relative to the build `outDir`. */
3456
3469
  files: string[];
3470
+ /** The preload bundle, present only when `preload` is `"all"`. Not counted in `fileCount`/`bytes`. */
3471
+ bundle?: {
3472
+ file: string;
3473
+ bytes: number;
3474
+ };
3457
3475
  }
3458
3476
  /**
3459
3477
  * Internal data state. `lastWrite` records the most recent `write()` (Node);
@@ -3471,6 +3489,14 @@ interface DataState {
3471
3489
  * the fetch settles, on success and on failure, so a failure is never cached.
3472
3490
  */
3473
3491
  pending: Map<string, Promise<unknown | null>>;
3492
+ /**
3493
+ * The preload bundle fetch while it is in flight (browser, `preload: "all"`).
3494
+ * `at()` awaits it before fetching a single file. Never rejects. `null` when no
3495
+ * bundle fetch is running.
3496
+ */
3497
+ bundle: Promise<void> | null;
3498
+ /** Cancels the scheduled idle callback or aborts the bundle fetch. `null` when nothing is scheduled. */
3499
+ cancelPreload: (() => void) | null;
3474
3500
  }
3475
3501
  /**
3476
3502
  * Public API mounted at `app.data` — the agnostic data provider. `write()` is the
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
2
- import { n as relativeDataFile, t as dataSuffix } from "./convention-Dp650o3y.mjs";
2
+ import { n as dataSuffix, r as relativeDataFile, t as BUNDLE_FILE } from "./convention-BtVf429P.mjs";
3
3
  import { n as relativeShardFile, t as collectionUrl } from "./convention-Bs8SrQS9.mjs";
4
4
  import { browserEnv, cloudflareBindings, dotenv, envPlugin, envPlugin as envPlugin$1, logPlugin, logPlugin as logPlugin$1, processEnv } from "@moku-labs/common";
5
5
  import { createCoreConfig } from "@moku-labs/core";
@@ -4607,7 +4607,7 @@ async function fetchAndCache(ctx, path) {
4607
4607
  /**
4608
4608
  * Builds the data provider — the agnostic bridge. `write()` is the Node persist
4609
4609
  * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
4610
- * convention. No `onStart`/`onStop` (holds no long-lived resource).
4610
+ * convention. The optional preload lives in `./preload` (started by `onStart`).
4611
4611
  *
4612
4612
  * @param ctx - The data plugin context.
4613
4613
  * @returns The {@link DataProvider} mounted at `app.data`.
@@ -4622,7 +4622,9 @@ function dataApi(ctx) {
4622
4622
  return {
4623
4623
  /**
4624
4624
  * READ (browser) — fetch (and cache) the persisted data for a page path.
4625
- * Concurrent calls for one path share one in-flight request.
4625
+ * Concurrent calls for one path share one in-flight request. While a preload
4626
+ * bundle is in flight, it waits for the bundle and fetches the single file
4627
+ * only if the bundle did not bring the path.
4626
4628
  * Returns the raw JSON as `unknown`, which the route uses directly as `ctx.data`
4627
4629
  * (no route `.parse()`); returns `null` if the fetch or JSON parse fails (so
4628
4630
  * `spa` can fall back to HTML).
@@ -4636,6 +4638,10 @@ function dataApi(ctx) {
4636
4638
  */
4637
4639
  async at(path) {
4638
4640
  if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
4641
+ if (ctx.state.bundle) {
4642
+ await ctx.state.bundle;
4643
+ if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
4644
+ }
4639
4645
  const inFlight = ctx.state.pending.get(path);
4640
4646
  if (inFlight) return inFlight;
4641
4647
  const request = fetchAndCache(ctx, path);
@@ -4657,7 +4663,7 @@ function dataApi(ctx) {
4657
4663
  * ```
4658
4664
  */
4659
4665
  async write(entries, options) {
4660
- const { writeData } = await import("./writer-CaoyORyZ.mjs");
4666
+ const { writeData } = await import("./writer-Dwd02T9h.mjs");
4661
4667
  return writeData(ctx, entries, options);
4662
4668
  },
4663
4669
  /**
@@ -4694,7 +4700,7 @@ function dataApi(ctx) {
4694
4700
  * Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
4695
4701
  * (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
4696
4702
  * (site-root-relative) the browser fetches from — the defaults agree
4697
- * (`"_data"` ↔ `"/_data/"`).
4703
+ * (`"_data"` ↔ `"/_data/"`). `preload` is `"none"`: lazy per-page fetches only.
4698
4704
  *
4699
4705
  * @example
4700
4706
  * ```ts
@@ -4703,14 +4709,77 @@ function dataApi(ctx) {
4703
4709
  */
4704
4710
  const defaultDataConfig = {
4705
4711
  outputDir: "_data",
4706
- baseUrl: "/_data/"
4712
+ baseUrl: "/_data/",
4713
+ preload: "none"
4707
4714
  };
4708
4715
  //#endregion
4716
+ //#region src/plugins/data/preload.ts
4717
+ /**
4718
+ * Fetches the bundle and caches every path not already cached. Never rejects:
4719
+ * a failed fetch leaves lazy `at()` working. Clears the preload slots when done.
4720
+ *
4721
+ * @param ctx - The data plugin context.
4722
+ * @param signal - Aborts the fetch on stop.
4723
+ * @returns Resolves when the fill is done or failed.
4724
+ * @example
4725
+ * ```ts
4726
+ * await fill(ctx, signal);
4727
+ * ```
4728
+ */
4729
+ async function fill(ctx, signal) {
4730
+ try {
4731
+ const response = await fetch(`${ctx.config.baseUrl}${BUNDLE_FILE}`, { signal });
4732
+ const bundle = response.ok ? await response.json() : {};
4733
+ for (const [path, data] of Object.entries(bundle)) if (!signal.aborted && !ctx.state.cache.has(path)) ctx.state.cache.set(path, data);
4734
+ } catch {} finally {
4735
+ ctx.state.bundle = ctx.state.cancelPreload = null;
4736
+ }
4737
+ }
4738
+ /**
4739
+ * From `onStart`: when idle, fetch `<baseUrl>_all.json` once and fill the cache.
4740
+ * No-op on Node, with `preload: "none"`, with save-data on, or if already scheduled.
4741
+ *
4742
+ * @param ctx - The data plugin context.
4743
+ * @example
4744
+ * ```ts
4745
+ * startPreload(ctx);
4746
+ * ```
4747
+ */
4748
+ function startPreload(ctx) {
4749
+ const nav = globalThis.navigator;
4750
+ if (ctx.config.preload !== "all" || typeof document === "undefined" || ctx.state.cancelPreload !== null || nav?.connection?.saveData === true) return;
4751
+ const controller = new AbortController();
4752
+ const run = () => {
4753
+ ctx.state.bundle = fill(ctx, controller.signal);
4754
+ };
4755
+ const idle = typeof requestIdleCallback === "function";
4756
+ const handle = idle ? requestIdleCallback(run, { timeout: 2e3 }) : setTimeout(run, 0);
4757
+ ctx.state.cancelPreload = () => {
4758
+ if (idle) cancelIdleCallback(handle);
4759
+ else clearTimeout(handle);
4760
+ controller.abort();
4761
+ };
4762
+ }
4763
+ /**
4764
+ * From `onStop`: cancel the idle callback or abort the bundle fetch.
4765
+ *
4766
+ * @param ctx - The data plugin context.
4767
+ * @example
4768
+ * ```ts
4769
+ * stopPreload(ctx);
4770
+ * ```
4771
+ */
4772
+ function stopPreload(ctx) {
4773
+ ctx.state.cancelPreload?.();
4774
+ ctx.state.cancelPreload = null;
4775
+ }
4776
+ //#endregion
4709
4777
  //#region src/plugins/data/state.ts
4710
4778
  /**
4711
4779
  * Creates initial data state: a null `lastWrite` slot (populated by the Node
4712
4780
  * `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.
4781
+ * side on first fetch), an empty `pending` map of in-flight fetches, and no
4782
+ * preload bundle in flight or scheduled.
4714
4783
  *
4715
4784
  * @param _ctx - Minimal context with global and config.
4716
4785
  * @param _ctx.global - Global framework configuration.
@@ -4725,7 +4794,9 @@ function createDataState(_ctx) {
4725
4794
  return {
4726
4795
  lastWrite: null,
4727
4796
  cache: /* @__PURE__ */ new Map(),
4728
- pending: /* @__PURE__ */ new Map()
4797
+ pending: /* @__PURE__ */ new Map(),
4798
+ bundle: null,
4799
+ cancelPreload: null
4729
4800
  };
4730
4801
  }
4731
4802
  //#endregion
@@ -4747,10 +4818,10 @@ function isInvalidBaseUrl$1(baseUrl) {
4747
4818
  }
4748
4819
  /**
4749
4820
  * Validates the resolved data config: the browser `baseUrl` must be a non-empty,
4750
- * site-root-relative URL path.
4821
+ * site-root-relative URL path, and `preload` must be `"none"` or `"all"`.
4751
4822
  *
4752
4823
  * @param config - The resolved plugin configuration.
4753
- * @throws {Error} If `baseUrl` is empty or not a rooted URL path.
4824
+ * @throws {Error} If `baseUrl` is empty or not a rooted URL path, or `preload` is unknown.
4754
4825
  * @example
4755
4826
  * ```ts
4756
4827
  * validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
@@ -4758,6 +4829,8 @@ function isInvalidBaseUrl$1(baseUrl) {
4758
4829
  */
4759
4830
  function validateDataConfig(config) {
4760
4831
  if (isInvalidBaseUrl$1(config.baseUrl)) throw new Error(`[web] data.baseUrl: must be a site-root-relative URL path starting with "/" (e.g. "/_data/").`);
4832
+ const { preload } = config;
4833
+ if (preload !== void 0 && preload !== "none" && preload !== "all") throw new Error(`[web] data.preload: must be "none" or "all".\n Set it to "all" in both apps, or remove it.`);
4761
4834
  }
4762
4835
  //#endregion
4763
4836
  //#region src/plugins/data/index.ts
@@ -4774,7 +4847,8 @@ function validateDataConfig(config) {
4774
4847
  * **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
4775
4848
  * a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
4776
4849
  * writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
4777
- * No `onStart`/`onStop`.
4850
+ * `onStart` schedules the optional `preload: "all"` bundle fetch (browser, idle);
4851
+ * `onStop` cancels it.
4778
4852
  * @see README.md
4779
4853
  */
4780
4854
  /**
@@ -4800,7 +4874,9 @@ const dataPlugin = createPlugin$1("data", {
4800
4874
  config: defaultDataConfig,
4801
4875
  createState: createDataState,
4802
4876
  onInit: (ctx) => validateDataConfig(ctx.config),
4803
- api: dataApi
4877
+ api: dataApi,
4878
+ onStart: (ctx) => startPreload(ctx),
4879
+ onStop: (ctx) => stopPreload(ctx)
4804
4880
  });
4805
4881
  //#endregion
4806
4882
  //#region src/plugins/build/phases/pages.tsx
@@ -1,4 +1,4 @@
1
- import { n as relativeDataFile } from "./convention-Dp650o3y.mjs";
1
+ import { r as relativeDataFile, t as BUNDLE_FILE } from "./convention-BtVf429P.mjs";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import pLimit from "p-limit";
@@ -63,8 +63,36 @@ function compileSummary(written) {
63
63
  };
64
64
  }
65
65
  /**
66
+ * Persist the preload bundle: one JSON object `{ "<page path>": data }` keyed by
67
+ * each entry's `path`, exactly the key `at(path)` caches under. A later entry for
68
+ * the same path wins, like the per-page file it overwrites.
69
+ *
70
+ * @param entries - The per-page data to bundle.
71
+ * @param outDir - The build output root.
72
+ * @param outputDir - The configured data output subdir.
73
+ * @returns The written bundle's `outDir`-relative path and byte length.
74
+ * @example
75
+ * ```ts
76
+ * await writeBundle([{ path: "/en/hello/", data }], "./dist", "_data");
77
+ * ```
78
+ */
79
+ async function writeBundle(entries, outDir, outputDir) {
80
+ const bundle = {};
81
+ for (const entry of entries) bundle[entry.path] = entry.data;
82
+ const file = `${outputDir.endsWith("/") ? outputDir.slice(0, -1) : outputDir}/${BUNDLE_FILE}`;
83
+ const filePath = path.join(outDir, file);
84
+ await mkdir(path.dirname(filePath), { recursive: true });
85
+ const body = JSON.stringify(bundle);
86
+ await writeFile(filePath, body, "utf8");
87
+ return {
88
+ file,
89
+ bytes: Buffer.byteLength(body, "utf8")
90
+ };
91
+ }
92
+ /**
66
93
  * The Node write side of the provider. Persists one JSON file per entry (bounded
67
94
  * by `p-limit`) — domain-agnostic, no route expansion (`build` already did that).
95
+ * With `preload: "all"` it also writes the bundle file (see {@link writeBundle}).
68
96
  * Records the summary in `ctx.state.lastWrite`.
69
97
  *
70
98
  * @param ctx - The data plugin context (state, config).
@@ -81,6 +109,7 @@ async function writeData(ctx, entries, options) {
81
109
  const outDir = options?.outDir ?? DEFAULT_OUT_DIR;
82
110
  const limit = pLimit(WRITE_CONCURRENCY);
83
111
  const summary = compileSummary(await Promise.all(entries.map((entry) => limit(() => writeEntry(entry, outDir, ctx.config.outputDir)))));
112
+ if (ctx.config.preload === "all") summary.bundle = await writeBundle(entries, outDir, ctx.config.outputDir);
84
113
  ctx.state.lastWrite = summary;
85
114
  return summary;
86
115
  }
@@ -1,4 +1,4 @@
1
- const require_convention = require("./convention-CLIDd9k-.cjs");
1
+ const require_convention = require("./convention-BO-VoFLO.cjs");
2
2
  const require_index = require("./index.cjs");
3
3
  let node_fs_promises = require("node:fs/promises");
4
4
  let node_path = require("node:path");
@@ -66,8 +66,36 @@ function compileSummary(written) {
66
66
  };
67
67
  }
68
68
  /**
69
+ * Persist the preload bundle: one JSON object `{ "<page path>": data }` keyed by
70
+ * each entry's `path`, exactly the key `at(path)` caches under. A later entry for
71
+ * the same path wins, like the per-page file it overwrites.
72
+ *
73
+ * @param entries - The per-page data to bundle.
74
+ * @param outDir - The build output root.
75
+ * @param outputDir - The configured data output subdir.
76
+ * @returns The written bundle's `outDir`-relative path and byte length.
77
+ * @example
78
+ * ```ts
79
+ * await writeBundle([{ path: "/en/hello/", data }], "./dist", "_data");
80
+ * ```
81
+ */
82
+ async function writeBundle(entries, outDir, outputDir) {
83
+ const bundle = {};
84
+ for (const entry of entries) bundle[entry.path] = entry.data;
85
+ const file = `${outputDir.endsWith("/") ? outputDir.slice(0, -1) : outputDir}/${require_convention.BUNDLE_FILE}`;
86
+ const filePath = node_path.default.join(outDir, file);
87
+ await (0, node_fs_promises.mkdir)(node_path.default.dirname(filePath), { recursive: true });
88
+ const body = JSON.stringify(bundle);
89
+ await (0, node_fs_promises.writeFile)(filePath, body, "utf8");
90
+ return {
91
+ file,
92
+ bytes: Buffer.byteLength(body, "utf8")
93
+ };
94
+ }
95
+ /**
69
96
  * The Node write side of the provider. Persists one JSON file per entry (bounded
70
97
  * by `p-limit`) — domain-agnostic, no route expansion (`build` already did that).
98
+ * With `preload: "all"` it also writes the bundle file (see {@link writeBundle}).
71
99
  * Records the summary in `ctx.state.lastWrite`.
72
100
  *
73
101
  * @param ctx - The data plugin context (state, config).
@@ -84,6 +112,7 @@ async function writeData(ctx, entries, options) {
84
112
  const outDir = options?.outDir ?? DEFAULT_OUT_DIR;
85
113
  const limit = (0, p_limit.default)(WRITE_CONCURRENCY);
86
114
  const summary = compileSummary(await Promise.all(entries.map((entry) => limit(() => writeEntry(entry, outDir, ctx.config.outputDir)))));
115
+ if (ctx.config.preload === "all") summary.bundle = await writeBundle(entries, outDir, ctx.config.outputDir);
87
116
  ctx.state.lastWrite = summary;
88
117
  return summary;
89
118
  }
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.13"
142
+ "version": "2.4.14"
143
143
  }