@mandujs/core 0.54.22 → 0.54.24

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.
@@ -52,9 +52,55 @@ export function statusFromError(error: ManduError): number {
52
52
 
53
53
  /**
54
54
  * 에러를 Response로 변환
55
+ *
56
+ * Content negotiation: for the built-in 404 not-found response, inspect the
57
+ * request's `Accept` header. Browser navigation (Accept includes `text/html`)
58
+ * gets a minimal HTML 404 page instead of raw JSON; API/fetch clients keep
59
+ * the JSON error envelope. A custom `app/not-found.tsx` is handled upstream
60
+ * and never reaches this fallback. See GitHub issue #320.
55
61
  */
56
- export function errorToResponse(error: ManduError, isDev: boolean): Response {
62
+ export function errorToResponse(error: ManduError, isDev: boolean, req?: Request): Response {
63
+ const status = statusFromError(error);
64
+
65
+ if (status === 404 && req && acceptsHtml(req)) {
66
+ return new Response(renderNotFoundHtml(error.message), {
67
+ status,
68
+ headers: { "Content-Type": "text/html; charset=utf-8" },
69
+ });
70
+ }
71
+
57
72
  return Response.json(formatErrorResponse(error, { isDev }), {
58
- status: statusFromError(error),
73
+ status,
59
74
  });
60
75
  }
76
+
77
+ /** Whether the request's `Accept` header opts into an HTML response. */
78
+ function acceptsHtml(req: Request): boolean {
79
+ const accept = req.headers.get("accept") ?? "";
80
+ return accept.includes("text/html");
81
+ }
82
+
83
+ /** Escape a string for safe interpolation into HTML text content. */
84
+ function escapeHtml(value: string): string {
85
+ return value
86
+ .replace(/&/g, "&")
87
+ .replace(/</g, "&lt;")
88
+ .replace(/>/g, "&gt;");
89
+ }
90
+
91
+ /** Minimal built-in 404 HTML document for browser navigation. */
92
+ function renderNotFoundHtml(message: string): string {
93
+ const safe = escapeHtml(message);
94
+ return `<!DOCTYPE html>
95
+ <html lang="en">
96
+ <head>
97
+ <meta charset="utf-8" />
98
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
99
+ <title>404 - Not Found</title>
100
+ </head>
101
+ <body>
102
+ <h1>404 - Not Found</h1>
103
+ <p>${safe}</p>
104
+ </body>
105
+ </html>`;
106
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Regression tests for issue #318:
3
+ * ctx.body() must raise a 400 (Bad Request) for empty/malformed JSON bodies,
4
+ * never a 500 FRAMEWORK_BUG / MANDU_F999 (a bad body is the client's fault).
5
+ */
6
+
7
+ import { describe, it, expect } from "bun:test";
8
+ import { ManduFillingFactory } from "./filling";
9
+ import { BadRequestError } from "./context";
10
+
11
+ const Mandu = ManduFillingFactory;
12
+
13
+ function jsonPost(body: string): Request {
14
+ return new Request("http://localhost/api/auth/login", {
15
+ method: "POST",
16
+ headers: { "content-type": "application/json" },
17
+ body,
18
+ });
19
+ }
20
+
21
+ describe("ctx.body() bad request handling (#318)", () => {
22
+ const filling = Mandu.filling().post(async (ctx) => {
23
+ const data = await ctx.body();
24
+ return ctx.ok(data);
25
+ });
26
+
27
+ it("returns 400 (not 500) for an empty JSON body", async () => {
28
+ const res = await filling.handle(jsonPost(""));
29
+ expect(res.status).toBe(400);
30
+
31
+ const payload = (await res.json()) as Record<string, unknown>;
32
+ expect(payload.errorType).not.toBe("FRAMEWORK_BUG");
33
+ expect(payload.code).not.toBe("MANDU_F999");
34
+ expect(payload.code).toBe("MANDU_C400");
35
+ });
36
+
37
+ it("returns 400 (not 500) for a malformed JSON body", async () => {
38
+ const res = await filling.handle(jsonPost("{bad,,"));
39
+ expect(res.status).toBe(400);
40
+
41
+ const payload = (await res.json()) as Record<string, unknown>;
42
+ expect(payload.errorType).not.toBe("FRAMEWORK_BUG");
43
+ expect(payload.errorType).not.toBe("LOGIC_ERROR");
44
+ expect(payload.code).toBe("MANDU_C400");
45
+ // Must not misdiagnose client input as a slot file problem.
46
+ expect(JSON.stringify(payload)).not.toContain("slot");
47
+ });
48
+
49
+ it("still parses a valid JSON body normally", async () => {
50
+ const res = await filling.handle(jsonPost(JSON.stringify({ email: "a@b.c" })));
51
+ expect(res.status).toBe(200);
52
+ const payload = (await res.json()) as Record<string, unknown>;
53
+ expect(payload.email).toBe("a@b.c");
54
+ });
55
+
56
+ it("ctx.body() throws BadRequestError directly on malformed JSON", async () => {
57
+ const ctx = Mandu.context(jsonPost("not json"));
58
+ await expect(ctx.body()).rejects.toBeInstanceOf(BadRequestError);
59
+ });
60
+ });
@@ -549,7 +549,16 @@ export class ManduContext {
549
549
  let data: unknown;
550
550
 
551
551
  if (contentType.includes("application/json")) {
552
- data = await this.request.json();
552
+ try {
553
+ data = await this.request.json();
554
+ } catch (cause) {
555
+ // Empty or malformed JSON body is a client error (400),
556
+ // not a framework bug (500). See issue #318.
557
+ throw new BadRequestError(
558
+ "Invalid or empty JSON request body",
559
+ cause
560
+ );
561
+ }
553
562
  } else if (contentType.includes("application/x-www-form-urlencoded")) {
554
563
  const formData = await this.request.formData();
555
564
  data = Object.fromEntries(formData.entries());
@@ -628,23 +637,23 @@ export class ManduContext {
628
637
  return this.withCookies(new Response(null, { status: 204 }));
629
638
  }
630
639
 
631
- /** 400 Bad Request, or custom 4xx/5xx error with ctx.error(status, message). */
632
- error(message: string, details?: unknown): Response;
633
- error(status: number, message: string, details?: unknown): Response;
634
- error(
635
- statusOrMessage: number | string,
636
- messageOrDetails?: string | unknown,
637
- maybeDetails?: unknown
638
- ): Response {
639
- if (typeof statusOrMessage === "number") {
640
- const status = Number.isInteger(statusOrMessage) && statusOrMessage >= 400 && statusOrMessage <= 599
641
- ? statusOrMessage
642
- : 400;
643
- const message = typeof messageOrDetails === "string" ? messageOrDetails : "Error";
644
- return this.json({ status: "error", message, details: maybeDetails }, status);
645
- }
646
- return this.json({ status: "error", message: statusOrMessage, details: messageOrDetails }, 400);
647
- }
640
+ /** 400 Bad Request, or custom 4xx/5xx error with ctx.error(status, message). */
641
+ error(message: string, details?: unknown): Response;
642
+ error(status: number, message: string, details?: unknown): Response;
643
+ error(
644
+ statusOrMessage: number | string,
645
+ messageOrDetails?: string | unknown,
646
+ maybeDetails?: unknown
647
+ ): Response {
648
+ if (typeof statusOrMessage === "number") {
649
+ const status = Number.isInteger(statusOrMessage) && statusOrMessage >= 400 && statusOrMessage <= 599
650
+ ? statusOrMessage
651
+ : 400;
652
+ const message = typeof messageOrDetails === "string" ? messageOrDetails : "Error";
653
+ return this.json({ status: "error", message, details: maybeDetails }, status);
654
+ }
655
+ return this.json({ status: "error", message: statusOrMessage, details: messageOrDetails }, 400);
656
+ }
648
657
 
649
658
  /** 401 Unauthorized */
650
659
  unauthorized(message: string = "Unauthorized"): Response {
@@ -888,3 +897,19 @@ export class ValidationError extends Error {
888
897
  this.name = "ValidationError";
889
898
  }
890
899
  }
900
+
901
+ /**
902
+ * Bad request error (400 Bad Request)
903
+ *
904
+ * Raised when client-supplied input cannot be processed, e.g. an empty or
905
+ * malformed JSON request body in {@link ManduContext.body}. This is the
906
+ * client's fault and must never be classified as a framework bug (500).
907
+ */
908
+ export class BadRequestError extends Error {
909
+ readonly statusCode = 400;
910
+
911
+ constructor(message: string = "Bad request", cause?: unknown) {
912
+ super(message, { cause });
913
+ this.name = "BadRequestError";
914
+ }
915
+ }
@@ -5,7 +5,7 @@
5
5
  * DNA-002: 의존성 주입 패턴 지원
6
6
  */
7
7
 
8
- import { ManduContext, ValidationError } from "./context";
8
+ import { ManduContext, ValidationError, BadRequestError } from "./context";
9
9
  import { AuthenticationError, AuthorizationError } from "./auth";
10
10
  import { type FillingDeps, globalDeps } from "./deps";
11
11
  import { ErrorClassifier, formatErrorResponse, ErrorCode } from "../error";
@@ -595,9 +595,24 @@ export class ManduFilling<TLoaderData = unknown> {
595
595
  if (actionResult) return actionResult;
596
596
  }
597
597
 
598
- const handler = this.config.handlers.get(method);
598
+ // RFC 7231 §4.3.2: HEAD is GET without a body. A route that only
599
+ // registers a GET handler must still serve HEAD — run GET, then strip
600
+ // the body while preserving status + headers. An explicit HEAD handler
601
+ // takes precedence when present.
602
+ const stripBody = method === "HEAD" && !this.config.handlers.has("HEAD");
603
+ const handler = this.config.handlers.get(method) ?? (stripBody ? this.config.handlers.get("GET") : undefined);
599
604
  if (!handler) {
600
- return ctx.json({ status: "error", message: `Method ${method} not allowed`, allowed: Array.from(this.config.handlers.keys()) }, 405);
605
+ const allowed = Array.from(this.config.handlers.keys());
606
+ if (this.config.handlers.has("GET") && !allowed.includes("HEAD")) {
607
+ allowed.push("HEAD");
608
+ }
609
+ // RFC 7231 §6.5.5: a 405 response MUST include an `Allow` header listing
610
+ // every supported method (not just the body field). ctx.json() cannot
611
+ // attach headers, so build the response directly here.
612
+ return Response.json(
613
+ { status: "error", message: `Method ${method} not allowed`, allowed },
614
+ { status: 405, headers: { Allow: allowed.join(", ") } },
615
+ );
601
616
  }
602
617
  const lifecycleWithDefaults = this.createLifecycleWithDefaults(routeContext);
603
618
  const runHandler = async () => {
@@ -615,7 +630,17 @@ export class ManduFilling<TLoaderData = unknown> {
615
630
  const composed = compose(chain);
616
631
  return composed(ctx);
617
632
  };
618
- return executeLifecycle(lifecycleWithDefaults, ctx, runHandler, options);
633
+ const response = await executeLifecycle(lifecycleWithDefaults, ctx, runHandler, options);
634
+ if (stripBody) {
635
+ // Preserve status + headers (incl. Content-Type / Content-Length the
636
+ // GET handler set), drop the body. A null-body Response keeps headers.
637
+ return new Response(null, {
638
+ status: response.status,
639
+ statusText: response.statusText,
640
+ headers: response.headers,
641
+ });
642
+ }
643
+ return response;
619
644
  }
620
645
 
621
646
  /**
@@ -745,6 +770,9 @@ export class ManduFilling<TLoaderData = unknown> {
745
770
  if (error instanceof ValidationError) {
746
771
  return ctx.json({ errorType: "LOGIC_ERROR", code: ErrorCode.SLOT_VALIDATION_ERROR, message: "Validation failed", summary: "입력 검증 실패 - 요청 데이터 확인 필요", fix: { file: routeContext ? `spec/slots/${routeContext.routeId}.slot.ts` : "spec/slots/", suggestion: "요청 데이터가 스키마와 일치하는지 확인하세요" }, route: routeContext, errors: error.errors, timestamp: new Date().toISOString() }, 400);
747
772
  }
773
+ if (error instanceof BadRequestError) {
774
+ return ctx.json({ errorType: "CLIENT_ERROR", code: "MANDU_C400", message: error.message, summary: "잘못된 요청 본문 - 클라이언트 입력 확인 필요", route: routeContext, timestamp: new Date().toISOString() }, 400);
775
+ }
748
776
  const classifier = new ErrorClassifier(null, routeContext ? { id: routeContext.routeId, pattern: routeContext.pattern } : undefined);
749
777
  const manduError = classifier.classify(error);
750
778
  console.error(`[Mandu] ${manduError.errorType}:`, manduError.message);
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Regression test for issue #319: a route that only registers a GET handler
3
+ * via `Mandu.filling().get(...)` must serve HEAD requests as a body-less GET
4
+ * (RFC 7231 §4.3.2) — returning the same status + headers with an empty body,
5
+ * not a 405 Method Not Allowed.
6
+ */
7
+ import { describe, it, expect } from "bun:test";
8
+ import { ManduFillingFactory } from "./filling";
9
+
10
+ describe("Filling HEAD handling (#319)", () => {
11
+ it("serves HEAD via the GET handler with status + headers and an empty body", async () => {
12
+ const filling = ManduFillingFactory.filling().get((ctx) =>
13
+ ctx.json({ ok: true }),
14
+ );
15
+
16
+ const getRes = await filling.handle(
17
+ new Request("http://localhost/api/health", { method: "GET" }),
18
+ );
19
+ expect(getRes.status).toBe(200);
20
+
21
+ const headRes = await filling.handle(
22
+ new Request("http://localhost/api/health", { method: "HEAD" }),
23
+ );
24
+
25
+ expect(headRes.status).toBe(200);
26
+ expect(headRes.headers.get("content-type")).toBe(
27
+ getRes.headers.get("content-type"),
28
+ );
29
+ const headBody = await headRes.text();
30
+ expect(headBody).toBe("");
31
+ });
32
+
33
+ it("prefers an explicit HEAD handler when one is registered", async () => {
34
+ const filling = ManduFillingFactory.filling()
35
+ .get((ctx) => ctx.json({ ok: true }))
36
+ .head((ctx) => {
37
+ const res = ctx.json({ ignored: true });
38
+ res.headers.set("X-Explicit-Head", "1");
39
+ return res;
40
+ });
41
+
42
+ const headRes = await filling.handle(
43
+ new Request("http://localhost/api/health", { method: "HEAD" }),
44
+ );
45
+
46
+ expect(headRes.status).toBe(200);
47
+ expect(headRes.headers.get("X-Explicit-Head")).toBe("1");
48
+ });
49
+
50
+ it("still returns 405 for a method with no GET fallback", async () => {
51
+ const filling = ManduFillingFactory.filling().post((ctx) => ctx.json({ ok: true }));
52
+
53
+ const headRes = await filling.handle(
54
+ new Request("http://localhost/api/health", { method: "HEAD" }),
55
+ );
56
+
57
+ expect(headRes.status).toBe(405);
58
+ });
59
+
60
+ it("lists HEAD in the Allow set when GET is registered but the method is unsupported", async () => {
61
+ const filling = ManduFillingFactory.filling().get((ctx) => ctx.json({ ok: true }));
62
+
63
+ const putRes = await filling.handle(
64
+ new Request("http://localhost/api/health", { method: "PUT" }),
65
+ );
66
+
67
+ expect(putRes.status).toBe(405);
68
+ const body = (await putRes.json()) as { allowed: string[] };
69
+ expect(body.allowed).toContain("GET");
70
+ expect(body.allowed).toContain("HEAD");
71
+ });
72
+
73
+ // Issue #321: 405 responses MUST carry an `Allow` header listing every
74
+ // supported method — for multi-method routes too, not just single-method.
75
+ it("sets the Allow header to a single method route's methods", async () => {
76
+ const filling = ManduFillingFactory.filling().get((ctx) => ctx.json({ ok: true }));
77
+
78
+ const putRes = await filling.handle(
79
+ new Request("http://localhost/api/health", { method: "PUT" }),
80
+ );
81
+
82
+ expect(putRes.status).toBe(405);
83
+ const allow = putRes.headers.get("Allow") ?? "";
84
+ expect(allow.split(", ")).toContain("GET");
85
+ expect(allow.split(", ")).toContain("HEAD");
86
+ });
87
+
88
+ it("sets the Allow header to ALL methods for a multi-method route (#321)", async () => {
89
+ const filling = ManduFillingFactory.filling()
90
+ .get((ctx) => ctx.json({ ok: true }))
91
+ .post((ctx) => ctx.json({ ok: true }))
92
+ .delete((ctx) => ctx.json({ ok: true }));
93
+
94
+ const putRes = await filling.handle(
95
+ new Request("http://localhost/api/pledges", { method: "PUT" }),
96
+ );
97
+
98
+ expect(putRes.status).toBe(405);
99
+ const allow = putRes.headers.get("Allow") ?? "";
100
+ const methods = allow.split(", ");
101
+ expect(methods).toContain("GET");
102
+ expect(methods).toContain("POST");
103
+ expect(methods).toContain("DELETE");
104
+ // HEAD is implied by GET.
105
+ expect(methods).toContain("HEAD");
106
+ });
107
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Content negotiation for the built-in default 404 response (GitHub issue #320).
3
+ *
4
+ * The framework's fallback 404 (when no `app/not-found.tsx` is registered)
5
+ * must honor the request `Accept` header: browser navigation that accepts
6
+ * `text/html` gets a minimal HTML 404 page, while API/fetch clients keep the
7
+ * JSON error envelope. Both keep status 404.
8
+ *
9
+ * This exercises `errorToResponse(createNotFoundResponse(...))` — the single
10
+ * convergence point every built-in default 404 path routes through in
11
+ * runtime/server.ts.
12
+ */
13
+
14
+ import { describe, it, expect } from "bun:test";
15
+ import { errorToResponse, createNotFoundResponse } from "../../error";
16
+
17
+ function build404(accept?: string): Response {
18
+ const headers: Record<string, string> = {};
19
+ if (accept !== undefined) headers["Accept"] = accept;
20
+ const req = new Request("http://test/this-page-does-not-exist", { headers });
21
+ return errorToResponse(
22
+ createNotFoundResponse("/this-page-does-not-exist"),
23
+ false,
24
+ req,
25
+ );
26
+ }
27
+
28
+ describe("default 404 content negotiation (#320)", () => {
29
+ it("returns HTML 404 when Accept includes text/html (browser navigation)", async () => {
30
+ const res = build404("text/html,application/xhtml+xml");
31
+ expect(res.status).toBe(404);
32
+ expect(res.headers.get("Content-Type")).toContain("text/html");
33
+
34
+ const body = await res.text();
35
+ expect(body).toContain("<!DOCTYPE html>");
36
+ expect(body).toContain("404 - Not Found");
37
+ // Body must NOT be a JSON error envelope.
38
+ expect(body).not.toContain("MANDU_E105");
39
+ });
40
+
41
+ it("returns JSON 404 when Accept is application/json (API client)", async () => {
42
+ const res = build404("application/json");
43
+ expect(res.status).toBe(404);
44
+ expect(res.headers.get("Content-Type")).toContain("application/json");
45
+
46
+ const body = (await res.json()) as Record<string, unknown>;
47
+ expect(body.code).toBe("MANDU_E105");
48
+ expect(body.message).toBe("Route not found: /this-page-does-not-exist");
49
+ });
50
+
51
+ it("returns JSON 404 when no Accept header is present (fetch default)", async () => {
52
+ const res = build404(undefined);
53
+ expect(res.status).toBe(404);
54
+ expect(res.headers.get("Content-Type")).toContain("application/json");
55
+ const body = (await res.json()) as Record<string, unknown>;
56
+ expect(body.code).toBe("MANDU_E105");
57
+ });
58
+
59
+ it("falls back to JSON when no request is supplied (back-compat)", async () => {
60
+ const res = errorToResponse(createNotFoundResponse("/x"), false);
61
+ expect(res.status).toBe(404);
62
+ expect(res.headers.get("Content-Type")).toContain("application/json");
63
+ });
64
+
65
+ it("escapes the message in the HTML body (no markup injection)", async () => {
66
+ const req = new Request("http://test/x", {
67
+ headers: { Accept: "text/html" },
68
+ });
69
+ const res = errorToResponse(
70
+ createNotFoundResponse("/<script>alert(1)</script>"),
71
+ false,
72
+ req,
73
+ );
74
+ const body = await res.text();
75
+ expect(body).not.toContain("<script>alert(1)</script>");
76
+ expect(body).toContain("&lt;script&gt;");
77
+ });
78
+ });
@@ -23,7 +23,7 @@ import {
23
23
  type PageRegistration,
24
24
  } from "./server";
25
25
  import { registerManifest } from "./registry";
26
- import { needsHydration, type RouteClientBoundary, type RoutesManifest } from "../spec/schema";
26
+ import { needsHydration, type RouteClientBoundary, type RoutesManifest } from "../spec/schema";
27
27
 
28
28
  type RouteModule = Record<string, unknown>;
29
29
 
@@ -62,11 +62,20 @@ function hasHttpMethodHandlers(module: RouteModule): boolean {
62
62
  function createMethodDispatcher(module: RouteModule, routeId: string) {
63
63
  return async (req: Request, params: Record<string, string> = {}) => {
64
64
  const method = req.method.toUpperCase();
65
- const handler = (isHttpMethod(method) ? module[method] : undefined) as
65
+ // RFC 7231 §4.3.2: HEAD is GET without a body. When no explicit HEAD
66
+ // export exists, dispatch to GET and strip the body — preserving the
67
+ // status + headers GET produced. An explicit HEAD export wins.
68
+ const stripBody = method === "HEAD" && typeof module.HEAD !== "function";
69
+ const lookup = stripBody ? "GET" : method;
70
+ const handler = (isHttpMethod(lookup) ? module[lookup] : undefined) as
66
71
  | ((request: Request, context?: { params: Record<string, string> }) => Response | Promise<Response>)
67
72
  | undefined;
68
73
 
69
74
  if (!handler) {
75
+ const allow = HTTP_METHODS.filter((m) => typeof module[m] === "function");
76
+ if (typeof module.GET === "function" && !allow.includes("HEAD")) {
77
+ allow.push("HEAD");
78
+ }
70
79
  return Response.json(
71
80
  {
72
81
  error: `Method ${method} not allowed for route ${routeId}`,
@@ -74,17 +83,25 @@ function createMethodDispatcher(module: RouteModule, routeId: string) {
74
83
  {
75
84
  status: 405,
76
85
  headers: {
77
- Allow: HTTP_METHODS.filter((m) => typeof module[m] === "function").join(", "),
86
+ Allow: allow.join(", "),
78
87
  },
79
88
  }
80
89
  );
81
90
  }
82
91
 
83
- return handler(req, { params });
92
+ const response = await handler(req, { params });
93
+ if (stripBody) {
94
+ return new Response(null, {
95
+ status: response.status,
96
+ statusText: response.statusText,
97
+ headers: response.headers,
98
+ });
99
+ }
100
+ return response;
84
101
  };
85
102
  }
86
103
 
87
- export interface RegisterHandlersOptions {
104
+ export interface RegisterHandlersOptions {
88
105
  /**
89
106
  * Module import function (dev: importFresh, start: standard import).
90
107
  * The optional `opts.changedFile` is forwarded into Phase 7.0 B5's
@@ -92,17 +109,17 @@ export interface RegisterHandlersOptions {
92
109
  * module's import graph, `importFn` returns the cached bundle in ~0.1 ms
93
110
  * instead of re-running Bun.build.
94
111
  */
95
- importFn: (
96
- modulePath: string,
97
- opts?: {
98
- changedFile?: string;
99
- clientBoundaryTransform?: {
100
- routeId: string;
101
- hydrate?: string;
102
- boundaries?: RouteClientBoundary[];
103
- };
104
- },
105
- ) => Promise<unknown>;
112
+ importFn: (
113
+ modulePath: string,
114
+ opts?: {
115
+ changedFile?: string;
116
+ clientBoundaryTransform?: {
117
+ routeId: string;
118
+ hydrate?: string;
119
+ boundaries?: RouteClientBoundary[];
120
+ };
121
+ },
122
+ ) => Promise<unknown>;
106
123
  /** Set for tracking already registered layout paths */
107
124
  registeredLayouts: Set<string>;
108
125
  /** Clear layout cache on reload */
@@ -125,23 +142,23 @@ export async function registerManifestHandlers(
125
142
  rootDir: string,
126
143
  options: RegisterHandlersOptions
127
144
  ): Promise<void> {
128
- const { importFn, registeredLayouts, isReload = false, changedFile } = options;
129
- const baseImportOpts: { changedFile?: string } | undefined =
130
- changedFile !== undefined ? { changedFile } : undefined;
131
- const importOptsForRoute = (route?: RoutesManifest["routes"][number]) => {
132
- const boundaryTransform = route?.kind === "page" && route.boundaries?.length
133
- ? {
134
- routeId: route.id,
135
- hydrate: route.hydration?.priority ?? "visible",
136
- boundaries: route.boundaries,
137
- }
138
- : undefined;
139
- if (!baseImportOpts && !boundaryTransform) return undefined;
140
- return {
141
- ...baseImportOpts,
142
- ...(boundaryTransform ? { clientBoundaryTransform: boundaryTransform } : {}),
143
- };
144
- };
145
+ const { importFn, registeredLayouts, isReload = false, changedFile } = options;
146
+ const baseImportOpts: { changedFile?: string } | undefined =
147
+ changedFile !== undefined ? { changedFile } : undefined;
148
+ const importOptsForRoute = (route?: RoutesManifest["routes"][number]) => {
149
+ const boundaryTransform = route?.kind === "page" && route.boundaries?.length
150
+ ? {
151
+ routeId: route.id,
152
+ hydrate: route.hydration?.priority ?? "visible",
153
+ boundaries: route.boundaries,
154
+ }
155
+ : undefined;
156
+ if (!baseImportOpts && !boundaryTransform) return undefined;
157
+ return {
158
+ ...baseImportOpts,
159
+ ...(boundaryTransform ? { clientBoundaryTransform: boundaryTransform } : {}),
160
+ };
161
+ };
145
162
 
146
163
  if (isReload) {
147
164
  registeredLayouts.clear();
@@ -158,19 +175,19 @@ export async function registerManifestHandlers(
158
175
  // runtime dispatcher invokes the default export on each request
159
176
  // so HMR reloads pick up edits automatically (same pattern as
160
177
  // API routes below).
161
- if (route.kind === "metadata") {
162
- const modulePath = path.resolve(rootDir, route.module);
163
- registerMetadataHandler(route.id, async () => {
164
- return importFn(modulePath, importOptsForRoute(route));
165
- });
166
- console.log(` 🗺️ Metadata: ${route.pattern} -> ${route.id}`);
167
- continue;
178
+ if (route.kind === "metadata") {
179
+ const modulePath = path.resolve(rootDir, route.module);
180
+ registerMetadataHandler(route.id, async () => {
181
+ return importFn(modulePath, importOptsForRoute(route));
182
+ });
183
+ console.log(` 🗺️ Metadata: ${route.pattern} -> ${route.id}`);
184
+ continue;
168
185
  }
169
186
 
170
- if (route.kind === "api") {
171
- const modulePath = path.resolve(rootDir, route.module);
172
- try {
173
- const module = (await importFn(modulePath, importOptsForRoute(route))) as RouteModule;
187
+ if (route.kind === "api") {
188
+ const modulePath = path.resolve(rootDir, route.module);
189
+ try {
190
+ const module = (await importFn(modulePath, importOptsForRoute(route))) as RouteModule;
174
191
  let handler: unknown = module.default ?? module.handler ?? module;
175
192
 
176
193
  // 1) ManduFilling instance
@@ -217,7 +234,7 @@ export async function registerManifestHandlers(
217
234
  // Layout modules must export a default component. Runtime
218
235
  // validation in `renderToHTML` / page-loader asserts this —
219
236
  // so casting the unknown `importFn` result is safe here.
220
- return importFn(absLayoutPath, baseImportOpts);
237
+ return importFn(absLayoutPath, baseImportOpts);
221
238
  }) as Parameters<typeof registerLayoutLoader>[1]);
222
239
  registeredLayouts.add(layoutPath);
223
240
  console.log(` 🎨 Layout: ${layoutPath}`);
@@ -228,7 +245,7 @@ export async function registerManifestHandlers(
228
245
  // Use PageHandler if slotModule exists (filling.loader support)
229
246
  if (route.slotModule) {
230
247
  registerPageHandler(route.id, async () => {
231
- const mod = (await importFn(componentPath, importOptsForRoute(route))) as Record<string, unknown>;
248
+ const mod = (await importFn(componentPath, importOptsForRoute(route))) as Record<string, unknown>;
232
249
  // Normalize the page module shape. Users write pages in two styles:
233
250
  // (a) `export default function Page() {…}` + `export const filling = …`
234
251
  // (b) `export default { component: …, filling: … }`
@@ -266,7 +283,7 @@ export async function registerManifestHandlers(
266
283
  ` 📄 Page: ${route.pattern} -> ${route.id} (with loader)${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
267
284
  );
268
285
  } else {
269
- registerPageLoader(route.id, (() => importFn(componentPath, importOptsForRoute(route))) as Parameters<typeof registerPageLoader>[1]);
286
+ registerPageLoader(route.id, (() => importFn(componentPath, importOptsForRoute(route))) as Parameters<typeof registerPageLoader>[1]);
270
287
  console.log(
271
288
  ` 📄 Page: ${route.pattern} -> ${route.id}${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
272
289
  );
@@ -276,7 +293,7 @@ export async function registerManifestHandlers(
276
293
 
277
294
  // Phase 6.3: register `app/not-found.tsx` if it exists. Global, one per
278
295
  // app — the server falls through to the built-in 404 if unregistered.
279
- await registerAppNotFound(rootDir, importFn, baseImportOpts);
296
+ await registerAppNotFound(rootDir, importFn, baseImportOpts);
280
297
  }
281
298
 
282
299
  /**
@@ -284,11 +301,11 @@ export async function registerManifestHandlers(
284
301
  * project root and register it as the app-level 404 handler. Silent
285
302
  * no-op if no file exists — the server's built-in 404 covers that case.
286
303
  */
287
- async function registerAppNotFound(
288
- rootDir: string,
289
- importFn: RegisterHandlersOptions["importFn"],
290
- importOpts?: { changedFile?: string },
291
- ): Promise<void> {
304
+ async function registerAppNotFound(
305
+ rootDir: string,
306
+ importFn: RegisterHandlersOptions["importFn"],
307
+ importOpts?: { changedFile?: string },
308
+ ): Promise<void> {
292
309
  const candidates = [
293
310
  "app/not-found.tsx",
294
311
  "app/not-found.ts",