@mandujs/core 0.23.0 → 0.24.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.
@@ -125,4 +125,58 @@ describe("safeBuild", () => {
125
125
  expect(state.active).toBe(0);
126
126
  expect(state.queued).toBe(0);
127
127
  });
128
+
129
+ it("slot handoff — new callers cannot bypass queued waiters (regression for cap+1 race)", async () => {
130
+ // Scenario: a build completes with a waiter queued; a NEW safeBuild call
131
+ // fires on the same microtask. A prior revision decremented `active`
132
+ // before resolving the waiter, leaving a microtask-sized window where
133
+ // the new caller saw `active < max`, skipped the wait, and became the
134
+ // cap+1 concurrent build. This test launches 3*max + 1 builds, samples
135
+ // active at every slot release, and asserts the peak never exceeds max.
136
+ const { max } = _getConcurrencyState();
137
+ const N = max * 3 + 1; // always enough to trigger at least one handoff
138
+ const entries = await Promise.all(
139
+ Array.from({ length: N }, (_, i) => makeEntry(`handoff-${i}`)),
140
+ );
141
+
142
+ let peak = 0;
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.
147
+ let stop = false;
148
+ const sample = async () => {
149
+ while (!stop) {
150
+ const { active } = _getConcurrencyState();
151
+ if (active > peak) peak = active;
152
+ samples++;
153
+ await Promise.resolve(); // yield to microtask queue
154
+ }
155
+ };
156
+ const sampler = sample();
157
+
158
+ try {
159
+ const results = await Promise.all(
160
+ entries.map((entry) =>
161
+ safeBuild({
162
+ entrypoints: [entry],
163
+ outdir: rootDir,
164
+ target: "browser",
165
+ naming: path.basename(entry, ".ts") + ".[ext]",
166
+ }),
167
+ ),
168
+ );
169
+ expect(results.every((r) => r.success)).toBe(true);
170
+ } finally {
171
+ stop = true;
172
+ await sampler;
173
+ }
174
+
175
+ expect(peak).toBeLessThanOrEqual(max);
176
+ expect(samples).toBeGreaterThan(0);
177
+
178
+ const state = _getConcurrencyState();
179
+ expect(state.active).toBe(0);
180
+ expect(state.queued).toBe(0);
181
+ });
128
182
  });
@@ -20,6 +20,15 @@
20
20
  * - In-process only. Cross-worker coordination is not this module's job;
21
21
  * per-worker throttling already prevents the observed failure modes in
22
22
  * our test matrix.
23
+ *
24
+ * Correctness — slot handoff:
25
+ * - The semaphore does slot *handoff* rather than release-then-acquire. When
26
+ * a build completes with waiters queued, the slot is transferred directly
27
+ * to the next waiter (active count never drops). A previous revision had
28
+ * a classic acquire race: `releaseSlot()` decremented `active` before the
29
+ * waiter's `active++` ran, which opened a microtask-sized window where a
30
+ * concurrent `safeBuild()` call could observe `active < max`, skip the
31
+ * wait, and join in — yielding `cap+1` concurrent builds.
23
32
  */
24
33
 
25
34
  import type { BuildConfig, BuildOutput } from "bun";
@@ -38,16 +47,36 @@ const maxConcurrent = parseMaxConcurrent();
38
47
  let active = 0;
39
48
  const waiters: Array<() => void> = [];
40
49
 
41
- function waitForSlot(): Promise<void> {
50
+ /**
51
+ * Acquire a slot. When `active < max`, increment synchronously and return.
52
+ * Otherwise, queue and await a direct handoff from a completing build —
53
+ * the completing build does NOT decrement `active`; it resolves our waiter,
54
+ * and `active` stays at `max` through the transition. This prevents a
55
+ * microtask-window race where a third caller could see `active < max` and
56
+ * skip the wait entirely.
57
+ */
58
+ function acquireSlot(): Promise<void> {
59
+ if (active < maxConcurrent) {
60
+ active++;
61
+ return Promise.resolve();
62
+ }
42
63
  return new Promise<void>((resolve) => {
43
64
  waiters.push(resolve);
44
65
  });
45
66
  }
46
67
 
68
+ /**
69
+ * Release a slot. If a waiter is queued, hand the slot off directly (keep
70
+ * `active` at `max`, resolve the waiter). Otherwise decrement `active`.
71
+ */
47
72
  function releaseSlot(): void {
48
- active--;
49
73
  const next = waiters.shift();
50
- if (next) next();
74
+ if (next) {
75
+ // Direct handoff — `active` stays at max, waiter resumes holding the slot.
76
+ next();
77
+ } else {
78
+ active--;
79
+ }
51
80
  }
52
81
 
53
82
  /**
@@ -60,10 +89,7 @@ function releaseSlot(): void {
60
89
  * coordination work.
61
90
  */
62
91
  export async function safeBuild(options: BuildConfig): Promise<BuildOutput> {
63
- if (active >= maxConcurrent) {
64
- await waitForSlot();
65
- }
66
- active++;
92
+ await acquireSlot();
67
93
  try {
68
94
  return await Bun.build(options);
69
95
  } finally {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Issue #192 — Hover prefetch helper
3
+ *
4
+ * Self-contained IIFE that watches `mouseover` events bubbling from
5
+ * internal `<a href="/...">` anchors and issues a one-shot
6
+ * `<link rel="prefetch" as="document">` per unique anchor. The browser
7
+ * cache services the subsequent full-reload navigation, so most
8
+ * above-the-fold links feel instantaneous without requiring a SPA
9
+ * runtime.
10
+ *
11
+ * Design choices (locked to keep the payload tiny — target ≤500 bytes
12
+ * minified + gzipped):
13
+ *
14
+ * 1. **WeakSet-based dedup**: we never attach per-anchor listeners;
15
+ * we use a single `document`-level capture listener and stamp
16
+ * each anchor we've seen into a `WeakSet`. Anchors removed from
17
+ * the DOM collect automatically.
18
+ *
19
+ * 2. **Scope: same-origin `/...` paths only**: we deliberately skip
20
+ * absolute URLs, `mailto:`, `tel:`, `javascript:`, hash-only
21
+ * fragments, and `#`-on-same-page links. The `a[href^="/"]`
22
+ * selector implicitly covers this and avoids a surprise
23
+ * cross-origin DNS lookup.
24
+ *
25
+ * 3. **Opt-out per-link via `data-no-prefetch`**: mirrors Next.js'
26
+ * `prefetch={false}` ergonomics while being framework-agnostic —
27
+ * works with plain `<a>` and with Mandu's `<Link>` component.
28
+ *
29
+ * 4. **`as="document"` hint**: the correct token for HTML documents
30
+ * that will be navigated to via a subsequent click. Without it
31
+ * Chrome 121+ logs a `rel=prefetch as=missing` warning.
32
+ *
33
+ * 5. **Capture phase + `passive: true`**: listening in the capture
34
+ * phase catches the event before any app-level bubble handlers
35
+ * can cancel it (defensive against apps that `stopPropagation`
36
+ * on `<a>`). `passive` ensures we can never accidentally block
37
+ * scroll.
38
+ *
39
+ * 6. **Inline, not external bundle**: the helper is under 1KB and
40
+ * emitting it as a `<script>` child (rather than
41
+ * `<script src=".../_prefetch.js">`) avoids one HTTP round-trip
42
+ * on every SSR response and keeps the module graph unchanged.
43
+ *
44
+ * The exported `PREFETCH_HELPER_SCRIPT` wraps the IIFE in a
45
+ * `<script>` tag, ready to paste into `<head>`. Callers MUST keep it
46
+ * nonce-aware if they plan to enable CSP (future work — right now the
47
+ * Fast Refresh preamble is the only inline script covered by Mandu's
48
+ * CSP header).
49
+ */
50
+
51
+ /** Inner IIFE — exposed for unit tests that want to parse the source. */
52
+ export const PREFETCH_HELPER_BODY = `(function(){var s=new WeakSet();document.addEventListener("mouseover",function(e){var t=e.target;if(!t||typeof t.closest!=="function")return;var a=t.closest("a[href^='/']");if(!a||s.has(a))return;if(a.dataset&&a.dataset.noPrefetch!==undefined)return;if(a.hasAttribute&&a.hasAttribute("download"))return;if(a.target&&a.target!=="_self")return;s.add(a);try{var l=document.createElement("link");l.rel="prefetch";l.href=a.href;l.as="document";document.head.appendChild(l);}catch(_){}},{passive:true,capture:true});})();`;
53
+
54
+ /** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
55
+ export const PREFETCH_HELPER_SCRIPT = `<script>${PREFETCH_HELPER_BODY}</script>`;
@@ -1,172 +1,216 @@
1
- import path from "path";
2
- import { readJsonFile } from "../utils/bun";
3
- import type { ManduAdapter } from "../runtime/adapter";
4
- import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
5
-
6
- export type GuardRuleSeverity = "error" | "warn" | "warning" | "off";
7
-
8
- /**
9
- * Test block configuration (Phase 12.1 — testing ecosystem).
10
- *
11
- * Shapes the CLI `mandu test` command's discovery, fixture, and reporter
12
- * behaviour. All fields are optional; omitting the block yields sensible
13
- * defaults that match Next.js / SvelteKit user expectations:
14
- *
15
- * - unit → `**\/*.test.ts` / `**\/*.test.tsx`, 30s timeout
16
- * - integration → `tests/integration/**\/*.test.ts`, in-memory fixtures
17
- * - e2e → reserved for Phase 12.2 (ATE integration)
18
- * - coverage → reserved for Phase 12.3 (bun + playwright merge)
19
- */
20
- export interface TestUnitConfig {
21
- /** Glob patterns for unit test files. Default: `["**\/*.test.ts", "**\/*.test.tsx"]`. */
22
- include?: string[];
23
- /** Glob patterns to exclude (applied after `include`). Default: `["node_modules/**", ".mandu/**", "dist/**"]`. */
24
- exclude?: string[];
25
- /** Per-test timeout in milliseconds. Default: `30_000` (30s). */
26
- timeout?: number;
27
- }
28
-
29
- export interface TestIntegrationConfig {
30
- /** Glob patterns for integration test files. Default: `["tests/integration/**\/*.test.ts"]`. */
31
- include?: string[];
32
- /** Glob patterns to exclude. Default: same as unit defaults. */
33
- exclude?: string[];
34
- /**
35
- * Database URL for fixtures. Default: `"sqlite::memory:"` (in-memory SQLite).
36
- * Accepts any Bun.sql-compatible URL — see `@mandujs/core/db` for the schema matrix.
37
- */
38
- dbUrl?: string;
39
- /**
40
- * Session storage strategy for `createTestSession`.
41
- * - `"memory"` (default): CookieSessionStorage with ephemeral secret
42
- * - `"sqlite"`: bun:sqlite-backed (Phase 2.5 storage, requires Phase 4)
43
- */
44
- sessionStore?: "memory" | "sqlite";
45
- /** Per-test timeout. Default: `60_000` (60s — integration work is slower). */
46
- timeout?: number;
47
- }
48
-
49
- export interface TestE2EConfig {
50
- /** Reserved for Phase 12.2. Currently a typed placeholder. */
51
- reserved?: true;
52
- }
53
-
54
- export interface TestCoverageConfig {
55
- /** Minimum line coverage percentage (0-100). Reserved for Phase 12.3. */
56
- lines?: number;
57
- /** Minimum branch coverage percentage (0-100). Reserved for Phase 12.3. */
58
- branches?: number;
59
- }
60
-
61
- export interface TestConfig {
62
- unit?: TestUnitConfig;
63
- integration?: TestIntegrationConfig;
64
- e2e?: TestE2EConfig;
65
- coverage?: TestCoverageConfig;
66
- }
67
-
68
- export interface ManduConfig {
69
- adapter?: ManduAdapter;
70
- server?: {
71
- port?: number;
72
- hostname?: string;
73
- cors?:
74
- | boolean
75
- | {
76
- origin?: string | string[];
77
- methods?: string[];
78
- credentials?: boolean;
79
- };
80
- streaming?: boolean;
81
- rateLimit?:
82
- | boolean
83
- | {
84
- windowMs?: number;
85
- max?: number;
86
- message?: string;
87
- statusCode?: number;
88
- headers?: boolean;
89
- };
90
- };
91
- guard?: {
92
- preset?: "mandu" | "fsd" | "clean" | "hexagonal" | "atomic" | "cqrs";
93
- srcDir?: string;
94
- exclude?: string[];
95
- realtime?: boolean;
96
- rules?: Record<string, GuardRuleSeverity>;
97
- contractRequired?: GuardRuleSeverity;
98
- };
99
- build?: {
100
- outDir?: string;
101
- minify?: boolean;
102
- sourcemap?: boolean;
103
- splitting?: boolean;
104
- };
105
- dev?: {
106
- hmr?: boolean;
107
- watchDirs?: string[];
108
- /** Observability SQLite 영구 저장 (기본: true) */
109
- observability?: boolean;
110
- };
111
- fsRoutes?: {
112
- routesDir?: string;
113
- extensions?: string[];
114
- exclude?: string[];
115
- islandSuffix?: string;
116
- };
117
- seo?: {
118
- enabled?: boolean;
119
- defaultTitle?: string;
120
- titleTemplate?: string;
121
- };
122
- /** Phase 12.1 — `mandu test` configuration block. */
123
- test?: TestConfig;
124
- plugins?: ManduPlugin[];
125
- hooks?: Partial<ManduHooks>;
126
- }
127
-
128
- export const CONFIG_FILES = [
129
- "mandu.config.ts",
130
- "mandu.config.js",
131
- "mandu.config.json",
132
- path.join(".mandu", "guard.json"),
133
- ];
134
-
135
- export function coerceConfig(raw: unknown, source: string): ManduConfig {
136
- if (!raw || typeof raw !== "object") return {};
137
-
138
- // .mandu/guard.json can be guard-only
139
- if (source.endsWith("guard.json") && !("guard" in (raw as Record<string, unknown>))) {
140
- return { guard: raw as ManduConfig["guard"] };
141
- }
142
-
143
- return raw as ManduConfig;
144
- }
145
-
146
- export async function loadManduConfig(rootDir: string): Promise<ManduConfig> {
147
- for (const fileName of CONFIG_FILES) {
148
- const filePath = path.join(rootDir, fileName);
149
- if (!(await Bun.file(filePath).exists())) {
150
- continue;
151
- }
152
-
153
- if (fileName.endsWith(".json")) {
154
- try {
155
- const parsed = await readJsonFile(filePath);
156
- return coerceConfig(parsed, fileName);
157
- } catch {
158
- return {};
159
- }
160
- }
161
-
162
- try {
163
- const module = await import(filePath);
164
- const raw = module?.default ?? module;
165
- return coerceConfig(raw, fileName);
166
- } catch {
167
- return {};
168
- }
169
- }
170
-
171
- return {};
172
- }
1
+ import path from "path";
2
+ import { readJsonFile } from "../utils/bun";
3
+ import type { ManduAdapter } from "../runtime/adapter";
4
+ import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
5
+
6
+ export type GuardRuleSeverity = "error" | "warn" | "warning" | "off";
7
+
8
+ /**
9
+ * Test block configuration (Phase 12.1 — testing ecosystem).
10
+ *
11
+ * Shapes the CLI `mandu test` command's discovery, fixture, and reporter
12
+ * behaviour. All fields are optional; omitting the block yields sensible
13
+ * defaults that match Next.js / SvelteKit user expectations:
14
+ *
15
+ * - unit → `**\/*.test.ts` / `**\/*.test.tsx`, 30s timeout
16
+ * - integration → `tests/integration/**\/*.test.ts`, in-memory fixtures
17
+ * - e2e → reserved for Phase 12.2 (ATE integration)
18
+ * - coverage → reserved for Phase 12.3 (bun + playwright merge)
19
+ */
20
+ export interface TestUnitConfig {
21
+ /** Glob patterns for unit test files. Default: `["**\/*.test.ts", "**\/*.test.tsx"]`. */
22
+ include?: string[];
23
+ /** Glob patterns to exclude (applied after `include`). Default: `["node_modules/**", ".mandu/**", "dist/**"]`. */
24
+ exclude?: string[];
25
+ /** Per-test timeout in milliseconds. Default: `30_000` (30s). */
26
+ timeout?: number;
27
+ }
28
+
29
+ export interface TestIntegrationConfig {
30
+ /** Glob patterns for integration test files. Default: `["tests/integration/**\/*.test.ts"]`. */
31
+ include?: string[];
32
+ /** Glob patterns to exclude. Default: same as unit defaults. */
33
+ exclude?: string[];
34
+ /**
35
+ * Database URL for fixtures. Default: `"sqlite::memory:"` (in-memory SQLite).
36
+ * Accepts any Bun.sql-compatible URL — see `@mandujs/core/db` for the schema matrix.
37
+ */
38
+ dbUrl?: string;
39
+ /**
40
+ * Session storage strategy for `createTestSession`.
41
+ * - `"memory"` (default): CookieSessionStorage with ephemeral secret
42
+ * - `"sqlite"`: bun:sqlite-backed (Phase 2.5 storage, requires Phase 4)
43
+ */
44
+ sessionStore?: "memory" | "sqlite";
45
+ /** Per-test timeout. Default: `60_000` (60s — integration work is slower). */
46
+ timeout?: number;
47
+ }
48
+
49
+ export interface TestE2EConfig {
50
+ /** Reserved for Phase 12.2. Currently a typed placeholder. */
51
+ reserved?: true;
52
+ }
53
+
54
+ export interface TestCoverageConfig {
55
+ /** Minimum line coverage percentage (0-100). Reserved for Phase 12.3. */
56
+ lines?: number;
57
+ /** Minimum branch coverage percentage (0-100). Reserved for Phase 12.3. */
58
+ branches?: number;
59
+ }
60
+
61
+ export interface TestConfig {
62
+ unit?: TestUnitConfig;
63
+ integration?: TestIntegrationConfig;
64
+ e2e?: TestE2EConfig;
65
+ coverage?: TestCoverageConfig;
66
+ }
67
+
68
+ export interface ManduConfig {
69
+ adapter?: ManduAdapter;
70
+ /**
71
+ * Issue #192 — Enable CSS View Transitions for cross-document
72
+ * navigations. When `true` (default) Mandu injects
73
+ * `<style>@view-transition { navigation: auto; }</style>` into the SSR
74
+ * `<head>`, which lets supporting browsers (Chrome/Edge ≥ 111) play a
75
+ * crossfade between the outgoing and incoming pages. Non-supporting
76
+ * browsers ignore the at-rule and fall back to the classic
77
+ * full-reload — zero regression.
78
+ *
79
+ * Set to `false` to opt out entirely (e.g. if your app ships a
80
+ * hand-rolled navigation animation or a conflicting CSS rule).
81
+ *
82
+ * Default: `true`.
83
+ */
84
+ transitions?: boolean;
85
+ /**
86
+ * Issue #192 — Enable hover-based link prefetch. When `true` (default)
87
+ * Mandu injects a ~500-byte inline script that listens for `mouseover`
88
+ * events on internal links (`<a href="/...">`) and issues a
89
+ * `<link rel="prefetch">` for each unique target. The browser's HTTP
90
+ * cache services the subsequent navigation, removing most of the TTFB
91
+ * for above-the-fold links.
92
+ *
93
+ * Per-link opt-out: add `data-no-prefetch` to an `<a>` tag to skip it.
94
+ * Global opt-out: set this field to `false`.
95
+ *
96
+ * Default: `true`.
97
+ */
98
+ prefetch?: boolean;
99
+ server?: {
100
+ port?: number;
101
+ hostname?: string;
102
+ cors?:
103
+ | boolean
104
+ | {
105
+ origin?: string | string[];
106
+ methods?: string[];
107
+ credentials?: boolean;
108
+ };
109
+ streaming?: boolean;
110
+ rateLimit?:
111
+ | boolean
112
+ | {
113
+ windowMs?: number;
114
+ max?: number;
115
+ message?: string;
116
+ statusCode?: number;
117
+ headers?: boolean;
118
+ };
119
+ };
120
+ guard?: {
121
+ preset?: "mandu" | "fsd" | "clean" | "hexagonal" | "atomic" | "cqrs";
122
+ srcDir?: string;
123
+ exclude?: string[];
124
+ realtime?: boolean;
125
+ rules?: Record<string, GuardRuleSeverity>;
126
+ contractRequired?: GuardRuleSeverity;
127
+ };
128
+ build?: {
129
+ outDir?: string;
130
+ minify?: boolean;
131
+ sourcemap?: boolean;
132
+ splitting?: boolean;
133
+ };
134
+ dev?: {
135
+ hmr?: boolean;
136
+ watchDirs?: string[];
137
+ /** Observability SQLite 영구 저장 (기본: true) */
138
+ observability?: boolean;
139
+ /**
140
+ * Issue #191 Dev-only `_devtools.js` (~1.15 MB React dev runtime +
141
+ * Mandu Kitchen panel) injection override.
142
+ *
143
+ * - `true` → force inject on every page (SSR-only projects that
144
+ * still want the Kitchen panel in dev).
145
+ * - `false` → force skip on every page (Kitchen-off dev loop).
146
+ * - `undefined` default. Inject iff the page's bundle manifest
147
+ * has at least one island. Pure-SSR pages download
148
+ * zero devtools bytes.
149
+ *
150
+ * Production builds never emit `_devtools.js`, so this flag is
151
+ * a no-op in prod regardless of value.
152
+ */
153
+ devtools?: boolean;
154
+ };
155
+ fsRoutes?: {
156
+ routesDir?: string;
157
+ extensions?: string[];
158
+ exclude?: string[];
159
+ islandSuffix?: string;
160
+ };
161
+ seo?: {
162
+ enabled?: boolean;
163
+ defaultTitle?: string;
164
+ titleTemplate?: string;
165
+ };
166
+ /** Phase 12.1 — `mandu test` configuration block. */
167
+ test?: TestConfig;
168
+ plugins?: ManduPlugin[];
169
+ hooks?: Partial<ManduHooks>;
170
+ }
171
+
172
+ export const CONFIG_FILES = [
173
+ "mandu.config.ts",
174
+ "mandu.config.js",
175
+ "mandu.config.json",
176
+ path.join(".mandu", "guard.json"),
177
+ ];
178
+
179
+ export function coerceConfig(raw: unknown, source: string): ManduConfig {
180
+ if (!raw || typeof raw !== "object") return {};
181
+
182
+ // .mandu/guard.json can be guard-only
183
+ if (source.endsWith("guard.json") && !("guard" in (raw as Record<string, unknown>))) {
184
+ return { guard: raw as ManduConfig["guard"] };
185
+ }
186
+
187
+ return raw as ManduConfig;
188
+ }
189
+
190
+ export async function loadManduConfig(rootDir: string): Promise<ManduConfig> {
191
+ for (const fileName of CONFIG_FILES) {
192
+ const filePath = path.join(rootDir, fileName);
193
+ if (!(await Bun.file(filePath).exists())) {
194
+ continue;
195
+ }
196
+
197
+ if (fileName.endsWith(".json")) {
198
+ try {
199
+ const parsed = await readJsonFile(filePath);
200
+ return coerceConfig(parsed, fileName);
201
+ } catch {
202
+ return {};
203
+ }
204
+ }
205
+
206
+ try {
207
+ const module = await import(filePath);
208
+ const raw = module?.default ?? module;
209
+ return coerceConfig(raw, fileName);
210
+ } catch {
211
+ return {};
212
+ }
213
+ }
214
+
215
+ return {};
216
+ }