@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.
- package/package.json +9 -1
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/run-audit.ts +15 -15
- package/src/agent/__tests__/context.test.ts +49 -1
- package/src/agent/context.ts +535 -535
- package/src/agent/index.ts +6 -6
- package/src/agent/plan.ts +282 -282
- package/src/agent/repair.ts +171 -171
- package/src/agent/sync.ts +200 -200
- package/src/agent/types.ts +8 -0
- package/src/agent/verify.ts +100 -2
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__tests__/build-runner.ts +36 -16
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +20 -20
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -17
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/build.test.ts +136 -48
- package/src/bundler/build.ts +193 -122
- package/src/bundler/css.ts +42 -42
- package/src/bundler/manifest-schema.ts +21 -21
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/block-generated-imports.ts +13 -13
- package/src/bundler/types.ts +31 -31
- package/src/client/island.ts +79 -79
- package/src/config/validate.ts +1 -1
- package/src/contract/schema.ts +7 -0
- package/src/deploy/inference/context.ts +82 -82
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/error/formatter.ts +10 -1
- package/src/experimental/index.ts +10 -0
- package/src/filling/context.ts +17 -17
- package/src/filling/filling.ts +22 -1
- package/src/filling/index.ts +15 -1
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.ts +210 -210
- package/src/guard/check.ts +9 -9
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/index.ts +11 -11
- package/src/index.ts +0 -10
- package/src/internal/index.ts +25 -0
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +6 -6
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +84 -41
- package/src/router/client-entry.ts +156 -89
- package/src/router/fs-routes.test.ts +90 -0
- package/src/router/fs-routes.ts +37 -29
- package/src/router/fs-scanner.ts +81 -33
- package/src/router/fs-types.ts +8 -5
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +164 -103
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/image-feature.ts +15 -0
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/page-render-response.ts +208 -106
- package/src/runtime/rate-limit.ts +231 -0
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -12
- package/src/runtime/scheduler-lifecycle.ts +64 -0
- package/src/runtime/server.ts +148 -353
- package/src/runtime/ssr.ts +59 -59
- package/src/runtime/static-files.ts +289 -289
- package/src/runtime/streaming-ssr.ts +22 -22
- package/src/spec/schema.ts +4 -3
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
|
@@ -1,106 +1,208 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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([
|
package/src/runtime/router.ts
CHANGED
|
@@ -415,12 +415,10 @@ export class Router {
|
|
|
415
415
|
route,
|
|
416
416
|
};
|
|
417
417
|
|
|
418
|
-
// Optional wildcard
|
|
419
|
-
|
|
420
|
-
|
|
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`);
|