@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.
@@ -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
+ }
@@ -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
- const manifest = scanResultToManifest(scanResult);
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 {
@@ -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)
@@ -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,