@mandujs/core 0.54.12 → 0.54.14

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.
Files changed (80) hide show
  1. package/package.json +9 -1
  2. package/scripts/postinstall-lock.ts +153 -153
  3. package/src/a11y/run-audit.ts +15 -15
  4. package/src/agent/__tests__/context.test.ts +49 -1
  5. package/src/agent/context.ts +535 -535
  6. package/src/agent/index.ts +6 -6
  7. package/src/agent/plan.ts +282 -282
  8. package/src/agent/repair.ts +171 -171
  9. package/src/agent/sync.ts +200 -200
  10. package/src/agent/types.ts +8 -0
  11. package/src/agent/verify.ts +100 -2
  12. package/src/brain/doctor/analyzer.ts +7 -7
  13. package/src/bundler/__tests__/build-runner.ts +36 -16
  14. package/src/bundler/__tests__/cold-start.test.ts +60 -60
  15. package/src/bundler/__tests__/css.test.ts +20 -20
  16. package/src/bundler/__tests__/fast-refresh.test.ts +24 -17
  17. package/src/bundler/analyzer.ts +15 -15
  18. package/src/bundler/build.test.ts +136 -48
  19. package/src/bundler/build.ts +193 -122
  20. package/src/bundler/css.ts +42 -42
  21. package/src/bundler/manifest-schema.ts +21 -21
  22. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  23. package/src/bundler/plugins/block-generated-imports.ts +13 -13
  24. package/src/bundler/types.ts +31 -31
  25. package/src/client/island.ts +79 -79
  26. package/src/config/validate.ts +1 -1
  27. package/src/contract/schema.ts +7 -0
  28. package/src/deploy/inference/context.ts +82 -82
  29. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  30. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  31. package/src/error/formatter.ts +10 -1
  32. package/src/experimental/index.ts +10 -0
  33. package/src/filling/context.ts +17 -17
  34. package/src/filling/filling.ts +22 -1
  35. package/src/filling/index.ts +15 -1
  36. package/src/generator/generate.ts +30 -30
  37. package/src/generator/index.ts +3 -3
  38. package/src/generator/templates.ts +210 -210
  39. package/src/guard/check.ts +9 -9
  40. package/src/guard/config-guard.ts +13 -13
  41. package/src/guard/fs-routes-policy.ts +51 -51
  42. package/src/guard/index.ts +11 -11
  43. package/src/index.ts +0 -10
  44. package/src/internal/index.ts +25 -0
  45. package/src/kitchen/api/file-api.ts +11 -11
  46. package/src/report/index.ts +1 -1
  47. package/src/resource/__tests__/generator.test.ts +6 -6
  48. package/src/resource/__tests__/schema.test.ts +14 -14
  49. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  50. package/src/resource/ddl/emit.ts +146 -146
  51. package/src/resource/generator-schema.ts +11 -11
  52. package/src/resource/generators/slot.ts +72 -72
  53. package/src/resource/schema.ts +21 -21
  54. package/src/router/client-entry.test.ts +84 -41
  55. package/src/router/client-entry.ts +156 -89
  56. package/src/router/fs-routes.test.ts +90 -0
  57. package/src/router/fs-routes.ts +37 -29
  58. package/src/router/fs-scanner.ts +81 -33
  59. package/src/router/fs-types.ts +8 -5
  60. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  61. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  62. package/src/runtime/__tests__/page-render-response.test.ts +164 -103
  63. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  64. package/src/runtime/devtools-adapter.ts +68 -68
  65. package/src/runtime/escape.ts +34 -34
  66. package/src/runtime/image-feature.ts +15 -0
  67. package/src/runtime/observability-lifecycle.ts +290 -290
  68. package/src/runtime/page-render-response.ts +208 -106
  69. package/src/runtime/rate-limit.ts +231 -0
  70. package/src/runtime/request-middleware.ts +31 -31
  71. package/src/runtime/router.test.ts +4 -4
  72. package/src/runtime/router.ts +10 -12
  73. package/src/runtime/scheduler-lifecycle.ts +64 -0
  74. package/src/runtime/server.ts +148 -353
  75. package/src/runtime/ssr.ts +59 -59
  76. package/src/runtime/static-files.ts +289 -289
  77. package/src/runtime/streaming-ssr.ts +22 -22
  78. package/src/spec/schema.ts +4 -3
  79. package/src/watcher/__tests__/watcher.test.ts +59 -59
  80. package/src/watcher/watcher.ts +61 -61
@@ -1,106 +1,208 @@
1
- import type React from "react";
2
- import type { BundleManifest } from "../bundler/types";
3
- import type { HydrationConfig } from "../spec/schema";
4
- import type { CookieManager } from "../filling/context";
5
- import { renderSSR, renderStreamingResponse, resolveAsyncElement } from "./ssr";
6
-
7
- export interface PageRenderResponseOptions {
8
- app: React.ReactElement;
9
- useStreaming: boolean;
10
- title: string;
11
- headTags: string;
12
- isDev: boolean;
13
- hmrPort?: number;
14
- routeId: string;
15
- routePattern: string;
16
- layoutChain?: string[];
17
- hydration?: HydrationConfig;
18
- bundleManifest?: BundleManifest;
19
- loaderData: unknown;
20
- cssPath?: string | false;
21
- transitions?: boolean;
22
- prefetch?: boolean;
23
- spa?: boolean;
24
- devtools?: boolean;
25
- islandPreWrapped?: boolean;
26
- cookies?: CookieManager;
27
- }
28
-
29
- export async function renderPageResponse(
30
- options: PageRenderResponseOptions
31
- ): Promise<Response> {
32
- let app = options.app;
33
-
34
- if (!options.useStreaming) {
35
- app = (await resolveAsyncElement(app)) as React.ReactElement;
36
- }
37
-
38
- const response = options.useStreaming
39
- ? await renderStreamingPageResponse(app, options)
40
- : renderNonStreamingPageResponse(app, options);
41
-
42
- return options.cookies ? options.cookies.applyToResponse(response) : response;
43
- }
44
-
45
- async function renderStreamingPageResponse(
46
- app: React.ReactElement,
47
- options: PageRenderResponseOptions
48
- ): Promise<Response> {
49
- return renderStreamingResponse(app, {
50
- title: options.title,
51
- headTags: options.headTags,
52
- isDev: options.isDev,
53
- hmrPort: options.hmrPort,
54
- routeId: options.routeId,
55
- routePattern: options.routePattern,
56
- layoutChain: options.layoutChain,
57
- hydration: options.hydration,
58
- bundleManifest: options.bundleManifest,
59
- criticalData: options.loaderData as Record<string, unknown> | undefined,
60
- enableClientRouter: true,
61
- cssPath: options.cssPath,
62
- transitions: options.transitions,
63
- prefetch: options.prefetch,
64
- spa: options.spa,
65
- devtools: options.devtools,
66
- onShellReady: () => {
67
- if (options.isDev) {
68
- console.log(`[Mandu Streaming] Shell ready: ${options.routeId}`);
69
- }
70
- },
71
- onMetrics: (metrics) => {
72
- if (options.isDev) {
73
- console.log(`[Mandu Streaming] Metrics for ${options.routeId}:`, {
74
- shellReadyTime: `${metrics.shellReadyTime}ms`,
75
- allReadyTime: `${metrics.allReadyTime}ms`,
76
- hasError: metrics.hasError,
77
- });
78
- }
79
- },
80
- });
81
- }
82
-
83
- function renderNonStreamingPageResponse(
84
- app: React.ReactElement,
85
- options: PageRenderResponseOptions
86
- ): Response {
87
- return renderSSR(app, {
88
- title: options.title,
89
- headTags: options.headTags,
90
- isDev: options.isDev,
91
- hmrPort: options.hmrPort,
92
- routeId: options.routeId,
93
- hydration: options.hydration,
94
- bundleManifest: options.bundleManifest,
95
- serverData: options.loaderData,
96
- enableClientRouter: true,
97
- routePattern: options.routePattern,
98
- cssPath: options.cssPath,
99
- islandPreWrapped: !!options.islandPreWrapped,
100
- transitions: options.transitions,
101
- prefetch: options.prefetch,
102
- spa: options.spa,
103
- devtools: options.devtools,
104
- layoutChain: options.layoutChain,
105
- });
106
- }
1
+ import React from "react";
2
+ import type { BundleManifest } from "../bundler/types";
3
+ import type { HydrationConfig } from "../spec/schema";
4
+ import type { CookieManager } from "../filling/context";
5
+ import { renderSSR, renderStreamingResponse, resolveAsyncElement } from "./ssr";
6
+ import { serializeProps } from "../client/serialize";
7
+
8
+ export interface InlineClientHydrationTarget {
9
+ routeId: string;
10
+ src: string;
11
+ priority: NonNullable<HydrationConfig["priority"]>;
12
+ component: unknown;
13
+ }
14
+
15
+ export interface PageRenderResponseOptions {
16
+ app: React.ReactElement;
17
+ useStreaming: boolean;
18
+ title: string;
19
+ headTags: string;
20
+ isDev: boolean;
21
+ hmrPort?: number;
22
+ routeId: string;
23
+ routePattern: string;
24
+ layoutChain?: string[];
25
+ hydration?: HydrationConfig;
26
+ bundleManifest?: BundleManifest;
27
+ loaderData: unknown;
28
+ cssPath?: string | false;
29
+ transitions?: boolean;
30
+ prefetch?: boolean;
31
+ spa?: boolean;
32
+ devtools?: boolean;
33
+ islandPreWrapped?: boolean;
34
+ inlineClientHydration?: InlineClientHydrationTarget;
35
+ cookies?: CookieManager;
36
+ }
37
+
38
+ export async function renderPageResponse(
39
+ options: PageRenderResponseOptions
40
+ ): Promise<Response> {
41
+ let app = options.app;
42
+ let islandPreWrapped = !!options.islandPreWrapped;
43
+
44
+ if (!options.useStreaming) {
45
+ if (options.inlineClientHydration) {
46
+ const resolved = await resolveAndWrapInlineClientHydration(
47
+ app,
48
+ options.inlineClientHydration,
49
+ );
50
+ app = resolved.node as React.ReactElement;
51
+ islandPreWrapped = islandPreWrapped || resolved.didWrap;
52
+ } else {
53
+ app = (await resolveAsyncElement(app)) as React.ReactElement;
54
+ }
55
+ }
56
+
57
+ const effectiveOptions = islandPreWrapped === !!options.islandPreWrapped
58
+ ? options
59
+ : { ...options, islandPreWrapped };
60
+
61
+ const response = options.useStreaming
62
+ ? await renderStreamingPageResponse(app, effectiveOptions)
63
+ : renderNonStreamingPageResponse(app, effectiveOptions);
64
+
65
+ return options.cookies ? options.cookies.applyToResponse(response) : response;
66
+ }
67
+
68
+ async function resolveAndWrapInlineClientHydration(
69
+ node: React.ReactNode,
70
+ target: InlineClientHydrationTarget,
71
+ counter = { value: 0 },
72
+ ): Promise<{ node: React.ReactNode; didWrap: boolean }> {
73
+ if (node == null || typeof node !== "object") {
74
+ return { node, didWrap: false };
75
+ }
76
+
77
+ if (Array.isArray(node)) {
78
+ let didWrap = false;
79
+ const children = await Promise.all(
80
+ node.map(async (child) => {
81
+ const result = await resolveAndWrapInlineClientHydration(child, target, counter);
82
+ didWrap = didWrap || result.didWrap;
83
+ return result.node;
84
+ }),
85
+ );
86
+ return { node: children, didWrap };
87
+ }
88
+
89
+ if (!React.isValidElement(node)) {
90
+ return { node, didWrap: false };
91
+ }
92
+
93
+ const element = node as React.ReactElement<Record<string, unknown>>;
94
+ const type = element.type;
95
+
96
+ if (type === target.component) {
97
+ const id = `${target.routeId}--${counter.value++}`;
98
+ return {
99
+ node: React.createElement(
100
+ "div",
101
+ {
102
+ "data-mandu-island": id,
103
+ "data-mandu-src": target.src,
104
+ "data-mandu-priority": target.priority,
105
+ "data-hydrate": priorityToHydrate(target.priority),
106
+ "data-props": serializeProps(element.props ?? {}),
107
+ style: { display: "contents" },
108
+ },
109
+ element,
110
+ ),
111
+ didWrap: true,
112
+ };
113
+ }
114
+
115
+ if (typeof type === "function" && !isClassComponent(type)) {
116
+ const rendered = await (type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>)(
117
+ element.props ?? {},
118
+ );
119
+ return resolveAndWrapInlineClientHydration(rendered, target, counter);
120
+ }
121
+
122
+ const props = element.props;
123
+ const rawChildren = props?.children as React.ReactNode | undefined;
124
+ if (rawChildren === undefined) {
125
+ return { node: element, didWrap: false };
126
+ }
127
+
128
+ const resolvedChildren = await resolveAndWrapInlineClientHydration(rawChildren, target, counter);
129
+ if (!resolvedChildren.didWrap && resolvedChildren.node === rawChildren) {
130
+ return { node: element, didWrap: false };
131
+ }
132
+
133
+ const cloned = Array.isArray(resolvedChildren.node)
134
+ ? React.cloneElement(element, undefined, ...resolvedChildren.node)
135
+ : React.cloneElement(element, undefined, resolvedChildren.node);
136
+ return { node: cloned, didWrap: resolvedChildren.didWrap };
137
+ }
138
+
139
+ function isClassComponent(type: Function): boolean {
140
+ return !!(type.prototype && type.prototype.isReactComponent);
141
+ }
142
+
143
+ function priorityToHydrate(priority: InlineClientHydrationTarget["priority"]): string {
144
+ return priority === "immediate" ? "load" : priority;
145
+ }
146
+
147
+ async function renderStreamingPageResponse(
148
+ app: React.ReactElement,
149
+ options: PageRenderResponseOptions
150
+ ): Promise<Response> {
151
+ return renderStreamingResponse(app, {
152
+ title: options.title,
153
+ headTags: options.headTags,
154
+ isDev: options.isDev,
155
+ hmrPort: options.hmrPort,
156
+ routeId: options.routeId,
157
+ routePattern: options.routePattern,
158
+ layoutChain: options.layoutChain,
159
+ hydration: options.hydration,
160
+ bundleManifest: options.bundleManifest,
161
+ criticalData: options.loaderData as Record<string, unknown> | undefined,
162
+ enableClientRouter: true,
163
+ cssPath: options.cssPath,
164
+ transitions: options.transitions,
165
+ prefetch: options.prefetch,
166
+ spa: options.spa,
167
+ devtools: options.devtools,
168
+ onShellReady: () => {
169
+ if (options.isDev) {
170
+ console.log(`[Mandu Streaming] Shell ready: ${options.routeId}`);
171
+ }
172
+ },
173
+ onMetrics: (metrics) => {
174
+ if (options.isDev) {
175
+ console.log(`[Mandu Streaming] Metrics for ${options.routeId}:`, {
176
+ shellReadyTime: `${metrics.shellReadyTime}ms`,
177
+ allReadyTime: `${metrics.allReadyTime}ms`,
178
+ hasError: metrics.hasError,
179
+ });
180
+ }
181
+ },
182
+ });
183
+ }
184
+
185
+ function renderNonStreamingPageResponse(
186
+ app: React.ReactElement,
187
+ options: PageRenderResponseOptions
188
+ ): Response {
189
+ return renderSSR(app, {
190
+ title: options.title,
191
+ headTags: options.headTags,
192
+ isDev: options.isDev,
193
+ hmrPort: options.hmrPort,
194
+ routeId: options.routeId,
195
+ hydration: options.hydration,
196
+ bundleManifest: options.bundleManifest,
197
+ serverData: options.loaderData,
198
+ enableClientRouter: true,
199
+ routePattern: options.routePattern,
200
+ cssPath: options.cssPath,
201
+ islandPreWrapped: !!options.islandPreWrapped,
202
+ transitions: options.transitions,
203
+ prefetch: options.prefetch,
204
+ spa: options.spa,
205
+ devtools: options.devtools,
206
+ layoutChain: options.layoutChain,
207
+ });
208
+ }
@@ -0,0 +1,231 @@
1
+ export interface RateLimitOptions {
2
+ windowMs?: number;
3
+ max?: number;
4
+ message?: string;
5
+ statusCode?: number;
6
+ headers?: boolean;
7
+ /**
8
+ * Reverse proxy 헤더를 신뢰할지 여부
9
+ * - false(기본): X-Forwarded-For 등을 읽지만 spoofing 가능성을 표시
10
+ * - true: 전달된 클라이언트 IP를 완전히 신뢰
11
+ * 주의: trustProxy: false여도 클라이언트 구분을 위해 헤더를 사용하므로
12
+ * IP spoofing이 가능합니다. 신뢰할 수 있는 프록시 뒤에서만 사용하세요.
13
+ */
14
+ trustProxy?: boolean;
15
+ /**
16
+ * 메모리 보호를 위한 최대 key 수
17
+ * - 초과 시 오래된 key부터 제거
18
+ */
19
+ maxKeys?: number;
20
+ }
21
+
22
+ export interface NormalizedRateLimitOptions {
23
+ windowMs: number;
24
+ max: number;
25
+ message: string;
26
+ statusCode: number;
27
+ headers: boolean;
28
+ trustProxy: boolean;
29
+ maxKeys: number;
30
+ }
31
+
32
+ export interface RateLimitDecision {
33
+ allowed: boolean;
34
+ limit: number;
35
+ remaining: number;
36
+ resetAt: number;
37
+ }
38
+
39
+ export class MemoryRateLimiter {
40
+ private readonly store = new Map<string, { count: number; resetAt: number }>();
41
+ private lastCleanupAt = 0;
42
+
43
+ consume(req: Request, routeId: string, options: NormalizedRateLimitOptions): RateLimitDecision {
44
+ const now = Date.now();
45
+ this.maybeCleanup(now, options);
46
+
47
+ const key = `${this.getClientKey(req, options)}:${routeId}`;
48
+ const current = this.store.get(key);
49
+
50
+ if (!current || current.resetAt <= now) {
51
+ const resetAt = now + options.windowMs;
52
+ this.store.set(key, { count: 1, resetAt });
53
+ this.enforceMaxKeys(options.maxKeys);
54
+ return { allowed: true, limit: options.max, remaining: Math.max(0, options.max - 1), resetAt };
55
+ }
56
+
57
+ current.count += 1;
58
+ this.store.set(key, current);
59
+
60
+ return {
61
+ allowed: current.count <= options.max,
62
+ limit: options.max,
63
+ remaining: Math.max(0, options.max - current.count),
64
+ resetAt: current.resetAt,
65
+ };
66
+ }
67
+
68
+ private maybeCleanup(now: number, options: NormalizedRateLimitOptions): void {
69
+ if (now - this.lastCleanupAt < Math.max(1_000, options.windowMs)) {
70
+ return;
71
+ }
72
+
73
+ this.lastCleanupAt = now;
74
+ for (const [key, entry] of this.store.entries()) {
75
+ if (entry.resetAt <= now) {
76
+ this.store.delete(key);
77
+ }
78
+ }
79
+ }
80
+
81
+ private enforceMaxKeys(maxKeys: number): void {
82
+ while (this.store.size > maxKeys) {
83
+ const oldestKey = this.store.keys().next().value;
84
+ if (!oldestKey) break;
85
+ this.store.delete(oldestKey);
86
+ }
87
+ }
88
+
89
+ private getClientKey(req: Request, options: NormalizedRateLimitOptions): string {
90
+ const candidates = [
91
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
92
+ req.headers.get("x-real-ip")?.trim(),
93
+ req.headers.get("cf-connecting-ip")?.trim(),
94
+ req.headers.get("true-client-ip")?.trim(),
95
+ req.headers.get("fly-client-ip")?.trim(),
96
+ ];
97
+
98
+ for (const candidate of candidates) {
99
+ if (candidate) {
100
+ const sanitized = candidate.slice(0, 64);
101
+ return options.trustProxy ? sanitized : `unverified:${sanitized}`;
102
+ }
103
+ }
104
+
105
+ return "default";
106
+ }
107
+ }
108
+
109
+ export function normalizeRateLimitOptions(
110
+ options: boolean | RateLimitOptions | undefined
111
+ ): NormalizedRateLimitOptions | false {
112
+ if (!options) return false;
113
+ if (options === true) {
114
+ return {
115
+ windowMs: 60_000,
116
+ max: 100,
117
+ message: "Too Many Requests",
118
+ statusCode: 429,
119
+ headers: true,
120
+ trustProxy: false,
121
+ maxKeys: 10_000,
122
+ };
123
+ }
124
+
125
+ const windowMs = Number.isFinite(options.windowMs) ? Math.max(1_000, options.windowMs!) : 60_000;
126
+ const max = Number.isFinite(options.max) ? Math.max(1, Math.floor(options.max!)) : 100;
127
+ const statusCode = Number.isFinite(options.statusCode)
128
+ ? Math.min(599, Math.max(400, Math.floor(options.statusCode!)))
129
+ : 429;
130
+ const maxKeys = Number.isFinite(options.maxKeys)
131
+ ? Math.max(100, Math.floor(options.maxKeys!))
132
+ : 10_000;
133
+
134
+ return {
135
+ windowMs,
136
+ max,
137
+ message: options.message ?? "Too Many Requests",
138
+ statusCode,
139
+ headers: options.headers ?? true,
140
+ trustProxy: options.trustProxy ?? false,
141
+ maxKeys,
142
+ };
143
+ }
144
+
145
+ export function appendRateLimitHeaders(
146
+ response: Response,
147
+ decision: RateLimitDecision,
148
+ options: NormalizedRateLimitOptions
149
+ ): Response {
150
+ if (!options.headers) return response;
151
+
152
+ const headers = new Headers(response.headers);
153
+ const retryAfterSec = Math.max(1, Math.ceil((decision.resetAt - Date.now()) / 1000));
154
+
155
+ headers.set("X-RateLimit-Limit", String(decision.limit));
156
+ headers.set("X-RateLimit-Remaining", String(decision.remaining));
157
+ headers.set("X-RateLimit-Reset", String(Math.floor(decision.resetAt / 1000)));
158
+ headers.set("Retry-After", String(retryAfterSec));
159
+
160
+ return new Response(response.body, {
161
+ status: response.status,
162
+ statusText: response.statusText,
163
+ headers,
164
+ });
165
+ }
166
+
167
+ export function createRateLimitResponse(
168
+ decision: RateLimitDecision,
169
+ options: NormalizedRateLimitOptions
170
+ ): Response {
171
+ const response = Response.json(
172
+ {
173
+ error: "rate_limit_exceeded",
174
+ message: options.message,
175
+ limit: decision.limit,
176
+ remaining: decision.remaining,
177
+ retryAfter: Math.max(1, Math.ceil((decision.resetAt - Date.now()) / 1000)),
178
+ },
179
+ { status: options.statusCode }
180
+ );
181
+
182
+ return appendRateLimitHeaders(response, decision, options);
183
+ }
184
+
185
+ /**
186
+ * Create standalone rate limiter for custom API handlers.
187
+ *
188
+ * @example
189
+ * ```typescript
190
+ * import { createRateLimiter } from '@mandujs/core/runtime/server';
191
+ *
192
+ * const limiter = createRateLimiter({ max: 5, windowMs: 60000 });
193
+ *
194
+ * export async function POST(req: Request) {
195
+ * const decision = limiter.check(req, 'my-api-route');
196
+ * if (!decision.allowed) return limiter.createResponse(decision);
197
+ * return new Response('OK');
198
+ * }
199
+ * ```
200
+ */
201
+ export function createRateLimiter(options?: RateLimitOptions) {
202
+ const normalized = normalizeRateLimitOptions(options || true);
203
+ if (!normalized) {
204
+ throw new Error("Rate limiter options must be truthy");
205
+ }
206
+
207
+ const limiter = new MemoryRateLimiter();
208
+
209
+ return {
210
+ /**
211
+ * Check if request is allowed.
212
+ */
213
+ check(req: Request, routeId: string): RateLimitDecision {
214
+ return limiter.consume(req, routeId, normalized);
215
+ },
216
+
217
+ /**
218
+ * Create 429 response for denied request.
219
+ */
220
+ createResponse(decision: RateLimitDecision): Response {
221
+ return createRateLimitResponse(decision, normalized);
222
+ },
223
+
224
+ /**
225
+ * Add rate limit headers to successful response.
226
+ */
227
+ addHeaders(response: Response, decision: RateLimitDecision): Response {
228
+ return appendRateLimitHeaders(response, decision, normalized);
229
+ },
230
+ };
231
+ }
@@ -1,31 +1,31 @@
1
- import {
2
- compose as composeMiddleware,
3
- type ComposedHandler,
4
- type FinalHandler,
5
- } from "../middleware/compose";
6
- import type { Middleware } from "../middleware/define";
7
-
8
- export type { ComposedHandler, FinalHandler };
9
-
10
- export function buildRequestMiddlewareChain(
11
- middleware: Middleware[] | undefined
12
- ): ComposedHandler | undefined {
13
- return middleware && middleware.length > 0
14
- ? composeMiddleware(...middleware)
15
- : undefined;
16
- }
17
-
18
- export interface RunRequestMiddlewareOptions {
19
- req: Request;
20
- middlewareChain: ComposedHandler | undefined;
21
- finalHandler: FinalHandler;
22
- skipMiddleware?: boolean;
23
- }
24
-
25
- export async function runRequestMiddleware(
26
- options: RunRequestMiddlewareOptions
27
- ): Promise<Response | undefined> {
28
- const { req, middlewareChain, finalHandler, skipMiddleware = false } = options;
29
- if (skipMiddleware || !middlewareChain) return undefined;
30
- return middlewareChain(req, finalHandler);
31
- }
1
+ import {
2
+ compose as composeMiddleware,
3
+ type ComposedHandler,
4
+ type FinalHandler,
5
+ } from "../middleware/compose";
6
+ import type { Middleware } from "../middleware/define";
7
+
8
+ export type { ComposedHandler, FinalHandler };
9
+
10
+ export function buildRequestMiddlewareChain(
11
+ middleware: Middleware[] | undefined
12
+ ): ComposedHandler | undefined {
13
+ return middleware && middleware.length > 0
14
+ ? composeMiddleware(...middleware)
15
+ : undefined;
16
+ }
17
+
18
+ export interface RunRequestMiddlewareOptions {
19
+ req: Request;
20
+ middlewareChain: ComposedHandler | undefined;
21
+ finalHandler: FinalHandler;
22
+ skipMiddleware?: boolean;
23
+ }
24
+
25
+ export async function runRequestMiddleware(
26
+ options: RunRequestMiddlewareOptions
27
+ ): Promise<Response | undefined> {
28
+ const { req, middlewareChain, finalHandler, skipMiddleware = false } = options;
29
+ if (skipMiddleware || !middlewareChain) return undefined;
30
+ return middlewareChain(req, finalHandler);
31
+ }
@@ -157,10 +157,10 @@ describe("Wildcard Matching", () => {
157
157
 
158
158
  const result = router.match("/docs");
159
159
 
160
- expect(result).not.toBeNull();
161
- expect(result!.route.id).toBe("docs");
162
- expect(result!.params).toEqual({});
163
- });
160
+ expect(result).not.toBeNull();
161
+ expect(result!.route.id).toBe("docs");
162
+ expect(result!.params).toEqual({ path: "" });
163
+ });
164
164
 
165
165
  test("optional wildcard matches with remaining path", () => {
166
166
  const router = createRouter([
@@ -415,12 +415,10 @@ export class Router {
415
415
  route,
416
416
  };
417
417
 
418
- // Optional wildcard: 현재 노드도 매칭 가능하게 route 설정
419
- if (isOptional && !node.route) {
420
- node.route = route;
421
- }
422
- return;
423
- }
418
+ // Optional wildcard base paths are matched from wildcardConfig so
419
+ // the named wildcard param is still materialized as an empty string.
420
+ return;
421
+ }
424
422
 
425
423
  // Regular parameter: :param
426
424
  const paramName = seg.slice(1);
@@ -528,12 +526,12 @@ export class Router {
528
526
  if (node.wildcardConfig.optional) {
529
527
  if (this.debug) {
530
528
  console.log(`[Router] Optional wildcard match: ${node.wildcardConfig.route.id} with empty path`);
531
- }
532
- return {
533
- route: node.wildcardConfig.route,
534
- params,
535
- };
536
- }
529
+ }
530
+ return {
531
+ route: node.wildcardConfig.route,
532
+ params: { ...params, [node.wildcardConfig.name]: "" },
533
+ };
534
+ }
537
535
  // Non-optional wildcard: /files/:path* does NOT match /files
538
536
  if (this.debug) {
539
537
  console.log(`[Router] Wildcard policy: ${pathname} does not match non-optional wildcard`);