@mandujs/core 0.25.0 → 0.25.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -14,6 +14,12 @@
14
14
  "./auth/verification": "./src/auth/verification.ts",
15
15
  "./client": "./src/client/index.ts",
16
16
  "./content": "./src/content/index.ts",
17
+ "./content/prebuild": "./src/content/prebuild.ts",
18
+ "./content/collection": "./src/content/collection.ts",
19
+ "./content/sidebar": "./src/content/sidebar.ts",
20
+ "./content/slug": "./src/content/slug.ts",
21
+ "./content/llms-txt": "./src/content/llms-txt.ts",
22
+ "./content/schema": "./src/content/schema.ts",
17
23
  "./db": "./src/db/index.ts",
18
24
  "./desktop": "./src/desktop/index.ts",
19
25
  "./desktop/worker": "./src/desktop/worker.ts",
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Internal helper used by `build.test.ts` + `fast-refresh.test.ts` to run
4
+ * `buildClientBundles` in an isolated `bun` subprocess.
5
+ *
6
+ * # Why this exists
7
+ *
8
+ * When the parent `bun test` process has loaded `react` or `react-dom`
9
+ * (transitively through most test files that import from `src/testing/*`
10
+ * or `src/runtime/*`), Bun 1.3.x's bundler resolver state interacts with
11
+ * `buildClientBundles`' 7-parallel shim fan-out (runtime + router +
12
+ * vendor[5] + devtools) to produce `AggregateError: Bundle failed` on
13
+ * one or more shims. Which shim fails is non-deterministic per run;
14
+ * retrying in-process does not recover because the state is sticky.
15
+ * Running the build in a fresh `bun` subprocess has a clean module graph
16
+ * and builds successfully on the first attempt.
17
+ *
18
+ * Reproducer (in-process): import `"react"` or `"./src/testing/server.ts"`
19
+ * in ANY other test file that ships with `src/bundler/build.test.ts`, and
20
+ * `bun test src/bundler/build.test.ts <that file>` fails ~100 %.
21
+ *
22
+ * # Contract
23
+ *
24
+ * - Invocation: `bun run src/bundler/__tests__/build-runner.ts <rootDir>`
25
+ * - The caller must pre-create `rootDir` with:
26
+ * - `package.json` (any valid contents; shim cache keys walk up to
27
+ * find `node_modules/react`)
28
+ * - `app/demo.client.tsx` (the fixed manifest references this as an
29
+ * island client module)
30
+ * - stdout: JSON blob terminated by `\n`:
31
+ * { "success": boolean,
32
+ * "errors": string[],
33
+ * "manifest": { shared: { fastRefresh?: { runtime: string; glue: string } } }
34
+ * }
35
+ * Only the fields tests consume are serialized; the in-memory manifest
36
+ * is also persisted at `<rootDir>/.mandu/manifest.json` by the build.
37
+ * - exit code: 0 on successful build, 1 on failure (for scripting).
38
+ *
39
+ * # Lifetime
40
+ *
41
+ * Each test spawns, awaits, and discards the subprocess. The subprocess
42
+ * does NOT reuse a vendor cache across runs — every fresh `rootDir` is a
43
+ * clean tmpdir with no prior `.mandu/vendor-cache/`.
44
+ */
45
+
46
+ import { buildClientBundles } from "../build";
47
+ import type { RoutesManifest } from "../../spec/schema";
48
+
49
+ const rootDir = process.argv[2];
50
+ if (!rootDir) {
51
+ console.error("usage: build-runner.ts <rootDir>");
52
+ process.exit(2);
53
+ }
54
+
55
+ const manifest: RoutesManifest = {
56
+ version: 1,
57
+ routes: [
58
+ {
59
+ id: "demo",
60
+ kind: "page",
61
+ pattern: "/",
62
+ module: "app/page.tsx",
63
+ componentModule: "app/page.tsx",
64
+ clientModule: "app/demo.client.tsx",
65
+ hydration: {
66
+ strategy: "island",
67
+ priority: "visible",
68
+ preload: false,
69
+ },
70
+ },
71
+ ],
72
+ };
73
+
74
+ try {
75
+ const result = await buildClientBundles(manifest, rootDir, {
76
+ minify: false,
77
+ sourcemap: false,
78
+ splitting: false,
79
+ });
80
+ process.stdout.write(
81
+ JSON.stringify({
82
+ success: result.success,
83
+ errors: result.errors,
84
+ manifest: {
85
+ shared: {
86
+ fastRefresh: result.manifest.shared?.fastRefresh ?? null,
87
+ },
88
+ },
89
+ }) + "\n",
90
+ );
91
+ process.exit(result.success ? 0 : 1);
92
+ } catch (err) {
93
+ process.stdout.write(
94
+ JSON.stringify({
95
+ success: false,
96
+ errors: [String(err)],
97
+ manifest: null,
98
+ }) + "\n",
99
+ );
100
+ process.exit(1);
101
+ }
@@ -534,36 +534,46 @@ describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
534
534
  });
535
535
 
536
536
  test("[E1] dev build produces _vendor-react-refresh.js + _fast-refresh-runtime.js + manifest.shared.fastRefresh entry", async () => {
537
- const { buildClientBundles } = await import("../build");
538
- const manifest = {
539
- version: 1 as const,
540
- routes: [
541
- {
542
- id: "demo",
543
- kind: "page" as const,
544
- pattern: "/",
545
- module: "app/page.tsx",
546
- componentModule: "app/page.tsx",
547
- clientModule: "app/demo.client.tsx",
548
- hydration: {
549
- strategy: "island" as const,
550
- priority: "visible" as const,
551
- preload: false,
552
- },
553
- },
554
- ],
555
- };
556
- // Force dev-mode build by explicit `minify: false`. The build
557
- // function's `isDev` branch is keyed off `options.minify`.
558
- const result = await buildClientBundles(manifest, rootDir, {
559
- minify: false,
560
- sourcemap: false,
561
- splitting: false,
537
+ // Force dev-mode build by spawning `buildClientBundles` in a fresh
538
+ // bun subprocess — see build-runner.ts header for the full story.
539
+ // In short: Bun 1.3.x's bundler resolver state gets poisoned when
540
+ // any sibling test file imports `react` / `react-dom`, making the
541
+ // 7-parallel shim fan-out fail with `AggregateError: Bundle failed`
542
+ // ~100 % of the time for the affected shim(s). A subprocess has a
543
+ // clean module graph.
544
+ const { spawn } = await import("node:child_process");
545
+ const runner = path.join(import.meta.dir, "build-runner.ts");
546
+ const cwd = path.resolve(import.meta.dir, "..", "..", "..");
547
+ const out = await new Promise<string>((resolve) => {
548
+ const proc = spawn(process.execPath, ["run", runner, rootDir], {
549
+ cwd,
550
+ stdio: ["ignore", "pipe", "inherit"],
551
+ });
552
+ let buf = "";
553
+ proc.stdout.on("data", (d: Buffer) => (buf += d.toString("utf-8")));
554
+ proc.on("close", () => resolve(buf));
562
555
  });
563
- if (!result.success) {
564
- console.error("[fr-vendor] errors:", result.errors);
556
+ const jsonLine =
557
+ out
558
+ .split(/\r?\n/)
559
+ .filter((l) => l.trim().length > 0)
560
+ .pop() ?? "";
561
+ let parsed: {
562
+ success: boolean;
563
+ errors: string[];
564
+ manifest: { shared: { fastRefresh: { runtime: string; glue: string } | null } } | null;
565
+ };
566
+ try {
567
+ parsed = JSON.parse(jsonLine);
568
+ } catch (e) {
569
+ throw new Error(
570
+ `build-runner stdout could not be parsed as JSON: ${String(e)}\nLast line: ${jsonLine}`,
571
+ );
565
572
  }
566
- expect(result.success).toBe(true);
573
+ if (!parsed.success) {
574
+ console.error("[fr-vendor] errors:", parsed.errors);
575
+ }
576
+ expect(parsed.success).toBe(true);
567
577
  // Both shim files must exist on disk
568
578
  const glueFile = path.join(
569
579
  rootDir,
@@ -585,10 +595,10 @@ describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
585
595
  // bundle output — avoids requiring a full evaluation)
586
596
  expect(glueContents).toContain("installGlobal");
587
597
  // Manifest exposes the paths
588
- expect(result.manifest.shared.fastRefresh?.runtime).toBe(
598
+ expect(parsed.manifest?.shared.fastRefresh?.runtime).toBe(
589
599
  "/.mandu/client/_vendor-react-refresh.js",
590
600
  );
591
- expect(result.manifest.shared.fastRefresh?.glue).toBe(
601
+ expect(parsed.manifest?.shared.fastRefresh?.glue).toBe(
592
602
  "/.mandu/client/_fast-refresh-runtime.js",
593
603
  );
594
604
  });
@@ -2,19 +2,74 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
3
3
  import path from "path";
4
4
  import { pathToFileURL } from "url";
5
- import type { RoutesManifest } from "../spec/schema";
6
- import type { BundleResult } from "./types";
7
- import { buildClientBundles } from "./build";
5
+ import { spawn } from "node:child_process";
8
6
 
9
7
  // 모든 테스트가 하나의 빌드 결과를 공유 — 병렬 Bun.build 충돌 방지
10
8
  let rootDir: string;
11
- let result: BundleResult;
9
+ let result: { success: boolean; errors: string[] };
12
10
 
13
11
  async function importBuiltModule(relativePath: string): Promise<Record<string, unknown>> {
14
12
  const fileUrl = pathToFileURL(path.join(rootDir, relativePath)).href;
15
13
  return import(`${fileUrl}?t=${Date.now()}`);
16
14
  }
17
15
 
16
+ /**
17
+ * Run `buildClientBundles` in an isolated `bun` subprocess.
18
+ *
19
+ * Bun 1.3.x exhibits a deterministic `AggregateError: Bundle failed` when
20
+ * `buildClientBundles` is called from a test file AND the same `bun test`
21
+ * process has previously imported `react` / `react-dom` through any sibling
22
+ * test file (happens transitively through almost every `src/testing/*` or
23
+ * `src/runtime/*` consumer). Retrying in-process does not recover — the
24
+ * resolver state is sticky. A fresh subprocess has a clean module graph.
25
+ * See `__tests__/build-runner.ts` for the subprocess entrypoint and more
26
+ * background.
27
+ */
28
+ async function runBuildInSubprocess(root: string): Promise<{
29
+ success: boolean;
30
+ errors: string[];
31
+ }> {
32
+ const runner = path.join(
33
+ import.meta.dir,
34
+ "__tests__",
35
+ "build-runner.ts",
36
+ );
37
+ return new Promise((resolve) => {
38
+ const proc = spawn(process.execPath, ["run", runner, root], {
39
+ cwd: path.resolve(import.meta.dir, "..", ".."),
40
+ stdio: ["ignore", "pipe", "inherit"],
41
+ });
42
+ let out = "";
43
+ proc.stdout.on("data", (chunk: Buffer) => {
44
+ out += chunk.toString("utf-8");
45
+ });
46
+ proc.on("close", () => {
47
+ // Find the final JSON line — the runner may log Mandu dev banners
48
+ // (e.g. "[Mandu] DevTools …") before emitting the payload. The
49
+ // contract is: last non-empty line is the JSON blob.
50
+ const lines = out.split(/\r?\n/).filter((l) => l.trim().length > 0);
51
+ const last = lines[lines.length - 1] ?? "";
52
+ try {
53
+ const parsed = JSON.parse(last);
54
+ resolve({
55
+ success: parsed.success === true,
56
+ errors: Array.isArray(parsed.errors) ? parsed.errors : [],
57
+ });
58
+ } catch (e) {
59
+ resolve({
60
+ success: false,
61
+ errors: [
62
+ `build-runner output could not be parsed as JSON: ${String(e)}\nLast stdout line: ${last}`,
63
+ ],
64
+ });
65
+ }
66
+ });
67
+ proc.on("error", (err) => {
68
+ resolve({ success: false, errors: [`spawn failed: ${String(err)}`] });
69
+ });
70
+ });
71
+ }
72
+
18
73
  beforeAll(async () => {
19
74
  rootDir = await mkdtemp(path.join(import.meta.dir, ".tmp-bundler-"));
20
75
 
@@ -30,30 +85,7 @@ beforeAll(async () => {
30
85
  "utf-8",
31
86
  );
32
87
 
33
- const manifest: RoutesManifest = {
34
- version: 1,
35
- routes: [
36
- {
37
- id: "demo",
38
- kind: "page",
39
- pattern: "/",
40
- module: "app/page.tsx",
41
- componentModule: "app/page.tsx",
42
- clientModule: "app/demo.client.tsx",
43
- hydration: {
44
- strategy: "island",
45
- priority: "visible",
46
- preload: false,
47
- },
48
- },
49
- ],
50
- };
51
-
52
- result = await buildClientBundles(manifest, rootDir, {
53
- minify: false,
54
- sourcemap: false,
55
- splitting: false,
56
- });
88
+ result = await runBuildInSubprocess(rootDir);
57
89
  });
58
90
 
59
91
  afterAll(async () => {
@@ -62,14 +94,30 @@ afterAll(async () => {
62
94
  }
63
95
  });
64
96
 
65
- // Bun.build has observed cross-worker races under bun:test's parallel file
66
- // execution — when tests shuffle under --randomize, this file sometimes
67
- // collides with another bundler test in a sibling worker and fails with
68
- // "AggregateError: Bundle failed" + missing shim outputs. Gate on an env
69
- // var so CI can run randomize-mode across everything else, then run this
70
- // suite in a dedicated serial pass. Local `bun run test:core` (no env set)
71
- // still runs these tests fully. Tracked as Phase 0.6.
72
- describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")("buildClientBundles vendor shims", () => {
97
+ // Historical note `MANDU_SKIP_BUNDLER_TESTS` gate REMOVED.
98
+ //
99
+ // A previous revision gated this describe block behind
100
+ // `describe.skipIf(MANDU_SKIP_BUNDLER_TESTS === "1")` because running
101
+ // `bun test src/bundler/` without the gate hung indefinitely on Windows
102
+ // (see Phase 0.6 and `docs/qa/wave-R2-integration-report.md`). Root cause
103
+ // was NOT actually in THIS file it was a deadlock in `safe-build.test.ts`'s
104
+ // "slot handoff" regression test, which drove Bun's microtask queue with a
105
+ // `while (!stop) { await Promise.resolve() }` sampler. That starved libuv
106
+ // I/O callbacks, so the 7 parallel `safeBuild()` calls never completed, the
107
+ // whole test process hung, and downstream test files (including this one
108
+ // when run in the same invocation) looked flaky when they were simply
109
+ // never reached. The handoff sampler now yields via `setImmediate`, which
110
+ // unblocks Bun.build completion and makes `bun test src/bundler/` finish
111
+ // deterministically in ~35s on Windows. Confirmed green 3/3 runs without
112
+ // the gate on 2026-04-20. If you are tempted to re-introduce the skip here,
113
+ // first check whether a sibling test is starving the event loop.
114
+ //
115
+ // A second, independent flake — Bun.build `AggregateError: Bundle failed`
116
+ // when another test file in the same invocation has imported `react` —
117
+ // is now sidestepped by running `buildClientBundles` in a spawned `bun`
118
+ // subprocess via `__tests__/build-runner.ts`. In-process retry does not
119
+ // recover from that one; a fresh module graph does.
120
+ describe("buildClientBundles vendor shims", () => {
73
121
  test("build succeeds", () => {
74
122
  if (!result.success) {
75
123
  console.error("[build.test] errors:", result.errors);
@@ -141,16 +141,34 @@ describe("safeBuild", () => {
141
141
 
142
142
  let peak = 0;
143
143
  let samples = 0;
144
- // Sample at microtask granularity more aggressive than the setInterval
145
- // sampler in the earlier test so the cap+1 window has a realistic
146
- // chance of being observed if the bug returned.
144
+ // Sample active-slot count while the build burst is in flight. An earlier
145
+ // revision of this test used a `while (!stop) { await Promise.resolve() }`
146
+ // microtask busy-loop to push sampling granularity below setInterval's
147
+ // Windows 4ms-ish clamp. That deadlocks under Bun 1.3.x on Windows:
148
+ // `await Promise.resolve()` stays on the microtask queue, which runs
149
+ // to exhaustion before Bun's libuv I/O phase — so Bun.build completion
150
+ // callbacks never fire, `releaseSlot()` never runs, and the promises
151
+ // returned by the 7 parallel `safeBuild()` calls hang indefinitely.
152
+ // Reproduction: `bun test src/bundler/safe-build.test.ts` times out with
153
+ // only the banner printed (confirmed with a standalone repro of the
154
+ // sampler + 7 safeBuild calls — hung at "start" past 60s).
155
+ //
156
+ // Fix: yield to the macrotask queue via `setImmediate`. This lets
157
+ // libuv I/O callbacks run between samples, so Bun.build completes and
158
+ // `releaseSlot()` advances the queue. Per-tick granularity on Node/Bun
159
+ // is still sub-millisecond and fires ~hundreds of times during a 7-
160
+ // build burst — more than enough to statistically catch the cap+1
161
+ // regression window if it ever returned (the window is microtask-sized,
162
+ // but any cross-tick sampling with high fan-out has a realistic chance
163
+ // of landing inside it). The strict assertion is still `peak <= max`.
147
164
  let stop = false;
148
165
  const sample = async () => {
149
166
  while (!stop) {
150
167
  const { active } = _getConcurrencyState();
151
168
  if (active > peak) peak = active;
152
169
  samples++;
153
- await Promise.resolve(); // yield to microtask queue
170
+ // Yield to libuv I/O phase so Bun.build callbacks can fire.
171
+ await new Promise<void>((resolve) => setImmediate(resolve));
154
172
  }
155
173
  };
156
174
  const sampler = sample();