@mandujs/core 0.20.10 → 0.22.0

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.
Files changed (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. package/src/testing/index.ts +247 -189
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Phase 7.2 R1 Agent C (H2) — BundleManifest schema validation.
3
+ *
4
+ * Background (see `docs/security/phase-7-1-audit.md` §M-02):
5
+ * Prior to Phase 7.2, callers read `.mandu/manifest.json` with a bare
6
+ * `JSON.parse(raw) as BundleManifest` cast. That was enough for happy-path
7
+ * dev but left an injection vector: an attacker with filesystem write to
8
+ * `.mandu/manifest.json` could rewrite `shared.fastRefresh.{glue,runtime}`
9
+ * to point at an external URL and the SSR preamble would fetch it (the
10
+ * browser does no cross-origin check for dynamic `import()`). With CSP off
11
+ * — Mandu's dev default — this is full RCE in the browser context.
12
+ *
13
+ * M-02 was scored Medium because the attack requires filesystem write,
14
+ * which is already a game-over pre-condition. The fix nonetheless closes
15
+ * the stealth window: a tampered manifest now throws at load time rather
16
+ * than silently serving evil URLs.
17
+ *
18
+ * This module exposes:
19
+ *
20
+ * - `BundleManifestSchema` — Zod schema for the manifest shape.
21
+ * - `validateBundleManifest(raw)` — strict validator. Throws
22
+ * `ManifestValidationError` on any
23
+ * schema mismatch, including the
24
+ * safe-URL constraints below.
25
+ * - `isSafeManduUrl(url)` — predicate used both inside the
26
+ * schema and at SSR preamble emit
27
+ * time for defense-in-depth.
28
+ *
29
+ * URL safety model (applies to `shared.runtime`, `shared.vendor`,
30
+ * `shared.router`, `shared.fastRefresh.glue`, `shared.fastRefresh.runtime`,
31
+ * `bundles[].js`, `bundles[].css`, `islands[].js`, `importMap.imports[*]`):
32
+ *
33
+ * ALLOW: absolute paths rooted at `/.mandu/client/` ending in `.js` or `.css`.
34
+ * The bundler itself only ever emits this shape.
35
+ * DENY: protocol URIs (`http://`, `https://`, `data:`, `javascript:`, ...),
36
+ * path traversal (`..`), backslashes, newlines, angle brackets,
37
+ * quotes, or anything longer than `MAX_URL_LEN` (4 KB).
38
+ *
39
+ * The ruleset is deliberately narrower than RFC 3986 — it is an allowlist,
40
+ * not a blocklist. Any URL format the bundler does not emit gets rejected.
41
+ * If a future bundler change introduces a new URL shape, update the schema
42
+ * first and let the tests fail-closed.
43
+ *
44
+ * References:
45
+ * docs/security/phase-7-1-audit.md §2 M-02
46
+ * docs/bun/phase-7-2-team-plan.md §3 Agent C H2
47
+ * packages/core/src/bundler/types.ts — `BundleManifest` runtime type
48
+ */
49
+
50
+ import { z } from "zod";
51
+
52
+ import type { BundleManifest } from "./types";
53
+
54
+ // ============================================================================
55
+ // Constants — Exported for direct tests / downstream re-use.
56
+ // ============================================================================
57
+
58
+ /**
59
+ * Maximum length any single manifest URL may take. 4 KB is ~8× the worst
60
+ * real-world path we have seen in tmpdir fixtures; anything larger is
61
+ * almost certainly a DoS probe or a malformed entry. Keep in sync with
62
+ * `appendBoundary` URL cap in `fast-refresh-plugin.ts` (both 2 KB).
63
+ */
64
+ export const MAX_MANIFEST_URL_LEN = 4096;
65
+
66
+ /**
67
+ * Shape constraint for Mandu-authored client assets. The bundler always
68
+ * writes to `/.mandu/client/{name}.js|.css`. We do not allow query strings
69
+ * in the manifest itself (callers add cache-bust `?t=<ts>` at emit time).
70
+ */
71
+ export const SAFE_MANDU_URL_REGEX = /^\/\.mandu\/client\/[A-Za-z0-9_./-]+\.(js|css|mjs)$/;
72
+
73
+ /**
74
+ * Characters we forbid anywhere inside a manifest URL — their presence
75
+ * signals either a serialization bug or an injection attempt (newlines
76
+ * break out of an inline `<script>` tag, `<`/`>` open new HTML contexts,
77
+ * quotes break out of attribute contexts).
78
+ */
79
+ export const FORBIDDEN_URL_CHARS = /[\x00-\x1f\x7f"<>`\\\n\r\t]/;
80
+
81
+ /**
82
+ * Substrings that indicate cross-origin / protocol escape attempts. The
83
+ * bundler never writes these; rejecting them makes `.mandu/manifest.json`
84
+ * tamper detection trivial.
85
+ */
86
+ export const FORBIDDEN_URL_SUBSTRINGS = [
87
+ "://",
88
+ "//",
89
+ "..",
90
+ "javascript:",
91
+ "data:",
92
+ "vbscript:",
93
+ "file:",
94
+ ] as const;
95
+
96
+ // ============================================================================
97
+ // Errors
98
+ // ============================================================================
99
+
100
+ /**
101
+ * Thrown when `validateBundleManifest` rejects input. Carries the full Zod
102
+ * issue list so callers (build.ts, dev.ts) can surface actionable messages
103
+ * instead of the default `ZodError` noise.
104
+ */
105
+ export class ManifestValidationError extends Error {
106
+ readonly issues: readonly { path: string; message: string }[];
107
+
108
+ constructor(
109
+ message: string,
110
+ issues: readonly { path: string; message: string }[] = [],
111
+ ) {
112
+ super(message);
113
+ this.name = "ManifestValidationError";
114
+ this.issues = issues;
115
+ }
116
+ }
117
+
118
+ // ============================================================================
119
+ // Primitives
120
+ // ============================================================================
121
+
122
+ /**
123
+ * Runtime predicate for Mandu-managed URLs. Surfaced separately from the
124
+ * schema so SSR callsites (`ssr.ts`, `streaming-ssr.ts`) can run the same
125
+ * check at preamble emit time — defense in depth against a manifest that
126
+ * slipped past validation (e.g. skipFrameworkBundles fallback path).
127
+ */
128
+ export function isSafeManduUrl(url: unknown): url is string {
129
+ if (typeof url !== "string") return false;
130
+ if (url.length === 0 || url.length > MAX_MANIFEST_URL_LEN) return false;
131
+ if (FORBIDDEN_URL_CHARS.test(url)) return false;
132
+ for (const s of FORBIDDEN_URL_SUBSTRINGS) {
133
+ if (url.includes(s)) return false;
134
+ }
135
+ return SAFE_MANDU_URL_REGEX.test(url);
136
+ }
137
+
138
+ /**
139
+ * Zod refinement that runs `isSafeManduUrl` with a helpful error message.
140
+ * We use a single shared factory so the same message text appears for
141
+ * every URL field — grepping support logs becomes one-pattern-fits-all.
142
+ */
143
+ const safeManduUrl = (fieldLabel: string) =>
144
+ z
145
+ .string()
146
+ .max(MAX_MANIFEST_URL_LEN, `${fieldLabel}: URL exceeds ${MAX_MANIFEST_URL_LEN} bytes`)
147
+ .refine(isSafeManduUrl, {
148
+ message: `${fieldLabel}: URL must match /.mandu/client/*.{js,css,mjs} with no protocol or traversal`,
149
+ });
150
+
151
+ // ============================================================================
152
+ // Shape schemas
153
+ // ============================================================================
154
+
155
+ const PrioritySchema = z.enum(["immediate", "visible", "idle", "interaction"]);
156
+
157
+ const BundleEntrySchema = z.object({
158
+ js: safeManduUrl("bundles[].js"),
159
+ css: safeManduUrl("bundles[].css").optional(),
160
+ dependencies: z.array(z.string()).default([]),
161
+ priority: PrioritySchema,
162
+ });
163
+
164
+ const IslandEntrySchema = z.object({
165
+ js: safeManduUrl("islands[].js"),
166
+ route: z.string().min(1),
167
+ priority: PrioritySchema,
168
+ });
169
+
170
+ const FastRefreshSchema = z.object({
171
+ runtime: safeManduUrl("shared.fastRefresh.runtime"),
172
+ glue: safeManduUrl("shared.fastRefresh.glue"),
173
+ });
174
+
175
+ const SharedSchema = z.object({
176
+ runtime: safeManduUrl("shared.runtime"),
177
+ vendor: safeManduUrl("shared.vendor"),
178
+ router: safeManduUrl("shared.router").optional(),
179
+ fastRefresh: FastRefreshSchema.optional(),
180
+ });
181
+
182
+ const ImportMapEntrySchema = z
183
+ .string()
184
+ .refine(
185
+ (url) => {
186
+ // Import map values may be bare specifiers OR Mandu-client URLs.
187
+ // Bare specifiers are strings without path-like characters (no
188
+ // leading slash). Mandu URLs follow the same rules as `safeManduUrl`.
189
+ if (url.length === 0 || url.length > MAX_MANIFEST_URL_LEN) return false;
190
+ if (FORBIDDEN_URL_CHARS.test(url)) return false;
191
+ // Accept absolute Mandu paths
192
+ if (url.startsWith("/")) return isSafeManduUrl(url);
193
+ // Reject protocol URIs everywhere in the map
194
+ for (const s of FORBIDDEN_URL_SUBSTRINGS) {
195
+ if (url.includes(s)) return false;
196
+ }
197
+ return true;
198
+ },
199
+ { message: "importMap value must be a bare specifier or /.mandu/client/* URL" },
200
+ );
201
+
202
+ const ImportMapSchema = z.object({
203
+ imports: z.record(z.string(), ImportMapEntrySchema),
204
+ });
205
+
206
+ /**
207
+ * Public BundleManifest schema. Matches `BundleManifest` in `./types.ts`
208
+ * but tightens URL / structural constraints that the TS type cannot
209
+ * express.
210
+ *
211
+ * Fields explicitly omitted from schema:
212
+ * - `buildTime` — validated as ISO string only.
213
+ *
214
+ * Optional fields (`css`, `router`, `fastRefresh`, `islands`, `importMap`)
215
+ * are allowed to be absent, matching the production manifest shape that
216
+ * `build.ts` emits when dev-only assets are not generated.
217
+ */
218
+ export const BundleManifestSchema = z
219
+ .object({
220
+ version: z.number().int().min(1),
221
+ buildTime: z.string().min(1),
222
+ env: z.enum(["development", "production"]),
223
+ bundles: z.record(z.string(), BundleEntrySchema),
224
+ islands: z.record(z.string(), IslandEntrySchema).optional(),
225
+ shared: SharedSchema,
226
+ importMap: ImportMapSchema.optional(),
227
+ })
228
+ .strict();
229
+
230
+ export type ValidatedBundleManifest = z.infer<typeof BundleManifestSchema>;
231
+
232
+ // ============================================================================
233
+ // Validator
234
+ // ============================================================================
235
+
236
+ /**
237
+ * Parse+validate a raw manifest object. Throws `ManifestValidationError`
238
+ * on any shape mismatch. The return value carries the same runtime type
239
+ * as `BundleManifest` so callers can keep the existing TS type without
240
+ * a cast.
241
+ *
242
+ * Intentional design points:
243
+ *
244
+ * 1. `BundleManifestSchema.strict()` rejects unknown top-level keys.
245
+ * That catches schema drift (a field the bundler started emitting
246
+ * but the validator didn't learn about) at build time rather than
247
+ * in production. If a legitimate new field shows up, update this
248
+ * file first — fail closed.
249
+ * 2. The thrown error's `.issues` array exposes the full list of
250
+ * violations rather than just the first. Build / dev paths log the
251
+ * whole set so the developer fixes them in one pass.
252
+ * 3. Validators are pure — no filesystem / console IO. Callers own
253
+ * logging. This keeps the function easy to unit-test and makes it
254
+ * safe to call from the SSR preamble (no startup overhead).
255
+ */
256
+ export function validateBundleManifest(raw: unknown): BundleManifest {
257
+ const result = BundleManifestSchema.safeParse(raw);
258
+ if (!result.success) {
259
+ const issues = result.error.issues.map((issue) => ({
260
+ path: issue.path.join("."),
261
+ message: issue.message,
262
+ }));
263
+ const firstFew = issues
264
+ .slice(0, 3)
265
+ .map((i) => `${i.path}: ${i.message}`)
266
+ .join("; ");
267
+ throw new ManifestValidationError(
268
+ `BundleManifest failed schema validation (${issues.length} issue${
269
+ issues.length === 1 ? "" : "s"
270
+ }): ${firstFew}`,
271
+ issues,
272
+ );
273
+ }
274
+ // Zod's inferred type matches BundleManifest by structural compat;
275
+ // the cast re-attaches the nominal TS name without runtime cost.
276
+ return result.data as unknown as BundleManifest;
277
+ }
278
+
279
+ /**
280
+ * Non-throwing variant. Returns either a validated manifest or a list of
281
+ * issues — convenient for callsites that want to log and continue with a
282
+ * fallback (e.g. `skipFrameworkBundles` path in build.ts that falls back
283
+ * to a full rebuild on any validation failure).
284
+ */
285
+ export function safeValidateBundleManifest(
286
+ raw: unknown,
287
+ ):
288
+ | { ok: true; manifest: BundleManifest }
289
+ | { ok: false; issues: { path: string; message: string }[] } {
290
+ const result = BundleManifestSchema.safeParse(raw);
291
+ if (!result.success) {
292
+ return {
293
+ ok: false,
294
+ issues: result.error.issues.map((issue) => ({
295
+ path: issue.path.join("."),
296
+ message: issue.message,
297
+ })),
298
+ };
299
+ }
300
+ return { ok: true, manifest: result.data as unknown as BundleManifest };
301
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtemp, writeFile, rm } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import path from "path";
5
+
6
+ import { safeBuild, _getConcurrencyState } from "./safe-build";
7
+
8
+ /**
9
+ * These tests verify that safeBuild caps concurrent Bun.build invocations.
10
+ * We build tiny entrypoints; correctness of the output is not under test here —
11
+ * that's covered by build.test.ts. We're testing the semaphore only.
12
+ */
13
+
14
+ let rootDir: string;
15
+
16
+ beforeEach(async () => {
17
+ rootDir = await mkdtemp(path.join(tmpdir(), "mandu-safebuild-test-"));
18
+ });
19
+
20
+ afterEach(async () => {
21
+ try {
22
+ await rm(rootDir, { recursive: true, force: true });
23
+ } catch {
24
+ // Windows may hold locks briefly after Bun.build
25
+ }
26
+ });
27
+
28
+ async function makeEntry(name: string, body = "export const x = 1;\n"): Promise<string> {
29
+ const file = path.join(rootDir, `${name}.ts`);
30
+ await writeFile(file, body);
31
+ return file;
32
+ }
33
+
34
+ describe("safeBuild", () => {
35
+ it("returns the same BuildOutput shape as Bun.build", async () => {
36
+ const entry = await makeEntry("a");
37
+ const result = await safeBuild({
38
+ entrypoints: [entry],
39
+ outdir: rootDir,
40
+ target: "browser",
41
+ });
42
+ expect(result.success).toBe(true);
43
+ expect(Array.isArray(result.outputs)).toBe(true);
44
+ expect(result.outputs.length).toBeGreaterThan(0);
45
+ });
46
+
47
+ it("propagates build errors without swallowing them", async () => {
48
+ const entry = await makeEntry("b", "import x from './does-not-exist';\nexport default x;\n");
49
+ // Bun.build either returns { success: false } or throws AggregateError
50
+ // depending on the failure mode. safeBuild must propagate either without
51
+ // altering semantics.
52
+ let caught: unknown = null;
53
+ let result: Awaited<ReturnType<typeof safeBuild>> | null = null;
54
+ try {
55
+ result = await safeBuild({
56
+ entrypoints: [entry],
57
+ outdir: rootDir,
58
+ target: "browser",
59
+ });
60
+ } catch (err) {
61
+ caught = err;
62
+ }
63
+ const softFailed = result !== null && !result.success;
64
+ const hardFailed = caught !== null;
65
+ expect(softFailed || hardFailed).toBe(true);
66
+ });
67
+
68
+ it("caps concurrent builds (never exceeds max) under fan-out", async () => {
69
+ const { max } = _getConcurrencyState();
70
+ expect(max).toBeGreaterThanOrEqual(1);
71
+
72
+ const entries = await Promise.all(
73
+ Array.from({ length: 8 }, (_, i) => makeEntry(`fan-${i}`)),
74
+ );
75
+
76
+ // Sample concurrency peak on a microtask schedule.
77
+ let peak = 0;
78
+ const sampler = setInterval(() => {
79
+ const { active } = _getConcurrencyState();
80
+ if (active > peak) peak = active;
81
+ }, 0);
82
+
83
+ try {
84
+ const results = await Promise.all(
85
+ entries.map((entry) =>
86
+ safeBuild({
87
+ entrypoints: [entry],
88
+ outdir: rootDir,
89
+ target: "browser",
90
+ naming: path.basename(entry, ".ts") + ".[ext]",
91
+ }),
92
+ ),
93
+ );
94
+ for (const r of results) {
95
+ expect(r.success).toBe(true);
96
+ }
97
+ } finally {
98
+ clearInterval(sampler);
99
+ }
100
+
101
+ // Peak observed concurrency must be <= max. Exact equality is not
102
+ // guaranteed because all 8 may resolve faster than the sampler ticks,
103
+ // but crucially peak must never exceed the cap.
104
+ expect(peak).toBeLessThanOrEqual(max);
105
+ });
106
+
107
+ it("drains the queue: all builds eventually complete", async () => {
108
+ const entries = await Promise.all(
109
+ Array.from({ length: 6 }, (_, i) => makeEntry(`drain-${i}`)),
110
+ );
111
+ const results = await Promise.all(
112
+ entries.map((entry) =>
113
+ safeBuild({
114
+ entrypoints: [entry],
115
+ outdir: rootDir,
116
+ target: "browser",
117
+ naming: path.basename(entry, ".ts") + ".[ext]",
118
+ }),
119
+ ),
120
+ );
121
+ expect(results.every((r) => r.success)).toBe(true);
122
+
123
+ // Semaphore must be fully released
124
+ const state = _getConcurrencyState();
125
+ expect(state.active).toBe(0);
126
+ expect(state.queued).toBe(0);
127
+ });
128
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Concurrency-limited wrapper around `Bun.build`.
3
+ *
4
+ * Why this exists:
5
+ * - `Bun.build` exhibits failure modes under high concurrent invocation —
6
+ * observed as `AggregateError: Bundle failed` with missing output shims
7
+ * (e.g. `_react-dom-client.js`) when 5+ builds fire in parallel and
8
+ * multiple test worker processes pile on simultaneously.
9
+ * - Our own `buildClientBundles` fans out vendor-shim builds via `Promise.all`
10
+ * (5 concurrent). Combined with parallel test workers or concurrent dev-mode
11
+ * rebuilds, total concurrent `Bun.build` invocations can exceed what the
12
+ * runtime/OS resolves reliably, yielding intermittent failures that are
13
+ * hard to diagnose.
14
+ *
15
+ * Design:
16
+ * - Process-wide semaphore. Default cap: `2` concurrent `Bun.build` per
17
+ * process. Tunable via `MANDU_BUN_BUILD_CONCURRENCY` (positive integer).
18
+ * - FIFO queue — no priority, no cancellation. Callers get `Bun.build`'s
19
+ * `BuildOutput` result (or exception) transparently.
20
+ * - In-process only. Cross-worker coordination is not this module's job;
21
+ * per-worker throttling already prevents the observed failure modes in
22
+ * our test matrix.
23
+ */
24
+
25
+ import type { BuildConfig, BuildOutput } from "bun";
26
+
27
+ const DEFAULT_MAX_CONCURRENT = 2;
28
+
29
+ function parseMaxConcurrent(): number {
30
+ const raw = process.env.MANDU_BUN_BUILD_CONCURRENCY;
31
+ if (!raw) return DEFAULT_MAX_CONCURRENT;
32
+ const parsed = Number.parseInt(raw, 10);
33
+ if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_MAX_CONCURRENT;
34
+ return parsed;
35
+ }
36
+
37
+ const maxConcurrent = parseMaxConcurrent();
38
+ let active = 0;
39
+ const waiters: Array<() => void> = [];
40
+
41
+ function waitForSlot(): Promise<void> {
42
+ return new Promise<void>((resolve) => {
43
+ waiters.push(resolve);
44
+ });
45
+ }
46
+
47
+ function releaseSlot(): void {
48
+ active--;
49
+ const next = waiters.shift();
50
+ if (next) next();
51
+ }
52
+
53
+ /**
54
+ * Runs `Bun.build(options)` subject to a process-wide concurrency cap.
55
+ * Preserves the exact return type and error semantics of `Bun.build`.
56
+ *
57
+ * Note: this wrapper only caps concurrency *within* a single process. Test
58
+ * harnesses that spawn multiple worker processes still see each worker run
59
+ * up to `maxConcurrent` concurrent builds — see Phase 0.6 for cross-process
60
+ * coordination work.
61
+ */
62
+ export async function safeBuild(options: BuildConfig): Promise<BuildOutput> {
63
+ if (active >= maxConcurrent) {
64
+ await waitForSlot();
65
+ }
66
+ active++;
67
+ try {
68
+ return await Bun.build(options);
69
+ } finally {
70
+ releaseSlot();
71
+ }
72
+ }
73
+
74
+ /** Exposed for tests — not part of the public API. */
75
+ export function _getConcurrencyState() {
76
+ return { active, queued: waiters.length, max: maxConcurrent };
77
+ }