@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.
- 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 +858 -368
- package/src/bundler/types.ts +20 -0
- package/src/client/spa-nav-helper.ts +92 -82
- package/src/config/mandu.ts +54 -0
- package/src/config/validate.ts +55 -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 +365 -36
- 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
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
|
*
|
|
@@ -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
|
-
|
|
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
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
//
|
|
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
|
|
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
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
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
|
|
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,
|