@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.
- package/package.json +1 -1
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -0
- package/src/bundler/dev.ts +288 -0
- package/src/bundler/reverse-import-graph.ts +339 -0
- package/src/bundler/safe-build.test.ts +54 -0
- package/src/bundler/safe-build.ts +33 -7
- package/src/client/prefetch-helper.ts +55 -0
- package/src/config/mandu.ts +216 -172
- package/src/config/validate.ts +376 -357
- package/src/runtime/adapter-bun.ts +64 -62
- package/src/runtime/server.ts +110 -4
- package/src/runtime/ssr.ts +146 -7
- package/src/runtime/streaming-ssr.ts +89 -3
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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>`;
|
package/src/config/mandu.ts
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
}
|