@mandujs/core 0.32.0 → 0.33.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 +4 -1
- package/src/bundler/build.ts +29 -1
- package/src/bundler/generate-static-params.ts +302 -290
- package/src/bundler/prerender.ts +446 -368
- package/src/bundler/types.ts +20 -0
- package/src/config/validate.ts +22 -0
- package/src/diagnose/__tests__/checks.test.ts +378 -0
- package/src/diagnose/checks.ts +599 -0
- package/src/diagnose/index.ts +15 -0
- package/src/diagnose/run.ts +87 -0
- package/src/diagnose/types.ts +53 -0
- package/src/guard/graph.ts +898 -0
- package/src/guard/index.ts +14 -0
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -0
- package/src/plugins/__tests__/runner.test.ts +409 -0
- package/src/plugins/define.ts +124 -0
- package/src/plugins/examples/dep-check-plugin.ts +80 -0
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -0
- package/src/plugins/examples/sitemap-plugin.ts +65 -0
- package/src/plugins/hooks.ts +297 -64
- package/src/plugins/index.ts +80 -41
- package/src/plugins/runner.ts +361 -0
- package/src/router/fs-routes.ts +64 -1
- package/src/runtime/server.ts +132 -1
- package/src/spec/schema.ts +25 -0
- package/src/testing/__tests__/reporter.test.ts +454 -0
- package/src/testing/index.ts +29 -0
- package/src/testing/reporter.ts +676 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Plugin Hook Runner — Phase 18.τ.
|
|
3
|
+
*
|
|
4
|
+
* This module is the only place where plugin hook dispatch + merge
|
|
5
|
+
* semantics are implemented. Every integration point (bundler, prerender,
|
|
6
|
+
* router scanner, runtime server) imports from here so the rules are
|
|
7
|
+
* consistent and easy to audit.
|
|
8
|
+
*
|
|
9
|
+
* Dispatch order, common to every hook:
|
|
10
|
+
* 1. Config-level hooks (from `ManduConfig.hooks`) run FIRST.
|
|
11
|
+
* 2. Plugin hooks (from `ManduConfig.plugins[].hooks`) run in
|
|
12
|
+
* declaration order.
|
|
13
|
+
* 3. Each invocation is isolated in try/catch — one failure does not
|
|
14
|
+
* block subsequent hooks. Errors are collected in the returned
|
|
15
|
+
* {@link HookRunReport} so callers can surface a single rollup.
|
|
16
|
+
*
|
|
17
|
+
* Merge semantics (per hook type):
|
|
18
|
+
*
|
|
19
|
+
* | Hook | Return type | Merge rule |
|
|
20
|
+
* | ----------------------- | ---------------------- | ------------------- |
|
|
21
|
+
* | `onRouteRegistered` | void | — |
|
|
22
|
+
* | `onBundleComplete` | void | — |
|
|
23
|
+
* | `definePrerenderHook` | PrerenderOverride|void | Object spread |
|
|
24
|
+
* | `onManifestBuilt` | RoutesManifest|void | Pipe (last wins) |
|
|
25
|
+
* | `defineBundlerPlugin` | BunPlugin|BunPlugin[] | Concat |
|
|
26
|
+
* | `defineMiddlewareChain` | Middleware[] | Concat |
|
|
27
|
+
* | `defineTestTransform` | string | Pipe (each sees prev)|
|
|
28
|
+
*
|
|
29
|
+
* @see `docs/architect/plugin-api.md`
|
|
30
|
+
* @see `./hooks.ts` for the typed surface
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { BunPlugin } from "bun";
|
|
34
|
+
import type { Middleware } from "../middleware/define";
|
|
35
|
+
import type { BundleStats } from "../bundler/types";
|
|
36
|
+
import type { RouteSpec, RoutesManifest } from "../spec/schema";
|
|
37
|
+
import type {
|
|
38
|
+
ManduPlugin,
|
|
39
|
+
ManduHooks,
|
|
40
|
+
PluginContext,
|
|
41
|
+
PrerenderContext,
|
|
42
|
+
PrerenderOverride,
|
|
43
|
+
TestTransformContext,
|
|
44
|
+
} from "./hooks";
|
|
45
|
+
|
|
46
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
47
|
+
// Shared types
|
|
48
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Per-hook-invocation error record. Attached to the returned report so
|
|
52
|
+
* callers can log "plugin X failed on hook Y" without swallowing the
|
|
53
|
+
* stack trace.
|
|
54
|
+
*/
|
|
55
|
+
export interface HookError {
|
|
56
|
+
/** Hook name that failed. */
|
|
57
|
+
hook: keyof ManduHooks;
|
|
58
|
+
/** Plugin label — either `"config"` or `plugin.name`. */
|
|
59
|
+
source: string;
|
|
60
|
+
/** Captured error. */
|
|
61
|
+
error: Error;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface HookRunReport<T = unknown> {
|
|
65
|
+
/** Merged result (shape varies by hook — see merge table above). */
|
|
66
|
+
result: T;
|
|
67
|
+
/** Errors swallowed per hook invocation. Empty when all plugins succeeded. */
|
|
68
|
+
errors: HookError[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Args common to every runner call.
|
|
73
|
+
*/
|
|
74
|
+
export interface RunnerArgs {
|
|
75
|
+
plugins: readonly ManduPlugin[];
|
|
76
|
+
configHooks?: Partial<ManduHooks>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
80
|
+
// Internal helpers
|
|
81
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Yield every `(source, fn)` pair for a given hook name, in the canonical
|
|
85
|
+
* dispatch order: config hook first, then plugin hooks by declaration.
|
|
86
|
+
*/
|
|
87
|
+
function* iterateHook<K extends keyof ManduHooks>(
|
|
88
|
+
hookName: K,
|
|
89
|
+
args: RunnerArgs,
|
|
90
|
+
): Generator<{ source: string; fn: NonNullable<ManduHooks[K]> }> {
|
|
91
|
+
const configFn = args.configHooks?.[hookName];
|
|
92
|
+
if (configFn) {
|
|
93
|
+
yield { source: "config", fn: configFn as NonNullable<ManduHooks[K]> };
|
|
94
|
+
}
|
|
95
|
+
for (const plugin of args.plugins) {
|
|
96
|
+
const pluginFn = plugin.hooks?.[hookName];
|
|
97
|
+
if (pluginFn) {
|
|
98
|
+
yield {
|
|
99
|
+
source: plugin.name,
|
|
100
|
+
fn: pluginFn as NonNullable<ManduHooks[K]>,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Invoke a hook function and surface any thrown error as a HookError so
|
|
108
|
+
* the caller can carry on with the next plugin. Returns the function's
|
|
109
|
+
* return value (or `undefined` on error).
|
|
110
|
+
*/
|
|
111
|
+
async function invokeSafely<R>(
|
|
112
|
+
hookName: keyof ManduHooks,
|
|
113
|
+
source: string,
|
|
114
|
+
thunk: () => R | Promise<R>,
|
|
115
|
+
errors: HookError[],
|
|
116
|
+
): Promise<R | undefined> {
|
|
117
|
+
try {
|
|
118
|
+
return await thunk();
|
|
119
|
+
} catch (raw) {
|
|
120
|
+
const error = raw instanceof Error ? raw : new Error(String(raw));
|
|
121
|
+
errors.push({ hook: hookName, source, error });
|
|
122
|
+
// Surface to stderr too — matches the legacy `runHook` behaviour so
|
|
123
|
+
// users see the failure even if the caller ignores the report.
|
|
124
|
+
console.error(`[plugin] ${hookName} failed in ${source}: ${error.message}`);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
130
|
+
// Observer runners (void return — "fire and forget")
|
|
131
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Fire `onRouteRegistered` for each scanned route. Errors in one plugin
|
|
135
|
+
* don't stop the scan, or even other plugins for the same route.
|
|
136
|
+
*/
|
|
137
|
+
export async function runOnRouteRegistered(
|
|
138
|
+
route: RouteSpec,
|
|
139
|
+
args: RunnerArgs,
|
|
140
|
+
): Promise<HookRunReport<void>> {
|
|
141
|
+
const errors: HookError[] = [];
|
|
142
|
+
for (const { source, fn } of iterateHook("onRouteRegistered", args)) {
|
|
143
|
+
await invokeSafely("onRouteRegistered", source, () => fn(route), errors);
|
|
144
|
+
}
|
|
145
|
+
return { result: undefined, errors };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fire `onBundleComplete` with the final BundleStats.
|
|
150
|
+
*/
|
|
151
|
+
export async function runOnBundleComplete(
|
|
152
|
+
stats: BundleStats,
|
|
153
|
+
args: RunnerArgs,
|
|
154
|
+
): Promise<HookRunReport<void>> {
|
|
155
|
+
const errors: HookError[] = [];
|
|
156
|
+
for (const { source, fn } of iterateHook("onBundleComplete", args)) {
|
|
157
|
+
await invokeSafely("onBundleComplete", source, () => fn(stats), errors);
|
|
158
|
+
}
|
|
159
|
+
return { result: undefined, errors };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
163
|
+
// Transformer runners (return-value pipe / spread)
|
|
164
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Merge strategy for `definePrerenderHook`:
|
|
168
|
+
*
|
|
169
|
+
* - Each plugin's non-undefined return spreads onto the accumulator so
|
|
170
|
+
* later plugins can override earlier fields (`{ skip: true }` wins
|
|
171
|
+
* unless a later plugin sets `{ skip: false }` explicitly).
|
|
172
|
+
* - An undefined / void return is treated as "no change".
|
|
173
|
+
*/
|
|
174
|
+
export async function runDefinePrerenderHook(
|
|
175
|
+
ctx: PrerenderContext,
|
|
176
|
+
args: RunnerArgs,
|
|
177
|
+
): Promise<HookRunReport<PrerenderOverride>> {
|
|
178
|
+
const errors: HookError[] = [];
|
|
179
|
+
let merged: PrerenderOverride = {};
|
|
180
|
+
for (const { source, fn } of iterateHook("definePrerenderHook", args)) {
|
|
181
|
+
const out = await invokeSafely(
|
|
182
|
+
"definePrerenderHook",
|
|
183
|
+
source,
|
|
184
|
+
() => fn(ctx),
|
|
185
|
+
errors,
|
|
186
|
+
);
|
|
187
|
+
if (out && typeof out === "object") {
|
|
188
|
+
merged = { ...merged, ...out };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { result: merged, errors };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Pipe `onManifestBuilt` across plugins. Each plugin receives the
|
|
196
|
+
* manifest the previous plugin returned. A `void` / `undefined` return
|
|
197
|
+
* means "no change, pass through".
|
|
198
|
+
*/
|
|
199
|
+
export async function runOnManifestBuilt(
|
|
200
|
+
manifest: RoutesManifest,
|
|
201
|
+
args: RunnerArgs,
|
|
202
|
+
): Promise<HookRunReport<RoutesManifest>> {
|
|
203
|
+
const errors: HookError[] = [];
|
|
204
|
+
let current: RoutesManifest = manifest;
|
|
205
|
+
for (const { source, fn } of iterateHook("onManifestBuilt", args)) {
|
|
206
|
+
const out = await invokeSafely(
|
|
207
|
+
"onManifestBuilt",
|
|
208
|
+
source,
|
|
209
|
+
() => fn(current),
|
|
210
|
+
errors,
|
|
211
|
+
);
|
|
212
|
+
if (out && typeof out === "object") {
|
|
213
|
+
current = out;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { result: current, errors };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Collect BunPlugin contributions across every plugin. Scalar returns are
|
|
221
|
+
* wrapped into a single-element array; array returns are concatenated.
|
|
222
|
+
* `undefined` is skipped.
|
|
223
|
+
*/
|
|
224
|
+
export async function runDefineBundlerPlugin(
|
|
225
|
+
args: RunnerArgs,
|
|
226
|
+
): Promise<HookRunReport<BunPlugin[]>> {
|
|
227
|
+
const errors: HookError[] = [];
|
|
228
|
+
const collected: BunPlugin[] = [];
|
|
229
|
+
for (const { source, fn } of iterateHook("defineBundlerPlugin", args)) {
|
|
230
|
+
const out = await invokeSafely(
|
|
231
|
+
"defineBundlerPlugin",
|
|
232
|
+
source,
|
|
233
|
+
() => fn(),
|
|
234
|
+
errors,
|
|
235
|
+
);
|
|
236
|
+
if (!out) continue;
|
|
237
|
+
if (Array.isArray(out)) {
|
|
238
|
+
collected.push(...out);
|
|
239
|
+
} else {
|
|
240
|
+
collected.push(out);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { result: collected, errors };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Concatenate Middleware arrays from every plugin that provides a
|
|
248
|
+
* `defineMiddlewareChain`. The returned array is prepended to the
|
|
249
|
+
* user-declared `ManduConfig.middleware` at server boot time (see
|
|
250
|
+
* `runtime/server.ts`).
|
|
251
|
+
*/
|
|
252
|
+
export async function runDefineMiddlewareChain(
|
|
253
|
+
ctx: PluginContext,
|
|
254
|
+
args: RunnerArgs,
|
|
255
|
+
): Promise<HookRunReport<Middleware[]>> {
|
|
256
|
+
const errors: HookError[] = [];
|
|
257
|
+
const collected: Middleware[] = [];
|
|
258
|
+
for (const { source, fn } of iterateHook("defineMiddlewareChain", args)) {
|
|
259
|
+
const out = await invokeSafely(
|
|
260
|
+
"defineMiddlewareChain",
|
|
261
|
+
source,
|
|
262
|
+
() => fn(ctx),
|
|
263
|
+
errors,
|
|
264
|
+
);
|
|
265
|
+
if (Array.isArray(out)) {
|
|
266
|
+
collected.push(...out);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { result: collected, errors };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Pipe test-file source through every plugin's `defineTestTransform`.
|
|
274
|
+
* Each plugin sees the output of the previous plugin. A thrown error
|
|
275
|
+
* leaves the source unchanged for that step.
|
|
276
|
+
*/
|
|
277
|
+
export async function runDefineTestTransform(
|
|
278
|
+
ctx: TestTransformContext,
|
|
279
|
+
args: RunnerArgs,
|
|
280
|
+
): Promise<HookRunReport<string>> {
|
|
281
|
+
const errors: HookError[] = [];
|
|
282
|
+
let current = ctx.source;
|
|
283
|
+
for (const { source, fn } of iterateHook("defineTestTransform", args)) {
|
|
284
|
+
const out = await invokeSafely(
|
|
285
|
+
"defineTestTransform",
|
|
286
|
+
source,
|
|
287
|
+
() => fn({ testFile: ctx.testFile, source: current }),
|
|
288
|
+
errors,
|
|
289
|
+
);
|
|
290
|
+
if (typeof out === "string") {
|
|
291
|
+
current = out;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return { result: current, errors };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Driver-friendly wrapper for `runDefineMiddlewareChain` that returns
|
|
299
|
+
* the merged Middleware[] directly (errors are logged to stderr via
|
|
300
|
+
* the same path as the inner runner). Use when a sync caller like
|
|
301
|
+
* `startServer()` needs the array pre-resolved — wrap the call in
|
|
302
|
+
* `await` from your driver (the CLI), then pass the result in as a
|
|
303
|
+
* PREFIX to `options.middleware`.
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* ```ts
|
|
307
|
+
* const pluginMiddleware = await resolvePluginMiddleware({
|
|
308
|
+
* plugins: config.plugins ?? [],
|
|
309
|
+
* configHooks: config.hooks,
|
|
310
|
+
* rootDir: process.cwd(),
|
|
311
|
+
* mode: isDev ? "development" : "production",
|
|
312
|
+
* });
|
|
313
|
+
* startServer(manifest, {
|
|
314
|
+
* ...serverOptions,
|
|
315
|
+
* middleware: [...pluginMiddleware, ...(config.middleware ?? [])],
|
|
316
|
+
* plugins: config.plugins,
|
|
317
|
+
* configHooks: config.hooks,
|
|
318
|
+
* });
|
|
319
|
+
* ```
|
|
320
|
+
*/
|
|
321
|
+
export async function resolvePluginMiddleware(input: {
|
|
322
|
+
plugins: readonly ManduPlugin[];
|
|
323
|
+
configHooks?: Partial<ManduHooks>;
|
|
324
|
+
rootDir: string;
|
|
325
|
+
mode: "development" | "production";
|
|
326
|
+
}): Promise<Middleware[]> {
|
|
327
|
+
const ctx: PluginContext = {
|
|
328
|
+
rootDir: input.rootDir,
|
|
329
|
+
mode: input.mode,
|
|
330
|
+
logger: {
|
|
331
|
+
debug: (m, d) => console.debug(`[plugin] ${m}`, d ?? ""),
|
|
332
|
+
info: (m, d) => console.info(`[plugin] ${m}`, d ?? ""),
|
|
333
|
+
warn: (m, d) => console.warn(`[plugin] ${m}`, d ?? ""),
|
|
334
|
+
error: (m, d) => console.error(`[plugin] ${m}`, d ?? ""),
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
const report = await runDefineMiddlewareChain(ctx, {
|
|
338
|
+
plugins: input.plugins,
|
|
339
|
+
configHooks: input.configHooks,
|
|
340
|
+
});
|
|
341
|
+
return report.result;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
345
|
+
// Convenience: summarize errors for CLI output
|
|
346
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Render an error rollup the CLI can print. Returns `null` when the
|
|
350
|
+
* report is clean — callers should treat `null` as "nothing to log".
|
|
351
|
+
*/
|
|
352
|
+
export function formatHookErrors(
|
|
353
|
+
report: HookRunReport<unknown>,
|
|
354
|
+
): string | null {
|
|
355
|
+
if (report.errors.length === 0) return null;
|
|
356
|
+
const lines = [`[plugin] ${report.errors.length} hook failure(s):`];
|
|
357
|
+
for (const e of report.errors) {
|
|
358
|
+
lines.push(` - ${e.hook} in ${e.source}: ${e.error.message}`);
|
|
359
|
+
}
|
|
360
|
+
return lines.join("\n");
|
|
361
|
+
}
|
package/src/router/fs-routes.ts
CHANGED
|
@@ -13,6 +13,11 @@ import type { FSRouteConfig, FSScannerConfig, ScanResult } from "./fs-types";
|
|
|
13
13
|
import { DEFAULT_SCANNER_CONFIG } from "./fs-types";
|
|
14
14
|
import { scanRoutes } from "./fs-scanner";
|
|
15
15
|
import { loadManduConfig } from "../config";
|
|
16
|
+
import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
|
|
17
|
+
import {
|
|
18
|
+
runOnRouteRegistered,
|
|
19
|
+
runOnManifestBuilt,
|
|
20
|
+
} from "../plugins/runner";
|
|
16
21
|
|
|
17
22
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
18
23
|
// Types
|
|
@@ -41,6 +46,20 @@ export interface GenerateOptions {
|
|
|
41
46
|
|
|
42
47
|
/** 출력 파일 경로 (지정 시 파일로 저장) */
|
|
43
48
|
outputPath?: string;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Phase 18.τ — optional plugins + config-level hooks bundle.
|
|
52
|
+
*
|
|
53
|
+
* When provided, `generateManifest()` fires two plugin hooks:
|
|
54
|
+
* - `onRouteRegistered(route)` — once per scanned route.
|
|
55
|
+
* - `onManifestBuilt(manifest)` — after assembly, BEFORE the
|
|
56
|
+
* manifest is written to disk. A plugin may return a mutated
|
|
57
|
+
* manifest (pipe semantics across plugins).
|
|
58
|
+
*
|
|
59
|
+
* Omitted → zero overhead, identical to pre-τ behaviour.
|
|
60
|
+
*/
|
|
61
|
+
plugins?: readonly ManduPlugin[];
|
|
62
|
+
configHooks?: Partial<ManduHooks>;
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -244,12 +263,36 @@ export async function generateManifest(
|
|
|
244
263
|
}
|
|
245
264
|
|
|
246
265
|
// FS Routes 매니페스트 생성
|
|
247
|
-
|
|
266
|
+
let manifest = scanResultToManifest(scanResult);
|
|
248
267
|
const warnings: string[] = [];
|
|
249
268
|
|
|
250
269
|
// Auto-linking: spec/slots/, spec/contracts/ 자동 연결
|
|
251
270
|
await resolveAutoLinks(manifest, rootDir);
|
|
252
271
|
|
|
272
|
+
// Phase 18.τ — fire plugin hooks AFTER auto-linking so plugins see the
|
|
273
|
+
// final RouteSpec (including slot/contract/client modules). Both hooks
|
|
274
|
+
// are opt-in; zero-overhead when `options.plugins` / `options.configHooks`
|
|
275
|
+
// are omitted.
|
|
276
|
+
const pluginArgs = {
|
|
277
|
+
plugins: options.plugins ?? [],
|
|
278
|
+
configHooks: options.configHooks,
|
|
279
|
+
};
|
|
280
|
+
if (pluginArgs.plugins.length > 0 || pluginArgs.configHooks) {
|
|
281
|
+
for (const route of manifest.routes) {
|
|
282
|
+
const { errors } = await runOnRouteRegistered(route, pluginArgs);
|
|
283
|
+
for (const e of errors) {
|
|
284
|
+
warnings.push(
|
|
285
|
+
`onRouteRegistered[${e.source}] ${route.id}: ${e.error.message}`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const manifestReport = await runOnManifestBuilt(manifest, pluginArgs);
|
|
290
|
+
manifest = manifestReport.result;
|
|
291
|
+
for (const e of manifestReport.errors) {
|
|
292
|
+
warnings.push(`onManifestBuilt[${e.source}]: ${e.error.message}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
253
296
|
// 기존 매니페스트에서 사용자 설정 필드 보존 (clientModule, hydration 등)
|
|
254
297
|
const outputPath = options.outputPath ?? ".mandu/routes.manifest.json";
|
|
255
298
|
const outputFullPath = join(rootDir, outputPath);
|
|
@@ -272,6 +315,26 @@ export async function generateManifest(
|
|
|
272
315
|
if (prev.hydration && !route.hydration) {
|
|
273
316
|
route.hydration = prev.hydration;
|
|
274
317
|
}
|
|
318
|
+
// Issue #214 — preserve prerender-time fields (`dynamicParams`,
|
|
319
|
+
// `staticParams`) across manifest rescans. `mandu build`'s prerender
|
|
320
|
+
// phase stamps these onto the manifest; a subsequent `mandu dev`
|
|
321
|
+
// rescan must not wipe them out.
|
|
322
|
+
if (route.kind === "page" && prev.kind === "page") {
|
|
323
|
+
const prevPage = prev as typeof prev & {
|
|
324
|
+
dynamicParams?: boolean;
|
|
325
|
+
staticParams?: unknown[];
|
|
326
|
+
};
|
|
327
|
+
const routePage = route as typeof route & {
|
|
328
|
+
dynamicParams?: boolean;
|
|
329
|
+
staticParams?: unknown[];
|
|
330
|
+
};
|
|
331
|
+
if (prevPage.dynamicParams !== undefined && routePage.dynamicParams === undefined) {
|
|
332
|
+
routePage.dynamicParams = prevPage.dynamicParams;
|
|
333
|
+
}
|
|
334
|
+
if (prevPage.staticParams !== undefined && routePage.staticParams === undefined) {
|
|
335
|
+
routePage.staticParams = prevPage.staticParams;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
275
338
|
}
|
|
276
339
|
}
|
|
277
340
|
} catch {
|
package/src/runtime/server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
|
-
import type { RoutesManifest, RouteSpec, HydrationConfig } from "../spec/schema";
|
|
2
|
+
import type { RoutesManifest, RouteSpec, HydrationConfig, StaticParamSetSchema } from "../spec/schema";
|
|
3
3
|
import type { BundleManifest } from "../bundler/types";
|
|
4
4
|
import type { ManduFilling, RenderMode } from "../filling/filling";
|
|
5
5
|
import { ManduContext, CookieManager } from "../filling/context";
|
|
@@ -531,6 +531,14 @@ export interface ServerOptions {
|
|
|
531
531
|
* `secureMiddleware`, `rateLimitMiddleware`).
|
|
532
532
|
*/
|
|
533
533
|
middleware?: Middleware[];
|
|
534
|
+
/**
|
|
535
|
+
* Phase 18.τ — plugins contributing `defineMiddlewareChain()` +
|
|
536
|
+
* lifecycle observers. Plugin middleware are PREPENDED to
|
|
537
|
+
* `options.middleware` so they run BEFORE user-declared layers. Both
|
|
538
|
+
* fields are optional; omission is a zero-overhead passthrough.
|
|
539
|
+
*/
|
|
540
|
+
plugins?: readonly import("../plugins/hooks").ManduPlugin[];
|
|
541
|
+
configHooks?: Partial<import("../plugins/hooks").ManduHooks>;
|
|
534
542
|
/**
|
|
535
543
|
* Phase 18.κ — tRPC-like typed RPC endpoints.
|
|
536
544
|
*
|
|
@@ -3404,6 +3412,57 @@ function stripLocaleForRedirectCheck(
|
|
|
3404
3412
|
}
|
|
3405
3413
|
// ─── End Phase 18.μ ──────────────────────────────────────────────────────
|
|
3406
3414
|
|
|
3415
|
+
// ─── Issue #214 — dynamicParams guard helper ────────────────────────────────
|
|
3416
|
+
/**
|
|
3417
|
+
* True when the incoming `params` from the router matches one of the
|
|
3418
|
+
* enumerated sets in `staticParams` (populated at build time from
|
|
3419
|
+
* `generateStaticParams`). Used by the runtime #214 guard to decide
|
|
3420
|
+
* whether a `dynamicParams: false` page must 404.
|
|
3421
|
+
*
|
|
3422
|
+
* Matching rules mirror `bundler/generate-static-params.ts`:
|
|
3423
|
+
* - Scalar segments compare as exact strings.
|
|
3424
|
+
* - Catch-all segments compare as slash-joined strings (the router
|
|
3425
|
+
* always materializes wildcards as a single string, whereas
|
|
3426
|
+
* `generateStaticParams` emits `string[]` — we normalize both
|
|
3427
|
+
* sides to the joined form for equality).
|
|
3428
|
+
*
|
|
3429
|
+
* When `staticParams` is undefined or empty, no URL can match — the
|
|
3430
|
+
* route effectively becomes "no dynamic URLs at all", which is the
|
|
3431
|
+
* documented behavior of `dynamicParams: false` + `generateStaticParams: []`.
|
|
3432
|
+
*/
|
|
3433
|
+
function paramsInStaticSet(
|
|
3434
|
+
params: Record<string, string>,
|
|
3435
|
+
staticParams: StaticParamSetSchema[] | undefined
|
|
3436
|
+
): boolean {
|
|
3437
|
+
if (!staticParams || staticParams.length === 0) return false;
|
|
3438
|
+
const paramKeys = Object.keys(params);
|
|
3439
|
+
for (const entry of staticParams) {
|
|
3440
|
+
if (!entry) continue;
|
|
3441
|
+
let allMatch = true;
|
|
3442
|
+
for (const key of paramKeys) {
|
|
3443
|
+
const requestValue = params[key];
|
|
3444
|
+
const declared = (entry as Record<string, string | string[] | undefined>)[key];
|
|
3445
|
+
if (declared === undefined) {
|
|
3446
|
+
// Optional catch-all that wasn't declared — router gives empty
|
|
3447
|
+
// string in that case; anything else is a miss.
|
|
3448
|
+
if (requestValue !== "") {
|
|
3449
|
+
allMatch = false;
|
|
3450
|
+
break;
|
|
3451
|
+
}
|
|
3452
|
+
continue;
|
|
3453
|
+
}
|
|
3454
|
+
const declaredJoined = Array.isArray(declared) ? declared.join("/") : declared;
|
|
3455
|
+
if (declaredJoined !== requestValue) {
|
|
3456
|
+
allMatch = false;
|
|
3457
|
+
break;
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
if (allMatch) return true;
|
|
3461
|
+
}
|
|
3462
|
+
return false;
|
|
3463
|
+
}
|
|
3464
|
+
// ─── End Issue #214 ─────────────────────────────────────────────────────────
|
|
3465
|
+
|
|
3407
3466
|
async function handleRequestInternal(
|
|
3408
3467
|
req: Request,
|
|
3409
3468
|
router: Router,
|
|
@@ -3707,6 +3766,64 @@ async function handleRequestInternal(
|
|
|
3707
3766
|
|
|
3708
3767
|
const { route, params } = match;
|
|
3709
3768
|
|
|
3769
|
+
// ─── Issue #214 — dynamicParams guard ─────────────────────────────────────
|
|
3770
|
+
// Runs AFTER γ's prerendered pass-through (step 0.5) and BEFORE ζ's
|
|
3771
|
+
// per-route ISR cache dispatch (which lives inside `handlePageRoute`).
|
|
3772
|
+
//
|
|
3773
|
+
// Contract (Next.js parity):
|
|
3774
|
+
// - Page route opted into `dynamicParams: false` AND has `staticParams`
|
|
3775
|
+
// populated from `generateStaticParams` at build time → the incoming
|
|
3776
|
+
// params MUST match one of the known sets. Otherwise: 404.
|
|
3777
|
+
// - `dynamicParams: true` (or undefined) → default behavior unchanged.
|
|
3778
|
+
// Any dynamic URL falls through to SSR just like before.
|
|
3779
|
+
// - API + metadata routes are never gated — `dynamicParams` is
|
|
3780
|
+
// page-only.
|
|
3781
|
+
//
|
|
3782
|
+
// The guard short-circuits with `renderNotFoundPage` so per-route
|
|
3783
|
+
// `not-found.tsx` / global `notFoundHandler` / built-in JSON 404 all
|
|
3784
|
+
// render correctly without recursing through SSR. Cookies are not
|
|
3785
|
+
// applied because no page loader has run yet — this check precedes
|
|
3786
|
+
// loader dispatch by design.
|
|
3787
|
+
if (
|
|
3788
|
+
route.kind === "page" &&
|
|
3789
|
+
(route as { dynamicParams?: boolean }).dynamicParams === false
|
|
3790
|
+
) {
|
|
3791
|
+
const staticParams = (route as { staticParams?: StaticParamSetSchema[] })
|
|
3792
|
+
.staticParams;
|
|
3793
|
+
if (!paramsInStaticSet(params, staticParams)) {
|
|
3794
|
+
const pageRouteForNF = route as {
|
|
3795
|
+
id: string;
|
|
3796
|
+
pattern: string;
|
|
3797
|
+
layoutChain?: string[];
|
|
3798
|
+
hydration?: HydrationConfig;
|
|
3799
|
+
streaming?: boolean;
|
|
3800
|
+
notFoundModule?: string;
|
|
3801
|
+
};
|
|
3802
|
+
const nfResponse = await renderNotFoundPage(
|
|
3803
|
+
req,
|
|
3804
|
+
pageRouteForNF,
|
|
3805
|
+
params,
|
|
3806
|
+
registry,
|
|
3807
|
+
/* pageCookies */ undefined,
|
|
3808
|
+
/* layoutCookies */ undefined,
|
|
3809
|
+
/* layoutData */ undefined,
|
|
3810
|
+
new Response(
|
|
3811
|
+
JSON.stringify({
|
|
3812
|
+
message: `No static param match for ${pathname}`,
|
|
3813
|
+
}),
|
|
3814
|
+
{ status: 404, headers: { "Content-Type": "application/json" } }
|
|
3815
|
+
)
|
|
3816
|
+
);
|
|
3817
|
+
if (settings.cors && isCorsRequest(req)) {
|
|
3818
|
+
const corsOptions: CorsOptions =
|
|
3819
|
+
typeof settings.cors === "object" ? settings.cors : {};
|
|
3820
|
+
return ok(applyCorsToResponse(nfResponse, req, corsOptions));
|
|
3821
|
+
}
|
|
3822
|
+
return ok(nfResponse);
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
// ─── End Issue #214 ───────────────────────────────────────────────────────
|
|
3826
|
+
|
|
3710
3827
|
// 3. 라우트 종류별 처리
|
|
3711
3828
|
if (route.kind === "api") {
|
|
3712
3829
|
const rateLimitOptions = settings.rateLimit;
|
|
@@ -3860,6 +3977,8 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
3860
3977
|
scheduler: schedulerOption,
|
|
3861
3978
|
i18n: i18nOption,
|
|
3862
3979
|
messages: messagesOption,
|
|
3980
|
+
plugins: pluginsOption,
|
|
3981
|
+
configHooks: configHooksOption,
|
|
3863
3982
|
} = options;
|
|
3864
3983
|
|
|
3865
3984
|
// Phase 18.μ — validate i18n + messages shape. Both are branded via
|
|
@@ -3881,6 +4000,18 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
3881
4000
|
// Phase 18.ε — build the request-level middleware chain once at boot.
|
|
3882
4001
|
// `compose()` returns a passthrough when the list is empty; storing
|
|
3883
4002
|
// `undefined` for "no middleware" keeps the hot path branch-free.
|
|
4003
|
+
//
|
|
4004
|
+
// Phase 18.τ — plugin-contributed middleware via `defineMiddlewareChain()`
|
|
4005
|
+
// is resolved asynchronously OUTSIDE `startServer()` (which is sync).
|
|
4006
|
+
// Drivers call `resolvePluginMiddleware({ plugins, configHooks, rootDir,
|
|
4007
|
+
// mode })` and pass the resulting `Middleware[]` as a PREFIX of
|
|
4008
|
+
// `options.middleware` before calling `startServer()`.
|
|
4009
|
+
//
|
|
4010
|
+
// `pluginsOption` / `configHooksOption` are still carried here so that
|
|
4011
|
+
// lifecycle observers fired by drivers can reuse the same bundle; they
|
|
4012
|
+
// are NOT consulted for the middleware chain itself.
|
|
4013
|
+
void pluginsOption;
|
|
4014
|
+
void configHooksOption;
|
|
3884
4015
|
const middlewareChain: ComposedHandler | undefined =
|
|
3885
4016
|
middlewareOption && middlewareOption.length > 0
|
|
3886
4017
|
? composeMiddleware(...middlewareOption)
|
package/src/spec/schema.ts
CHANGED
|
@@ -80,6 +80,13 @@ const RouteSpecBase = {
|
|
|
80
80
|
streaming: z.boolean().optional(),
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
+
// ---- Static params (Issue #214) ----
|
|
84
|
+
// StaticParamSet values mirror `bundler/generate-static-params.ts` —
|
|
85
|
+
// scalar params are strings, catch-all params are `string[]`.
|
|
86
|
+
const StaticParamValue = z.union([z.string(), z.array(z.string())]);
|
|
87
|
+
const StaticParamSet = z.record(StaticParamValue);
|
|
88
|
+
export type StaticParamSetSchema = z.infer<typeof StaticParamSet>;
|
|
89
|
+
|
|
83
90
|
// ---- Page 라우트 ----
|
|
84
91
|
export const PageRouteSpec = z
|
|
85
92
|
.object({
|
|
@@ -93,6 +100,20 @@ export const PageRouteSpec = z
|
|
|
93
100
|
loadingModule: z.string().optional(),
|
|
94
101
|
errorModule: z.string().optional(),
|
|
95
102
|
notFoundModule: z.string().optional(),
|
|
103
|
+
/**
|
|
104
|
+
* Issue #214 — when `false`, the runtime rejects dynamic URLs
|
|
105
|
+
* whose params aren't in `staticParams` with a 404 instead of
|
|
106
|
+
* falling through to SSR. Undefined or `true` preserves the
|
|
107
|
+
* default "SSR on miss" behavior (Next.js parity).
|
|
108
|
+
*/
|
|
109
|
+
dynamicParams: z.boolean().optional(),
|
|
110
|
+
/**
|
|
111
|
+
* Issue #214 — populated at build time from `generateStaticParams`.
|
|
112
|
+
* Consulted by the runtime #214 guard together with `dynamicParams`
|
|
113
|
+
* to decide whether an incoming param set is allowed. Scalar values
|
|
114
|
+
* are strings; catch-all values are string arrays.
|
|
115
|
+
*/
|
|
116
|
+
staticParams: z.array(StaticParamSet).optional(),
|
|
96
117
|
})
|
|
97
118
|
.refine(
|
|
98
119
|
(route) => {
|
|
@@ -159,6 +180,10 @@ export const RouteSpec = z.discriminatedUnion("kind", [
|
|
|
159
180
|
loadingModule: z.string().optional(),
|
|
160
181
|
errorModule: z.string().optional(),
|
|
161
182
|
notFoundModule: z.string().optional(),
|
|
183
|
+
// Issue #214 — see PageRouteSpec for contract. Kept optional so
|
|
184
|
+
// existing manifests load unchanged (default behavior: dynamic SSR).
|
|
185
|
+
dynamicParams: z.boolean().optional(),
|
|
186
|
+
staticParams: z.array(StaticParamSet).optional(),
|
|
162
187
|
}),
|
|
163
188
|
z.object({
|
|
164
189
|
...RouteSpecBase,
|