@mandujs/core 0.32.0 → 0.33.1

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.
@@ -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
  *
@@ -589,6 +597,25 @@ export interface ServerOptions {
589
597
  * use `ctx.locale.code` and ignore `ctx.t`.
590
598
  */
591
599
  messages?: MessageRegistry;
600
+ /**
601
+ * Issue #217 — suppress the "🥟 Mandu server listening"/"🥟 Mandu Dev
602
+ * Server listening" banner printed at boot. The HTTP listener still
603
+ * binds and `ManduServer.server.port` still reports the chosen port;
604
+ * only the stdout banner (plus its auxiliary lines — HMR hint, CORS
605
+ * hint, static-file hint, streaming hint, Kitchen hint, "also
606
+ * reachable at" hint) is gated.
607
+ *
608
+ * Intended for internal callers such as the build-time prerender
609
+ * orchestrator that spin up a transient listener on an ephemeral
610
+ * port (`port: 0`) and tear it down seconds later — the banner's
611
+ * URL is always wrong by the time a human (or an LLM reading build
612
+ * logs) tries to curl it, so printing it causes confusion.
613
+ *
614
+ * User-facing commands (`mandu dev`, `mandu start`) leave this
615
+ * `undefined` / `false` to preserve the normal banner. Default:
616
+ * `false`.
617
+ */
618
+ silent?: boolean;
592
619
  }
593
620
 
594
621
  export interface ManduServer {
@@ -1317,6 +1344,155 @@ function createStaticErrorResponse(status: 400 | 403 | 404 | 500): Response {
1317
1344
  return new Response(body, { status });
1318
1345
  }
1319
1346
 
1347
+ // ═══════════════════════════════════════════════════════════════════════════
1348
+ // Static asset cache policy — Issue #218
1349
+ //
1350
+ // The `immutable` Cache-Control directive is a *contract* with browsers:
1351
+ // "the bytes at this URL will never change." Violating it (by overwriting a
1352
+ // stable-name file between builds) means users keep stale CSS/JS until they
1353
+ // hard-refresh. Mandu historically emitted `/.mandu/client/globals.css`
1354
+ // and `/.mandu/client/runtime*.js` with fixed URLs but stamped the response
1355
+ // with `immutable`, which is the exact failure mode.
1356
+ //
1357
+ // Policy:
1358
+ // - Hashed URL (e.g. `.../chunk.a1b2c3d4.js`) → `immutable` is safe,
1359
+ // 1-year max-age.
1360
+ // - Stable URL (no hash in filename) → `max-age=0, must-revalidate`.
1361
+ // The client revalidates on every request; a matching `If-None-Match`
1362
+ // short-circuits to 304 with no body, so the cost is one HEAD-sized
1363
+ // round-trip, not a full re-download.
1364
+ //
1365
+ // Strong ETag (content-hash) is emitted for every `/.mandu/client/*`
1366
+ // response so conditional GETs are cheap. We use `Bun.hash` (wyhash, ~5GB/s)
1367
+ // for the digest and cache results keyed by `path + size + mtime` to avoid
1368
+ // re-hashing on every hit.
1369
+ // ═══════════════════════════════════════════════════════════════════════════
1370
+
1371
+ /**
1372
+ * Heuristic: does the filename look like it carries a content hash?
1373
+ *
1374
+ * Matches:
1375
+ * - `name.<hash>.ext` where hash is >=8 hex chars (e.g. `chunk.a1b2c3d4.js`)
1376
+ * - `name-<hash>.ext` (e.g. `vendor-8f3a2b9c.js`)
1377
+ * - `name.<hash>.chunk.ext` common bundler shape
1378
+ *
1379
+ * A hash segment is 8+ lowercase hex chars. Longer digests (16, 20, 32) also
1380
+ * match. We deliberately avoid matching ALL-hex short names like `abc.js`
1381
+ * (requires min length 8).
1382
+ */
1383
+ function hasContentHashInFilename(filename: string): boolean {
1384
+ // `.` or `-` separator, 8+ hex chars, then `.` before extension
1385
+ // Examples that match: chunk.a1b2c3d4.js, vendor-8f3a2b9c.js, app.1234567890abcdef.css
1386
+ // Examples that DON'T match: globals.css, runtime.js, chunk.js
1387
+ return /[.\-][a-f0-9]{8,}\.[a-z0-9]+$/i.test(filename);
1388
+ }
1389
+
1390
+ /**
1391
+ * Compute Cache-Control for a static asset.
1392
+ *
1393
+ * - Dev: no caching (always refetch).
1394
+ * - Prod, hashed filename: `public, max-age=31536000, immutable` (1 year).
1395
+ * - Prod, stable filename: `public, max-age=0, must-revalidate` (force
1396
+ * revalidation; 304 via `If-None-Match` keeps it cheap).
1397
+ */
1398
+ function computeStaticCacheControl(filename: string, isDev: boolean): string {
1399
+ if (isDev) return "no-cache, no-store, must-revalidate";
1400
+ if (hasContentHashInFilename(filename)) {
1401
+ return "public, max-age=31536000, immutable";
1402
+ }
1403
+ return "public, max-age=0, must-revalidate";
1404
+ }
1405
+
1406
+ /**
1407
+ * In-process ETag cache keyed by absolute filePath. Entry is invalidated
1408
+ * when `size` or `mtime` changes. Avoids re-hashing hot files on every
1409
+ * request. The hot path is a single lookup + two scalar compares.
1410
+ */
1411
+ interface EtagCacheEntry {
1412
+ size: number;
1413
+ mtime: number;
1414
+ etag: string;
1415
+ }
1416
+ const etagCache = new Map<string, EtagCacheEntry>();
1417
+ const ETAG_CACHE_MAX = 2048;
1418
+
1419
+ /** Cheap LRU eviction: drop oldest insert when over cap. */
1420
+ function evictEtagCacheIfNeeded(): void {
1421
+ if (etagCache.size <= ETAG_CACHE_MAX) return;
1422
+ const oldestKey = etagCache.keys().next().value;
1423
+ if (oldestKey !== undefined) etagCache.delete(oldestKey);
1424
+ }
1425
+
1426
+ /**
1427
+ * Compute a strong ETag from file bytes (Bun.hash / wyhash). Cached by
1428
+ * `path + size + mtime` so we only re-hash when the file changes.
1429
+ *
1430
+ * Strong (not weak `W/`) because we actually hashed the payload — this
1431
+ * preserves byte-range / delta semantics per RFC 7232.
1432
+ */
1433
+ async function computeStrongEtag(
1434
+ filePath: string,
1435
+ file: import("bun").BunFile,
1436
+ ): Promise<string> {
1437
+ const size = file.size;
1438
+ const mtime = file.lastModified;
1439
+
1440
+ const cached = etagCache.get(filePath);
1441
+ if (cached && cached.size === size && cached.mtime === mtime) {
1442
+ return cached.etag;
1443
+ }
1444
+
1445
+ // Bun.hash returns a number/bigint; stringify in base36 for compact ETag.
1446
+ // Fall back to size+mtime-derived ETag if hashing ever throws (edge-runtime
1447
+ // polyfills etc. — `Bun.hash` is a Bun-native primitive).
1448
+ let digest: string;
1449
+ try {
1450
+ const bytes = await file.arrayBuffer();
1451
+ const h = Bun.hash(bytes);
1452
+ digest = typeof h === "bigint" ? h.toString(36) : Number(h).toString(36);
1453
+ } catch {
1454
+ digest = `${size.toString(36)}-${mtime.toString(36)}`;
1455
+ }
1456
+
1457
+ const etag = `"${digest}"`;
1458
+ etagCache.set(filePath, { size, mtime, etag });
1459
+ evictEtagCacheIfNeeded();
1460
+ return etag;
1461
+ }
1462
+
1463
+ /** Exposed for tests — allows clearing the ETag cache between cases. */
1464
+ export function __clearStaticEtagCacheForTests(): void {
1465
+ etagCache.clear();
1466
+ }
1467
+
1468
+ /**
1469
+ * RFC 7232 §3.2 — `If-None-Match` comparison.
1470
+ *
1471
+ * Accepts:
1472
+ * - `*` wildcard (matches any current representation)
1473
+ * - a single ETag (`"abc"` or `W/"abc"`)
1474
+ * - a comma-separated list
1475
+ *
1476
+ * Uses weak-comparison semantics (strip leading `W/`) because that is the
1477
+ * RFC-prescribed form for `If-None-Match`; a strong server-side ETag still
1478
+ * matches a weak client token if the opaque-string portion is equal.
1479
+ */
1480
+ function matchesEtag(ifNoneMatch: string, currentEtag: string): boolean {
1481
+ const trimmed = ifNoneMatch.trim();
1482
+ if (trimmed === "*") return true;
1483
+
1484
+ const normalize = (tag: string): string => {
1485
+ const t = tag.trim();
1486
+ return t.startsWith("W/") ? t.slice(2) : t;
1487
+ };
1488
+
1489
+ const currentNormalized = normalize(currentEtag);
1490
+ for (const part of trimmed.split(",")) {
1491
+ if (normalize(part) === currentNormalized) return true;
1492
+ }
1493
+ return false;
1494
+ }
1495
+
1320
1496
  /**
1321
1497
  * 경로가 허용된 디렉토리 내에 있는지 검증
1322
1498
  * Path traversal 공격 방지
@@ -1433,26 +1609,39 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
1433
1609
  }
1434
1610
 
1435
1611
  const mimeType = getMimeType(filePath);
1612
+ const filename = path.basename(filePath);
1436
1613
 
1437
- // Cache-Control 헤더 설정
1614
+ // Cache-Control Issue #218: `immutable` is only safe when the URL
1615
+ // contains a content hash. Stable-name bundles (globals.css, runtime.js)
1616
+ // must use `max-age=0, must-revalidate` or clients will serve stale
1617
+ // bytes until a hard refresh.
1618
+ //
1619
+ // Bundle files go through the hash-aware policy; non-bundle assets
1620
+ // (public/*, favicon, etc.) keep the conservative 1-day cache they had
1621
+ // before — they're user-controlled and unlikely to change per deploy.
1438
1622
  let cacheControl: string;
1439
1623
  if (settings.isDev) {
1440
- // 개발 모드: 캐시 없음
1441
1624
  cacheControl = "no-cache, no-store, must-revalidate";
1442
1625
  } else if (isBundleFile) {
1443
- // 프로덕션 번들: 1년 캐시 (파일명에 해시 포함 가정)
1444
- cacheControl = "public, max-age=31536000, immutable";
1626
+ cacheControl = computeStaticCacheControl(filename, /* isDev */ false);
1445
1627
  } else {
1446
- // 프로덕션 일반 정적 파일: 1일 캐시
1447
1628
  cacheControl = "public, max-age=86400";
1448
1629
  }
1449
1630
 
1450
- // ETag: weak validator (파일 크기 + 최종 수정 시간)
1451
- const etag = `W/"${file.size.toString(36)}-${file.lastModified.toString(36)}"`;
1452
-
1453
- // 304 Not Modified 불필요한 전송 방지
1631
+ // Strong ETag from content hash for bundle files enables cheap 304
1632
+ // round-trips when the client revalidates (`If-None-Match`).
1633
+ // Non-bundle static files keep a weak size+mtime validator (same as
1634
+ // pre-#218 behaviour)we don't pay the hash cost for user-owned
1635
+ // `public/*` content the framework doesn't control.
1636
+ const etag = isBundleFile
1637
+ ? await computeStrongEtag(filePath, file)
1638
+ : `W/"${file.size.toString(36)}-${file.lastModified.toString(36)}"`;
1639
+
1640
+ // 304 Not Modified — unnecessary transfer avoidance. We compare the
1641
+ // full `If-None-Match` string; RFC 7232 also allows a comma-separated
1642
+ // list and `*`, so handle those two forms explicitly.
1454
1643
  const ifNoneMatch = request?.headers.get("If-None-Match");
1455
- if (ifNoneMatch === etag) {
1644
+ if (ifNoneMatch && matchesEtag(ifNoneMatch, etag)) {
1456
1645
  return {
1457
1646
  handled: true,
1458
1647
  response: new Response(null, {
@@ -3404,6 +3593,57 @@ function stripLocaleForRedirectCheck(
3404
3593
  }
3405
3594
  // ─── End Phase 18.μ ──────────────────────────────────────────────────────
3406
3595
 
3596
+ // ─── Issue #214 — dynamicParams guard helper ────────────────────────────────
3597
+ /**
3598
+ * True when the incoming `params` from the router matches one of the
3599
+ * enumerated sets in `staticParams` (populated at build time from
3600
+ * `generateStaticParams`). Used by the runtime #214 guard to decide
3601
+ * whether a `dynamicParams: false` page must 404.
3602
+ *
3603
+ * Matching rules mirror `bundler/generate-static-params.ts`:
3604
+ * - Scalar segments compare as exact strings.
3605
+ * - Catch-all segments compare as slash-joined strings (the router
3606
+ * always materializes wildcards as a single string, whereas
3607
+ * `generateStaticParams` emits `string[]` — we normalize both
3608
+ * sides to the joined form for equality).
3609
+ *
3610
+ * When `staticParams` is undefined or empty, no URL can match — the
3611
+ * route effectively becomes "no dynamic URLs at all", which is the
3612
+ * documented behavior of `dynamicParams: false` + `generateStaticParams: []`.
3613
+ */
3614
+ function paramsInStaticSet(
3615
+ params: Record<string, string>,
3616
+ staticParams: StaticParamSetSchema[] | undefined
3617
+ ): boolean {
3618
+ if (!staticParams || staticParams.length === 0) return false;
3619
+ const paramKeys = Object.keys(params);
3620
+ for (const entry of staticParams) {
3621
+ if (!entry) continue;
3622
+ let allMatch = true;
3623
+ for (const key of paramKeys) {
3624
+ const requestValue = params[key];
3625
+ const declared = (entry as Record<string, string | string[] | undefined>)[key];
3626
+ if (declared === undefined) {
3627
+ // Optional catch-all that wasn't declared — router gives empty
3628
+ // string in that case; anything else is a miss.
3629
+ if (requestValue !== "") {
3630
+ allMatch = false;
3631
+ break;
3632
+ }
3633
+ continue;
3634
+ }
3635
+ const declaredJoined = Array.isArray(declared) ? declared.join("/") : declared;
3636
+ if (declaredJoined !== requestValue) {
3637
+ allMatch = false;
3638
+ break;
3639
+ }
3640
+ }
3641
+ if (allMatch) return true;
3642
+ }
3643
+ return false;
3644
+ }
3645
+ // ─── End Issue #214 ─────────────────────────────────────────────────────────
3646
+
3407
3647
  async function handleRequestInternal(
3408
3648
  req: Request,
3409
3649
  router: Router,
@@ -3707,6 +3947,64 @@ async function handleRequestInternal(
3707
3947
 
3708
3948
  const { route, params } = match;
3709
3949
 
3950
+ // ─── Issue #214 — dynamicParams guard ─────────────────────────────────────
3951
+ // Runs AFTER γ's prerendered pass-through (step 0.5) and BEFORE ζ's
3952
+ // per-route ISR cache dispatch (which lives inside `handlePageRoute`).
3953
+ //
3954
+ // Contract (Next.js parity):
3955
+ // - Page route opted into `dynamicParams: false` AND has `staticParams`
3956
+ // populated from `generateStaticParams` at build time → the incoming
3957
+ // params MUST match one of the known sets. Otherwise: 404.
3958
+ // - `dynamicParams: true` (or undefined) → default behavior unchanged.
3959
+ // Any dynamic URL falls through to SSR just like before.
3960
+ // - API + metadata routes are never gated — `dynamicParams` is
3961
+ // page-only.
3962
+ //
3963
+ // The guard short-circuits with `renderNotFoundPage` so per-route
3964
+ // `not-found.tsx` / global `notFoundHandler` / built-in JSON 404 all
3965
+ // render correctly without recursing through SSR. Cookies are not
3966
+ // applied because no page loader has run yet — this check precedes
3967
+ // loader dispatch by design.
3968
+ if (
3969
+ route.kind === "page" &&
3970
+ (route as { dynamicParams?: boolean }).dynamicParams === false
3971
+ ) {
3972
+ const staticParams = (route as { staticParams?: StaticParamSetSchema[] })
3973
+ .staticParams;
3974
+ if (!paramsInStaticSet(params, staticParams)) {
3975
+ const pageRouteForNF = route as {
3976
+ id: string;
3977
+ pattern: string;
3978
+ layoutChain?: string[];
3979
+ hydration?: HydrationConfig;
3980
+ streaming?: boolean;
3981
+ notFoundModule?: string;
3982
+ };
3983
+ const nfResponse = await renderNotFoundPage(
3984
+ req,
3985
+ pageRouteForNF,
3986
+ params,
3987
+ registry,
3988
+ /* pageCookies */ undefined,
3989
+ /* layoutCookies */ undefined,
3990
+ /* layoutData */ undefined,
3991
+ new Response(
3992
+ JSON.stringify({
3993
+ message: `No static param match for ${pathname}`,
3994
+ }),
3995
+ { status: 404, headers: { "Content-Type": "application/json" } }
3996
+ )
3997
+ );
3998
+ if (settings.cors && isCorsRequest(req)) {
3999
+ const corsOptions: CorsOptions =
4000
+ typeof settings.cors === "object" ? settings.cors : {};
4001
+ return ok(applyCorsToResponse(nfResponse, req, corsOptions));
4002
+ }
4003
+ return ok(nfResponse);
4004
+ }
4005
+ }
4006
+ // ─── End Issue #214 ───────────────────────────────────────────────────────
4007
+
3710
4008
  // 3. 라우트 종류별 처리
3711
4009
  if (route.kind === "api") {
3712
4010
  const rateLimitOptions = settings.rateLimit;
@@ -3860,6 +4158,13 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3860
4158
  scheduler: schedulerOption,
3861
4159
  i18n: i18nOption,
3862
4160
  messages: messagesOption,
4161
+ plugins: pluginsOption,
4162
+ configHooks: configHooksOption,
4163
+ // #217 — internal flag: suppress the "listening" banner. Threaded
4164
+ // through by `mandu build`'s transient prerender server so that
4165
+ // ephemeral `port: 0` listeners don't confuse humans/LLMs with a
4166
+ // URL that's already torn down by the time they curl it.
4167
+ silent = false,
3863
4168
  } = options;
3864
4169
 
3865
4170
  // Phase 18.μ — validate i18n + messages shape. Both are branded via
@@ -3881,6 +4186,18 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3881
4186
  // Phase 18.ε — build the request-level middleware chain once at boot.
3882
4187
  // `compose()` returns a passthrough when the list is empty; storing
3883
4188
  // `undefined` for "no middleware" keeps the hot path branch-free.
4189
+ //
4190
+ // Phase 18.τ — plugin-contributed middleware via `defineMiddlewareChain()`
4191
+ // is resolved asynchronously OUTSIDE `startServer()` (which is sync).
4192
+ // Drivers call `resolvePluginMiddleware({ plugins, configHooks, rootDir,
4193
+ // mode })` and pass the resulting `Middleware[]` as a PREFIX of
4194
+ // `options.middleware` before calling `startServer()`.
4195
+ //
4196
+ // `pluginsOption` / `configHooksOption` are still carried here so that
4197
+ // lifecycle observers fired by drivers can reuse the same bundle; they
4198
+ // are NOT consulted for the middleware chain itself.
4199
+ void pluginsOption;
4200
+ void configHooksOption;
3884
4201
  const middlewareChain: ComposedHandler | undefined =
3885
4202
  middlewareOption && middlewareOption.length > 0
3886
4203
  ? composeMiddleware(...middlewareOption)
@@ -4131,31 +4448,43 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4131
4448
 
4132
4449
  const addresses = formatServerAddresses(hostname, actualPort);
4133
4450
 
4134
- if (isDev) {
4135
- console.log(`🥟 Mandu Dev Server listening at ${addresses.primary}`);
4136
- if (addresses.additional.length > 0) {
4137
- console.log(` (also reachable at ${addresses.additional.join(", ")})`);
4138
- }
4139
- if (registry.settings.hmrPort) {
4140
- console.log(`🔥 HMR enabled on port ${registry.settings.hmrPort + PORTS.HMR_OFFSET}`);
4141
- }
4142
- console.log(`📂 Static files: /${publicDir}/, /.mandu/client/`);
4143
- if (corsOptions) {
4144
- console.log(`🌐 CORS enabled`);
4145
- }
4146
- if (streaming) {
4147
- console.log(`🌊 Streaming SSR enabled`);
4148
- }
4149
- if (registry.kitchen) {
4150
- console.log(`🍳 Kitchen dashboard at ${addresses.primary}/__kitchen`);
4151
- }
4152
- } else {
4153
- console.log(`🥟 Mandu server listening at ${addresses.primary}`);
4154
- if (addresses.additional.length > 0) {
4155
- console.log(` (also reachable at ${addresses.additional.join(", ")})`);
4156
- }
4157
- if (streaming) {
4158
- console.log(`🌊 Streaming SSR enabled`);
4451
+ // ─── #217 — gate the boot banner on `!silent` ─────────────────────────
4452
+ // `silent: true` is passed by internal callers that spawn a transient
4453
+ // listener on an ephemeral port (e.g. the build-time prerender
4454
+ // orchestrator). The HTTP listener still binds; only the stdout banner
4455
+ // — "🥟 Mandu server listening" / "🥟 Mandu Dev Server listening" plus
4456
+ // its auxiliary lines (additional addresses, HMR, static-file hint,
4457
+ // CORS, streaming, Kitchen) is suppressed. User-facing commands
4458
+ // (`mandu dev`, `mandu start`) leave `silent` undefined/false and
4459
+ // therefore see the banner unchanged.
4460
+ // ─── End #217 ─────────────────────────────────────────────────────────
4461
+ if (!silent) {
4462
+ if (isDev) {
4463
+ console.log(`🥟 Mandu Dev Server listening at ${addresses.primary}`);
4464
+ if (addresses.additional.length > 0) {
4465
+ console.log(` (also reachable at ${addresses.additional.join(", ")})`);
4466
+ }
4467
+ if (registry.settings.hmrPort) {
4468
+ console.log(`🔥 HMR enabled on port ${registry.settings.hmrPort + PORTS.HMR_OFFSET}`);
4469
+ }
4470
+ console.log(`📂 Static files: /${publicDir}/, /.mandu/client/`);
4471
+ if (corsOptions) {
4472
+ console.log(`🌐 CORS enabled`);
4473
+ }
4474
+ if (streaming) {
4475
+ console.log(`🌊 Streaming SSR enabled`);
4476
+ }
4477
+ if (registry.kitchen) {
4478
+ console.log(`🍳 Kitchen dashboard at ${addresses.primary}/__kitchen`);
4479
+ }
4480
+ } else {
4481
+ console.log(`🥟 Mandu server listening at ${addresses.primary}`);
4482
+ if (addresses.additional.length > 0) {
4483
+ console.log(` (also reachable at ${addresses.additional.join(", ")})`);
4484
+ }
4485
+ if (streaming) {
4486
+ console.log(`🌊 Streaming SSR enabled`);
4487
+ }
4159
4488
  }
4160
4489
  }
4161
4490
 
@@ -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,