@mandujs/core 0.54.21 → 0.54.23
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 +1 -1
- package/src/client/router.ts +57 -47
- package/src/error/result.ts +48 -2
- package/src/filling/body-parse.test.ts +60 -0
- package/src/filling/context.ts +43 -18
- package/src/filling/filling.ts +26 -4
- package/src/filling/head-method.test.ts +72 -0
- package/src/runtime/__tests__/not-found-content-negotiation.test.ts +78 -0
- package/src/runtime/handlers.ts +70 -53
- package/src/runtime/server.ts +16 -5
- package/src/runtime/ssr.ts +102 -76
package/package.json
CHANGED
package/src/client/router.ts
CHANGED
|
@@ -165,42 +165,42 @@ const patternCache = new LRUCache<string, CompiledPattern>(LIMITS.ROUTER_PATTERN
|
|
|
165
165
|
// because `registerCacheSize` replaces any prior reporter under the same key.
|
|
166
166
|
registerCacheSize("patternCache", () => patternCache.size);
|
|
167
167
|
|
|
168
|
-
/**
|
|
169
|
-
* 패턴을 정규식으로 컴파일
|
|
170
|
-
*/
|
|
171
|
-
function compilePattern(pattern: string): CompiledPattern {
|
|
172
|
-
const cached = patternCache.get(pattern);
|
|
173
|
-
if (cached) return cached;
|
|
174
|
-
|
|
175
|
-
const paramNames: string[] = [];
|
|
176
|
-
const normalized = pattern === "/" ? "/" : pattern.replace(/\/+$/, "") || "/";
|
|
177
|
-
const segments = normalized.split("/").filter(Boolean);
|
|
178
|
-
|
|
179
|
-
const regexStr = segments.length === 0
|
|
180
|
-
? "/"
|
|
181
|
-
: segments.map((segment) => {
|
|
182
|
-
if (segment === "*") {
|
|
183
|
-
return "/.+";
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const wildcardMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)\*(\?)?$/);
|
|
187
|
-
if (wildcardMatch) {
|
|
188
|
-
paramNames.push(wildcardMatch[1]);
|
|
189
|
-
return wildcardMatch[2] === "?" ? "(?:/(.*))?" : "/(.+)";
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const paramMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
|
|
193
|
-
if (paramMatch) {
|
|
194
|
-
paramNames.push(paramMatch[1]);
|
|
195
|
-
return "/([^/]+)";
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return `/${escapePatternSegment(segment)}`;
|
|
199
|
-
}).join("");
|
|
200
|
-
|
|
201
|
-
const compiled = {
|
|
202
|
-
regex: new RegExp(`^${regexStr}$`),
|
|
203
|
-
paramNames,
|
|
168
|
+
/**
|
|
169
|
+
* 패턴을 정규식으로 컴파일
|
|
170
|
+
*/
|
|
171
|
+
function compilePattern(pattern: string): CompiledPattern {
|
|
172
|
+
const cached = patternCache.get(pattern);
|
|
173
|
+
if (cached) return cached;
|
|
174
|
+
|
|
175
|
+
const paramNames: string[] = [];
|
|
176
|
+
const normalized = pattern === "/" ? "/" : pattern.replace(/\/+$/, "") || "/";
|
|
177
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
178
|
+
|
|
179
|
+
const regexStr = segments.length === 0
|
|
180
|
+
? "/"
|
|
181
|
+
: segments.map((segment) => {
|
|
182
|
+
if (segment === "*") {
|
|
183
|
+
return "/.+";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const wildcardMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)\*(\?)?$/);
|
|
187
|
+
if (wildcardMatch) {
|
|
188
|
+
paramNames.push(wildcardMatch[1]);
|
|
189
|
+
return wildcardMatch[2] === "?" ? "(?:/(.*))?" : "/(.+)";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const paramMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
|
|
193
|
+
if (paramMatch) {
|
|
194
|
+
paramNames.push(paramMatch[1]);
|
|
195
|
+
return "/([^/]+)";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return `/${escapePatternSegment(segment)}`;
|
|
199
|
+
}).join("");
|
|
200
|
+
|
|
201
|
+
const compiled = {
|
|
202
|
+
regex: new RegExp(`^${regexStr}$`),
|
|
203
|
+
paramNames,
|
|
204
204
|
};
|
|
205
205
|
|
|
206
206
|
patternCache.set(pattern, compiled);
|
|
@@ -219,17 +219,17 @@ function extractParamsFromPath(
|
|
|
219
219
|
|
|
220
220
|
if (!match) return {};
|
|
221
221
|
|
|
222
|
-
const params: Record<string, string> = {};
|
|
223
|
-
compiled.paramNames.forEach((name, index) => {
|
|
224
|
-
params[name] = match[index + 1] ?? "";
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
return params;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function escapePatternSegment(segment: string): string {
|
|
231
|
-
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
232
|
-
}
|
|
222
|
+
const params: Record<string, string> = {};
|
|
223
|
+
compiled.paramNames.forEach((name, index) => {
|
|
224
|
+
params[name] = match[index + 1] ?? "";
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return params;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function escapePatternSegment(segment: string): string {
|
|
231
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
232
|
+
}
|
|
233
233
|
|
|
234
234
|
// ========== Navigation ==========
|
|
235
235
|
|
|
@@ -324,6 +324,16 @@ export async function navigate(
|
|
|
324
324
|
// json 파싱 사이에 새 네비게이션이 시작됐을 수 있음
|
|
325
325
|
if (controller.signal.aborted) return;
|
|
326
326
|
|
|
327
|
+
// #316: server-only target (no client-renderable route component). A
|
|
328
|
+
// client-side state update would change the URL but not the content
|
|
329
|
+
// ("click does nothing / goes back"). Fall back to a full document
|
|
330
|
+
// navigation so the server SSRs the page. `=== false` is intentional:
|
|
331
|
+
// older servers omit the flag, and we must not regress those.
|
|
332
|
+
if (data.clientRenderable === false) {
|
|
333
|
+
window.location.href = url.href;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
327
337
|
// 상태 + History + 스크롤을 한 번에 적용하는 함수
|
|
328
338
|
const applyUpdate = () => {
|
|
329
339
|
const historyState = { routeId: data.routeId, params: data.params };
|
package/src/error/result.ts
CHANGED
|
@@ -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
|
|
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, "<")
|
|
88
|
+
.replace(/>/g, ">");
|
|
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
|
+
});
|
package/src/filling/context.ts
CHANGED
|
@@ -549,7 +549,16 @@ export class ManduContext {
|
|
|
549
549
|
let data: unknown;
|
|
550
550
|
|
|
551
551
|
if (contentType.includes("application/json")) {
|
|
552
|
-
|
|
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
|
+
}
|
package/src/filling/filling.ts
CHANGED
|
@@ -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,18 @@ export class ManduFilling<TLoaderData = unknown> {
|
|
|
595
595
|
if (actionResult) return actionResult;
|
|
596
596
|
}
|
|
597
597
|
|
|
598
|
-
|
|
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
|
-
|
|
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
|
+
return ctx.json({ status: "error", message: `Method ${method} not allowed`, allowed }, 405);
|
|
601
610
|
}
|
|
602
611
|
const lifecycleWithDefaults = this.createLifecycleWithDefaults(routeContext);
|
|
603
612
|
const runHandler = async () => {
|
|
@@ -615,7 +624,17 @@ export class ManduFilling<TLoaderData = unknown> {
|
|
|
615
624
|
const composed = compose(chain);
|
|
616
625
|
return composed(ctx);
|
|
617
626
|
};
|
|
618
|
-
|
|
627
|
+
const response = await executeLifecycle(lifecycleWithDefaults, ctx, runHandler, options);
|
|
628
|
+
if (stripBody) {
|
|
629
|
+
// Preserve status + headers (incl. Content-Type / Content-Length the
|
|
630
|
+
// GET handler set), drop the body. A null-body Response keeps headers.
|
|
631
|
+
return new Response(null, {
|
|
632
|
+
status: response.status,
|
|
633
|
+
statusText: response.statusText,
|
|
634
|
+
headers: response.headers,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
return response;
|
|
619
638
|
}
|
|
620
639
|
|
|
621
640
|
/**
|
|
@@ -745,6 +764,9 @@ export class ManduFilling<TLoaderData = unknown> {
|
|
|
745
764
|
if (error instanceof ValidationError) {
|
|
746
765
|
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
766
|
}
|
|
767
|
+
if (error instanceof BadRequestError) {
|
|
768
|
+
return ctx.json({ errorType: "CLIENT_ERROR", code: "MANDU_C400", message: error.message, summary: "잘못된 요청 본문 - 클라이언트 입력 확인 필요", route: routeContext, timestamp: new Date().toISOString() }, 400);
|
|
769
|
+
}
|
|
748
770
|
const classifier = new ErrorClassifier(null, routeContext ? { id: routeContext.routeId, pattern: routeContext.pattern } : undefined);
|
|
749
771
|
const manduError = classifier.classify(error);
|
|
750
772
|
console.error(`[Mandu] ${manduError.errorType}:`, manduError.message);
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
});
|
|
@@ -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("<script>");
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/runtime/handlers.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
86
|
+
Allow: allow.join(", "),
|
|
78
87
|
},
|
|
79
88
|
}
|
|
80
89
|
);
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
|
|
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",
|
package/src/runtime/server.ts
CHANGED
|
@@ -1328,7 +1328,7 @@ async function handleRequestObserved(
|
|
|
1328
1328
|
const result = await handleRequestInternal(req, router, registry);
|
|
1329
1329
|
|
|
1330
1330
|
if (!result.ok) {
|
|
1331
|
-
const errorResponse = errorToResponse(result.error, registry.settings.isDev);
|
|
1331
|
+
const errorResponse = errorToResponse(result.error, registry.settings.isDev, req);
|
|
1332
1332
|
if (registry.settings.isDev) {
|
|
1333
1333
|
// #177: dev 모드 에러 응답도 캐시 방지
|
|
1334
1334
|
if (!errorResponse.headers.has("Cache-Control")) {
|
|
@@ -2394,14 +2394,14 @@ async function renderNotFoundPage(
|
|
|
2394
2394
|
if (!registration) {
|
|
2395
2395
|
const handler = registry.notFoundHandler;
|
|
2396
2396
|
if (!handler) {
|
|
2397
|
-
return errorToResponse(createNotFoundResponse(new URL(req.url).pathname), settings.isDev);
|
|
2397
|
+
return errorToResponse(createNotFoundResponse(new URL(req.url).pathname), settings.isDev, req);
|
|
2398
2398
|
}
|
|
2399
2399
|
try {
|
|
2400
2400
|
const globalReg = await handler();
|
|
2401
2401
|
registration = { component: globalReg.component as React.ComponentType<Record<string, unknown>>, filling: globalReg.filling };
|
|
2402
2402
|
} catch (handlerError) {
|
|
2403
2403
|
console.error(`[Mandu] global notFoundHandler failed; falling back to built-in 404:`, handlerError);
|
|
2404
|
-
return errorToResponse(createNotFoundResponse(new URL(req.url).pathname), settings.isDev);
|
|
2404
|
+
return errorToResponse(createNotFoundResponse(new URL(req.url).pathname), settings.isDev, req);
|
|
2405
2405
|
}
|
|
2406
2406
|
}
|
|
2407
2407
|
|
|
@@ -2460,7 +2460,7 @@ async function renderNotFoundPage(
|
|
|
2460
2460
|
return response;
|
|
2461
2461
|
} catch (renderError) {
|
|
2462
2462
|
console.error(`[Mandu] app/not-found.tsx render failed; falling back to built-in 404:`, renderError);
|
|
2463
|
-
return errorToResponse(createNotFoundResponse(new URL(req.url).pathname), settings.isDev);
|
|
2463
|
+
return errorToResponse(createNotFoundResponse(new URL(req.url).pathname), settings.isDev, req);
|
|
2464
2464
|
}
|
|
2465
2465
|
}
|
|
2466
2466
|
|
|
@@ -2638,11 +2638,22 @@ async function handlePageRoute(
|
|
|
2638
2638
|
// in prod is zero-value attack surface (request-triggered response
|
|
2639
2639
|
// header reflection). Silently ignore the request header instead.
|
|
2640
2640
|
const isHDR = settings.isDev && req.headers.get("x-mandu-hdr") === "1";
|
|
2641
|
+
// #316: tell the full SPA router whether it can render this route on the
|
|
2642
|
+
// client. Server-only pages (no route-level client hydration bundle) have
|
|
2643
|
+
// no client component, so a pushState + state update leaves the URL changed
|
|
2644
|
+
// but the content stale. When false, the router must fall back to a full
|
|
2645
|
+
// document navigation so the server re-renders the page.
|
|
2646
|
+
const clientRenderable = !!(
|
|
2647
|
+
route.hydration &&
|
|
2648
|
+
route.hydration.strategy !== "none" &&
|
|
2649
|
+
settings.bundleManifest?.bundles[route.id]?.js
|
|
2650
|
+
);
|
|
2641
2651
|
const jsonResponse = Response.json({
|
|
2642
2652
|
routeId: route.id,
|
|
2643
2653
|
pattern: route.pattern,
|
|
2644
2654
|
params,
|
|
2645
2655
|
loaderData: loaderData ?? null,
|
|
2656
|
+
clientRenderable,
|
|
2646
2657
|
timestamp: Date.now(),
|
|
2647
2658
|
});
|
|
2648
2659
|
if (isHDR) {
|
|
@@ -3383,7 +3394,7 @@ async function handleRequestInternal(
|
|
|
3383
3394
|
// Surface error-path responses to the chain so logging / metrics
|
|
3384
3395
|
// layers see the final status. The outer `handleRequest` still owns
|
|
3385
3396
|
// dev-mode Cache-Control stamping + observability lifecycle emission.
|
|
3386
|
-
return errorToResponse(result.error, settings.isDev);
|
|
3397
|
+
return errorToResponse(result.error, settings.isDev, finalReq);
|
|
3387
3398
|
},
|
|
3388
3399
|
});
|
|
3389
3400
|
if (middlewareResponse) {
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -6,13 +6,13 @@ import type { BundleManifest } from "../bundler/types";
|
|
|
6
6
|
import { isSafeManduUrl } from "../bundler/manifest-schema";
|
|
7
7
|
import type { HydrationConfig, HydrationPriority } from "../spec/schema";
|
|
8
8
|
import { PORTS, TIMEOUTS } from "../constants";
|
|
9
|
-
import { decodeHtmlText, escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
|
|
9
|
+
import { decodeHtmlText, escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
|
|
10
10
|
import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
|
|
11
|
-
import { generateFastRefreshPreamble } from "../bundler/fast-refresh-preamble";
|
|
11
|
+
import { generateFastRefreshPreamble } from "../bundler/fast-refresh-preamble";
|
|
12
12
|
import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
|
|
13
13
|
import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
|
|
14
|
-
import { maybeInjectDevOverlay } from "../dev-error-overlay";
|
|
15
|
-
import { renderWithManduClientBoundaryManifest } from "../internal/client-boundary";
|
|
14
|
+
import { maybeInjectDevOverlay } from "../dev-error-overlay";
|
|
15
|
+
import { renderWithManduClientBoundaryManifest } from "../internal/client-boundary";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Issue #192 — `@view-transition` at-rule block.
|
|
@@ -47,7 +47,7 @@ export interface SSROptions {
|
|
|
47
47
|
title?: string;
|
|
48
48
|
lang?: string;
|
|
49
49
|
/** 서버에서 로드한 데이터 (클라이언트로 전달) */
|
|
50
|
-
serverData?: unknown;
|
|
50
|
+
serverData?: unknown;
|
|
51
51
|
/** Hydration 설정 */
|
|
52
52
|
hydration?: HydrationConfig;
|
|
53
53
|
/** 번들 매니페스트 */
|
|
@@ -256,36 +256,36 @@ function generateHydrationScripts(
|
|
|
256
256
|
? Object.values(manifest.islands).filter((ib) => ib.route === routeId)
|
|
257
257
|
: [];
|
|
258
258
|
|
|
259
|
-
if (routeIslands.length > 0) {
|
|
260
|
-
for (const ib of routeIslands) {
|
|
261
|
-
const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
262
|
-
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
263
|
-
}
|
|
259
|
+
if (routeIslands.length > 0) {
|
|
260
|
+
for (const ib of routeIslands) {
|
|
261
|
+
const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
262
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
263
|
+
}
|
|
264
264
|
} else {
|
|
265
265
|
// Fallback: route-level bundle (backward compat)
|
|
266
266
|
const bundle = manifest.bundles[routeId];
|
|
267
267
|
if (bundle) {
|
|
268
268
|
const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
269
|
-
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (manifest.partials) {
|
|
274
|
-
for (const partial of Object.values(manifest.partials)) {
|
|
275
|
-
const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
276
|
-
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (manifest.boundaries) {
|
|
281
|
-
for (const boundary of Object.values(manifest.boundaries)) {
|
|
282
|
-
if (boundary.route !== routeId) continue;
|
|
283
|
-
const cacheBust = `${boundary.js}${boundary.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
284
|
-
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
|
|
269
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (manifest.partials) {
|
|
274
|
+
for (const partial of Object.values(manifest.partials)) {
|
|
275
|
+
const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
276
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (manifest.boundaries) {
|
|
281
|
+
for (const boundary of Object.values(manifest.boundaries)) {
|
|
282
|
+
if (boundary.route !== routeId) continue;
|
|
283
|
+
const cacheBust = `${boundary.js}${boundary.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
284
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
|
|
289
289
|
if (manifest.shared.runtime) {
|
|
290
290
|
scripts.push(`<script type="module" src="${escapeHtmlAttr(manifest.shared.runtime)}"></script>`);
|
|
291
291
|
}
|
|
@@ -628,9 +628,9 @@ export async function resolveAsyncElement(node: ReactNode): Promise<ReactNode> {
|
|
|
628
628
|
return React.cloneElement(element, undefined, resolvedChildren);
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
-
export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
|
|
632
|
-
const hasExplicitTitle = options.title !== undefined;
|
|
633
|
-
const {
|
|
631
|
+
export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
|
|
632
|
+
const hasExplicitTitle = options.title !== undefined;
|
|
633
|
+
const {
|
|
634
634
|
title = "Mandu App",
|
|
635
635
|
lang = "ko",
|
|
636
636
|
serverData,
|
|
@@ -712,9 +712,9 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
712
712
|
} catch { /* client 모듈 로드 실패 시 무시 */ }
|
|
713
713
|
|
|
714
714
|
const renderToString = getRenderToString();
|
|
715
|
-
let content = renderWithManduClientBoundaryManifest(routeId, bundleManifest, () =>
|
|
716
|
-
renderToString(element),
|
|
717
|
-
);
|
|
715
|
+
let content = renderWithManduClientBoundaryManifest(routeId, bundleManifest, () =>
|
|
716
|
+
renderToString(element),
|
|
717
|
+
);
|
|
718
718
|
|
|
719
719
|
// 렌더링 중 수집된 head 태그
|
|
720
720
|
collectedHeadTags = headGet?.() ?? "";
|
|
@@ -724,14 +724,14 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
724
724
|
const needsHydration =
|
|
725
725
|
hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
726
726
|
|
|
727
|
-
if (needsHydration && !islandPreWrapped) {
|
|
728
|
-
// v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
|
|
729
|
-
const bundle = bundleManifest.bundles[routeId];
|
|
730
|
-
const bundleSrc = bundle?.js;
|
|
731
|
-
if (bundleSrc) {
|
|
732
|
-
content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
727
|
+
if (needsHydration && !islandPreWrapped) {
|
|
728
|
+
// v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
|
|
729
|
+
const bundle = bundleManifest.bundles[routeId];
|
|
730
|
+
const bundleSrc = bundle?.js;
|
|
731
|
+
if (bundleSrc) {
|
|
732
|
+
content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
735
|
|
|
736
736
|
// Zero-JS 모드: island이 없는 페이지에서는 클라이언트 JS 번들을 전송하지 않음
|
|
737
737
|
// HMR/DevTools는 dev 환경에서만 유지 (CSS 핫리로드 등)
|
|
@@ -742,10 +742,10 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
742
742
|
|
|
743
743
|
if (needsHydration) {
|
|
744
744
|
// 서버 데이터 스크립트 (클라이언트 hydration에서 사용)
|
|
745
|
-
if (serverData !== undefined && routeId) {
|
|
746
|
-
const wrappedData = {
|
|
747
|
-
[routeId]: {
|
|
748
|
-
serverData,
|
|
745
|
+
if (serverData !== undefined && routeId) {
|
|
746
|
+
const wrappedData = {
|
|
747
|
+
[routeId]: {
|
|
748
|
+
serverData,
|
|
749
749
|
timestamp: Date.now(),
|
|
750
750
|
},
|
|
751
751
|
};
|
|
@@ -814,28 +814,52 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
814
814
|
? `<script>window.__MANDU_SPA__=false;</script>`
|
|
815
815
|
: "";
|
|
816
816
|
|
|
817
|
-
// #179: body 내 <link> 태그를 <head>로 호이스팅
|
|
817
|
+
// #179: body 내 <link> 태그를 <head>로 호이스팅
|
|
818
818
|
// React 컴포넌트(layout.tsx 등)에서 <link>를 렌더링하면 body 안에 위치하게 되는데,
|
|
819
819
|
// 폰트/스타일시트는 <head>에 있어야 FOUT 없이 로드됨
|
|
820
820
|
const linkTagPattern = /<link\s[^>]*(?:rel=["'](?:stylesheet|preconnect|preload|icon|dns-prefetch)["'][^>]*|href=["'][^"']+["'][^>]*)\/?\s*>/gi;
|
|
821
821
|
const hoistedLinks: string[] = [];
|
|
822
|
-
const
|
|
822
|
+
const bodyAfterLinks = content.replace(linkTagPattern, (match) => {
|
|
823
823
|
hoistedLinks.push(match);
|
|
824
824
|
return "";
|
|
825
825
|
});
|
|
826
|
-
const hoistedLinkTags = hoistedLinks.join("\n ");
|
|
827
|
-
|
|
828
|
-
// #
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
826
|
+
const hoistedLinkTags = hoistedLinks.join("\n ");
|
|
827
|
+
|
|
828
|
+
// #317: hoist body <meta> (og:*, twitter:*, description, …) and JSON-LD into
|
|
829
|
+
// <head>. The legacy renderToString path does not perform React 19's
|
|
830
|
+
// document-metadata hoisting, so without this og/twitter cards land in the
|
|
831
|
+
// body where crawlers ignore them. Mirrors React 19's rule: <meta> is
|
|
832
|
+
// hoistable document metadata EXCEPT microdata (`itemprop`), which is
|
|
833
|
+
// content and must stay where it was authored.
|
|
834
|
+
const metaTagPattern = /<meta\s[^>]*?\/?>/gi;
|
|
835
|
+
const hoistedMetas: string[] = [];
|
|
836
|
+
const bodyAfterMeta = bodyAfterLinks.replace(metaTagPattern, (match) => {
|
|
837
|
+
if (/\bitemprop[\s=]/i.test(match)) return match;
|
|
838
|
+
hoistedMetas.push(match);
|
|
839
|
+
return "";
|
|
840
|
+
});
|
|
841
|
+
const hoistedMetaTags = hoistedMetas.join("\n ");
|
|
842
|
+
|
|
843
|
+
const ldJsonPattern =
|
|
844
|
+
/<script\s[^>]*type=["']application\/ld\+json["'][^>]*>[\s\S]*?<\/script>/gi;
|
|
845
|
+
const hoistedLdJson: string[] = [];
|
|
846
|
+
const bodyContent = bodyAfterMeta.replace(ldJsonPattern, (match) => {
|
|
847
|
+
hoistedLdJson.push(match);
|
|
848
|
+
return "";
|
|
849
|
+
});
|
|
850
|
+
const hoistedLdJsonTags = hoistedLdJson.join("\n ");
|
|
851
|
+
|
|
852
|
+
// #273 F15 — React 19 renders document metadata such as <title> from a
|
|
853
|
+
// page component into the body string in this SSR path. Hoist the first
|
|
854
|
+
// body title into <head> when no metadata/generateMetadata title was
|
|
855
|
+
// provided, and always strip body titles to avoid duplicate/invalid HTML.
|
|
856
|
+
let effectiveTitle = title;
|
|
857
|
+
const titleTagPattern = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i;
|
|
858
|
+
const bodyTitleMatch = bodyContent.match(titleTagPattern);
|
|
859
|
+
const bodyWithoutTitle = bodyContent.replace(/<title(?:\s[^>]*)?>[\s\S]*?<\/title>/gi, "");
|
|
860
|
+
if (!hasExplicitTitle && bodyTitleMatch) {
|
|
861
|
+
effectiveTitle = decodeHtmlText(bodyTitleMatch[1] ?? title);
|
|
862
|
+
}
|
|
839
863
|
|
|
840
864
|
// Phase 18.α — Dev Error Overlay injection.
|
|
841
865
|
// Only emitted when `isDev` AND the user has not opted out (via
|
|
@@ -853,19 +877,21 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
853
877
|
<head>
|
|
854
878
|
<meta charset="UTF-8">
|
|
855
879
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
856
|
-
<title>${escapeHtmlText(effectiveTitle)}</title>
|
|
880
|
+
<title>${escapeHtmlText(effectiveTitle)}</title>
|
|
857
881
|
${cssLinkTag}
|
|
858
882
|
${viewTransitionTag}
|
|
859
883
|
${prefetchScriptTag}
|
|
860
884
|
${spaNavHelperTag}
|
|
861
885
|
${hoistedLinkTags}
|
|
886
|
+
${hoistedMetaTags}
|
|
887
|
+
${hoistedLdJsonTags}
|
|
862
888
|
${headTags}
|
|
863
889
|
${collectedHeadTags}
|
|
864
890
|
${fastRefreshPreamble}
|
|
865
891
|
${devErrorOverlayTag}
|
|
866
892
|
</head>
|
|
867
893
|
<body>
|
|
868
|
-
<div id="root"${rootAttrs}>${bodyWithoutTitle}</div>
|
|
894
|
+
<div id="root"${rootAttrs}>${bodyWithoutTitle}</div>
|
|
869
895
|
${dataScript}
|
|
870
896
|
${routeScript}
|
|
871
897
|
${hydrationScripts}
|
|
@@ -882,11 +908,11 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
882
908
|
/**
|
|
883
909
|
* Client-side Routing: 현재 라우트 정보 스크립트 생성
|
|
884
910
|
*/
|
|
885
|
-
function generateRouteScript(
|
|
886
|
-
routeId: string,
|
|
887
|
-
pattern: string,
|
|
888
|
-
_serverData?: unknown
|
|
889
|
-
): string {
|
|
911
|
+
function generateRouteScript(
|
|
912
|
+
routeId: string,
|
|
913
|
+
pattern: string,
|
|
914
|
+
_serverData?: unknown
|
|
915
|
+
): string {
|
|
890
916
|
const routeInfo = {
|
|
891
917
|
id: routeId,
|
|
892
918
|
pattern,
|
|
@@ -1187,12 +1213,12 @@ export function renderSSR(element: ReactElement, options: SSROptions = {}): Resp
|
|
|
1187
1213
|
*/
|
|
1188
1214
|
export async function renderWithHydration(
|
|
1189
1215
|
element: ReactElement,
|
|
1190
|
-
options: SSROptions & {
|
|
1191
|
-
routeId: string;
|
|
1192
|
-
serverData: unknown;
|
|
1193
|
-
hydration: HydrationConfig;
|
|
1194
|
-
bundleManifest: BundleManifest;
|
|
1195
|
-
}
|
|
1216
|
+
options: SSROptions & {
|
|
1217
|
+
routeId: string;
|
|
1218
|
+
serverData: unknown;
|
|
1219
|
+
hydration: HydrationConfig;
|
|
1220
|
+
bundleManifest: BundleManifest;
|
|
1221
|
+
}
|
|
1196
1222
|
): Promise<Response> {
|
|
1197
1223
|
const html = renderToHTML(element, options);
|
|
1198
1224
|
// Phase 7.2 R1 Agent C (H1) — same CSP header logic as renderSSR.
|