@mandujs/core 0.54.12 → 0.54.13
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 +5 -4
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +20 -20
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/build.test.ts +66 -15
- package/src/bundler/build.ts +165 -103
- 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 +69 -33
- package/src/router/client-entry.ts +134 -74
- package/src/router/fs-routes.ts +24 -22
- package/src/router/fs-scanner.ts +21 -17
- 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 +103 -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 +106 -106
- package/src/runtime/rate-limit.ts +231 -0
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/scheduler-lifecycle.ts +64 -0
- package/src/runtime/server.ts +27 -295
- 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,106 @@
|
|
|
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 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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { CronDef, CronRegistration } from "../scheduler";
|
|
2
|
+
import type * as SchedulerModule from "../scheduler";
|
|
3
|
+
import type * as SchedulerCronMiddlewareModule from "../middleware/scheduler-cron";
|
|
4
|
+
|
|
5
|
+
export interface RuntimeSchedulerOptions {
|
|
6
|
+
jobs?: CronDef[];
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RuntimeSchedulerLifecycle {
|
|
11
|
+
stop(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function startRuntimeSchedulerLifecycle(
|
|
15
|
+
schedulerOption: RuntimeSchedulerOptions | undefined,
|
|
16
|
+
): RuntimeSchedulerLifecycle {
|
|
17
|
+
let schedulerRegistration: CronRegistration | null = null;
|
|
18
|
+
let clearActiveRegistration = (): void => {};
|
|
19
|
+
|
|
20
|
+
const jobDefs = schedulerOption?.jobs ?? [];
|
|
21
|
+
const schedulerDisabled = schedulerOption?.disabled === true;
|
|
22
|
+
|
|
23
|
+
if (jobDefs.length > 0 && !schedulerDisabled) {
|
|
24
|
+
try {
|
|
25
|
+
const { defineCron } = require("../scheduler") as typeof SchedulerModule;
|
|
26
|
+
const { setActiveSchedulerRegistration } = require("../middleware/scheduler-cron") as typeof SchedulerCronMiddlewareModule;
|
|
27
|
+
schedulerRegistration = defineCron(jobDefs);
|
|
28
|
+
schedulerRegistration.start();
|
|
29
|
+
setActiveSchedulerRegistration(schedulerRegistration);
|
|
30
|
+
clearActiveRegistration = () => setActiveSchedulerRegistration(null);
|
|
31
|
+
|
|
32
|
+
const bunJobCount = Object.keys(schedulerRegistration.status()).filter((name) => {
|
|
33
|
+
const def = jobDefs.find((job) => job.name === name);
|
|
34
|
+
const runOn = def?.runOn && def.runOn.length > 0 ? def.runOn : ["bun", "workers"];
|
|
35
|
+
return runOn.includes("bun");
|
|
36
|
+
}).length;
|
|
37
|
+
|
|
38
|
+
console.log(
|
|
39
|
+
`⏰ Scheduler: ${bunJobCount} cron job(s) registered on Bun runtime` +
|
|
40
|
+
(jobDefs.length !== bunJobCount
|
|
41
|
+
? ` (${jobDefs.length - bunJobCount} workers-only — see wrangler.toml)`
|
|
42
|
+
: ""),
|
|
43
|
+
);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(
|
|
46
|
+
"❌ [scheduler] failed to start — HTTP server continues without cron jobs:",
|
|
47
|
+
err instanceof Error ? err.message : err,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
stop(): void {
|
|
54
|
+
if (!schedulerRegistration) return;
|
|
55
|
+
const reg = schedulerRegistration;
|
|
56
|
+
schedulerRegistration = null;
|
|
57
|
+
void reg.stop()
|
|
58
|
+
.then(() => clearActiveRegistration())
|
|
59
|
+
.catch((err) => {
|
|
60
|
+
console.error("[scheduler] shutdown error:", err);
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|