@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
|
@@ -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
|
+
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -78,14 +78,13 @@ import {
|
|
|
78
78
|
type ComposedHandler,
|
|
79
79
|
} from "./request-middleware";
|
|
80
80
|
import type { Middleware } from "../middleware/define";
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
import { setActiveSchedulerRegistration } from "../middleware/scheduler-cron";
|
|
81
|
+
import {
|
|
82
|
+
startRuntimeSchedulerLifecycle,
|
|
83
|
+
type RuntimeSchedulerOptions,
|
|
84
|
+
} from "./scheduler-lifecycle";
|
|
86
85
|
import { createFetchHandler } from "./handler";
|
|
87
86
|
import { wrapBunWebSocket, type WSHandlers, type WSUpgradeData } from "../filling/ws";
|
|
88
|
-
import {
|
|
87
|
+
import { maybeHandleImageFeatureRequest } from "./image-feature";
|
|
89
88
|
import {
|
|
90
89
|
serveStaticFile,
|
|
91
90
|
computeStrongEtag,
|
|
@@ -94,7 +93,7 @@ import {
|
|
|
94
93
|
} from "./static-files";
|
|
95
94
|
export { __clearStaticEtagCacheForTests } from "./static-files";
|
|
96
95
|
import { extractShellHtml, createPPRResponse } from "./ppr";
|
|
97
|
-
import { renderPageResponse } from "./page-render-response";
|
|
96
|
+
import { renderPageResponse, type InlineClientHydrationTarget } from "./page-render-response";
|
|
98
97
|
import { isRedirectResponse } from "./redirect";
|
|
99
98
|
import { isNotFoundResponse } from "./not-found";
|
|
100
99
|
import { newId } from "../id";
|
|
@@ -137,183 +136,19 @@ import type {
|
|
|
137
136
|
Translator,
|
|
138
137
|
} from "../i18n/types";
|
|
139
138
|
import type { MessageRegistry } from "../i18n/message-registry";
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
*/
|
|
154
|
-
trustProxy?: boolean;
|
|
155
|
-
/**
|
|
156
|
-
* 메모리 보호를 위한 최대 key 수
|
|
157
|
-
* - 초과 시 오래된 key부터 제거
|
|
158
|
-
*/
|
|
159
|
-
maxKeys?: number;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
interface NormalizedRateLimitOptions {
|
|
163
|
-
windowMs: number;
|
|
164
|
-
max: number;
|
|
165
|
-
message: string;
|
|
166
|
-
statusCode: number;
|
|
167
|
-
headers: boolean;
|
|
168
|
-
trustProxy: boolean;
|
|
169
|
-
maxKeys: number;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
interface RateLimitDecision {
|
|
173
|
-
allowed: boolean;
|
|
174
|
-
limit: number;
|
|
175
|
-
remaining: number;
|
|
176
|
-
resetAt: number;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
class MemoryRateLimiter {
|
|
180
|
-
private readonly store = new Map<string, { count: number; resetAt: number }>();
|
|
181
|
-
private lastCleanupAt = 0;
|
|
182
|
-
|
|
183
|
-
consume(req: Request, routeId: string, options: NormalizedRateLimitOptions): RateLimitDecision {
|
|
184
|
-
const now = Date.now();
|
|
185
|
-
this.maybeCleanup(now, options);
|
|
186
|
-
|
|
187
|
-
const key = `${this.getClientKey(req, options)}:${routeId}`;
|
|
188
|
-
const current = this.store.get(key);
|
|
189
|
-
|
|
190
|
-
if (!current || current.resetAt <= now) {
|
|
191
|
-
const resetAt = now + options.windowMs;
|
|
192
|
-
this.store.set(key, { count: 1, resetAt });
|
|
193
|
-
this.enforceMaxKeys(options.maxKeys);
|
|
194
|
-
return { allowed: true, limit: options.max, remaining: Math.max(0, options.max - 1), resetAt };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
current.count += 1;
|
|
198
|
-
this.store.set(key, current);
|
|
199
|
-
|
|
200
|
-
return {
|
|
201
|
-
allowed: current.count <= options.max,
|
|
202
|
-
limit: options.max,
|
|
203
|
-
remaining: Math.max(0, options.max - current.count),
|
|
204
|
-
resetAt: current.resetAt,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
private maybeCleanup(now: number, options: NormalizedRateLimitOptions): void {
|
|
209
|
-
if (now - this.lastCleanupAt < Math.max(1_000, options.windowMs)) {
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
this.lastCleanupAt = now;
|
|
214
|
-
for (const [key, entry] of this.store.entries()) {
|
|
215
|
-
if (entry.resetAt <= now) {
|
|
216
|
-
this.store.delete(key);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
private enforceMaxKeys(maxKeys: number): void {
|
|
222
|
-
while (this.store.size > maxKeys) {
|
|
223
|
-
const oldestKey = this.store.keys().next().value;
|
|
224
|
-
if (!oldestKey) break;
|
|
225
|
-
this.store.delete(oldestKey);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
private getClientKey(req: Request, options: NormalizedRateLimitOptions): string {
|
|
230
|
-
const candidates = [
|
|
231
|
-
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
|
|
232
|
-
req.headers.get("x-real-ip")?.trim(),
|
|
233
|
-
req.headers.get("cf-connecting-ip")?.trim(),
|
|
234
|
-
req.headers.get("true-client-ip")?.trim(),
|
|
235
|
-
req.headers.get("fly-client-ip")?.trim(),
|
|
236
|
-
];
|
|
237
|
-
|
|
238
|
-
for (const candidate of candidates) {
|
|
239
|
-
if (candidate) {
|
|
240
|
-
const sanitized = candidate.slice(0, 64);
|
|
241
|
-
// trustProxy: false면 경고를 위해 prefix 추가 (spoofing 가능)
|
|
242
|
-
return options.trustProxy ? sanitized : `unverified:${sanitized}`;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// 헤더가 전혀 없는 경우만 fallback (로컬 개발 환경)
|
|
247
|
-
return "default";
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function normalizeRateLimitOptions(options: boolean | RateLimitOptions | undefined): NormalizedRateLimitOptions | false {
|
|
252
|
-
if (!options) return false;
|
|
253
|
-
if (options === true) {
|
|
254
|
-
return {
|
|
255
|
-
windowMs: 60_000,
|
|
256
|
-
max: 100,
|
|
257
|
-
message: "Too Many Requests",
|
|
258
|
-
statusCode: 429,
|
|
259
|
-
headers: true,
|
|
260
|
-
trustProxy: false,
|
|
261
|
-
maxKeys: 10_000,
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const windowMs = Number.isFinite(options.windowMs) ? Math.max(1_000, options.windowMs!) : 60_000;
|
|
266
|
-
const max = Number.isFinite(options.max) ? Math.max(1, Math.floor(options.max!)) : 100;
|
|
267
|
-
const statusCode = Number.isFinite(options.statusCode)
|
|
268
|
-
? Math.min(599, Math.max(400, Math.floor(options.statusCode!)))
|
|
269
|
-
: 429;
|
|
270
|
-
const maxKeys = Number.isFinite(options.maxKeys)
|
|
271
|
-
? Math.max(100, Math.floor(options.maxKeys!))
|
|
272
|
-
: 10_000;
|
|
273
|
-
|
|
274
|
-
return {
|
|
275
|
-
windowMs,
|
|
276
|
-
max,
|
|
277
|
-
message: options.message ?? "Too Many Requests",
|
|
278
|
-
statusCode,
|
|
279
|
-
headers: options.headers ?? true,
|
|
280
|
-
trustProxy: options.trustProxy ?? false,
|
|
281
|
-
maxKeys,
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function appendRateLimitHeaders(response: Response, decision: RateLimitDecision, options: NormalizedRateLimitOptions): Response {
|
|
286
|
-
if (!options.headers) return response;
|
|
287
|
-
|
|
288
|
-
const headers = new Headers(response.headers);
|
|
289
|
-
const retryAfterSec = Math.max(1, Math.ceil((decision.resetAt - Date.now()) / 1000));
|
|
290
|
-
|
|
291
|
-
headers.set("X-RateLimit-Limit", String(decision.limit));
|
|
292
|
-
headers.set("X-RateLimit-Remaining", String(decision.remaining));
|
|
293
|
-
headers.set("X-RateLimit-Reset", String(Math.floor(decision.resetAt / 1000)));
|
|
294
|
-
headers.set("Retry-After", String(retryAfterSec));
|
|
295
|
-
|
|
296
|
-
return new Response(response.body, {
|
|
297
|
-
status: response.status,
|
|
298
|
-
statusText: response.statusText,
|
|
299
|
-
headers,
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function createRateLimitResponse(decision: RateLimitDecision, options: NormalizedRateLimitOptions): Response {
|
|
304
|
-
const response = Response.json(
|
|
305
|
-
{
|
|
306
|
-
error: "rate_limit_exceeded",
|
|
307
|
-
message: options.message,
|
|
308
|
-
limit: decision.limit,
|
|
309
|
-
remaining: decision.remaining,
|
|
310
|
-
retryAfter: Math.max(1, Math.ceil((decision.resetAt - Date.now()) / 1000)),
|
|
311
|
-
},
|
|
312
|
-
{ status: options.statusCode }
|
|
313
|
-
);
|
|
314
|
-
|
|
315
|
-
return appendRateLimitHeaders(response, decision, options);
|
|
316
|
-
}
|
|
139
|
+
import {
|
|
140
|
+
appendRateLimitHeaders,
|
|
141
|
+
createRateLimitResponse,
|
|
142
|
+
MemoryRateLimiter,
|
|
143
|
+
normalizeRateLimitOptions,
|
|
144
|
+
type NormalizedRateLimitOptions,
|
|
145
|
+
type RateLimitOptions,
|
|
146
|
+
} from "./rate-limit";
|
|
147
|
+
export {
|
|
148
|
+
createRateLimiter,
|
|
149
|
+
type RateLimitDecision,
|
|
150
|
+
type RateLimitOptions,
|
|
151
|
+
} from "./rate-limit";
|
|
317
152
|
|
|
318
153
|
// ========== Server Options ==========
|
|
319
154
|
export interface ServerOptions {
|
|
@@ -547,10 +382,7 @@ export interface ServerOptions {
|
|
|
547
382
|
*
|
|
548
383
|
* Typically threaded from `ManduConfig.scheduler`.
|
|
549
384
|
*/
|
|
550
|
-
scheduler?:
|
|
551
|
-
jobs?: CronDef[];
|
|
552
|
-
disabled?: boolean;
|
|
553
|
-
};
|
|
385
|
+
scheduler?: RuntimeSchedulerOptions;
|
|
554
386
|
/**
|
|
555
387
|
* Phase 18.μ — first-class i18n. Threaded from `ManduConfig.i18n`.
|
|
556
388
|
*
|
|
@@ -647,8 +479,12 @@ export type ErrorLoader = () => Promise<{ default: ErrorComponent }>;
|
|
|
647
479
|
* - component: React 컴포넌트
|
|
648
480
|
* - filling: Slot의 ManduFilling 인스턴스 (loader 포함)
|
|
649
481
|
*/
|
|
650
|
-
export interface PageRegistration {
|
|
651
|
-
component: React.ComponentType<{
|
|
482
|
+
export interface PageRegistration {
|
|
483
|
+
component: React.ComponentType<{
|
|
484
|
+
params: Record<string, string>;
|
|
485
|
+
loaderData?: unknown;
|
|
486
|
+
__manduHydration?: InlineClientHydrationTarget;
|
|
487
|
+
}>;
|
|
652
488
|
filling?: ManduFilling<unknown>;
|
|
653
489
|
/** #186: page 모듈의 static `metadata` export (선택) */
|
|
654
490
|
metadata?: Metadata;
|
|
@@ -670,15 +506,20 @@ export type PageHandler = () => Promise<PageRegistration>;
|
|
|
670
506
|
*/
|
|
671
507
|
export type MetadataHandler = () => Promise<unknown>;
|
|
672
508
|
|
|
673
|
-
export interface AppContext {
|
|
674
|
-
routeId: string;
|
|
675
|
-
url: string;
|
|
676
|
-
params: Record<string, string>;
|
|
677
|
-
/** SSR loader에서 로드한 데이터 */
|
|
678
|
-
loaderData?: unknown;
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
509
|
+
export interface AppContext {
|
|
510
|
+
routeId: string;
|
|
511
|
+
url: string;
|
|
512
|
+
params: Record<string, string>;
|
|
513
|
+
/** SSR loader에서 로드한 데이터 */
|
|
514
|
+
loaderData?: unknown;
|
|
515
|
+
__manduHydration?: InlineClientHydrationTarget;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
type RouteComponent = (props: {
|
|
519
|
+
params: Record<string, string>;
|
|
520
|
+
loaderData?: unknown;
|
|
521
|
+
__manduHydration?: InlineClientHydrationTarget;
|
|
522
|
+
}) => React.ReactElement;
|
|
682
523
|
type CreateAppFn = (context: AppContext) => React.ReactElement;
|
|
683
524
|
|
|
684
525
|
// ========== Server Registry (인스턴스별 분리) ==========
|
|
@@ -1254,8 +1095,8 @@ async function wrapWithLayouts(
|
|
|
1254
1095
|
}
|
|
1255
1096
|
|
|
1256
1097
|
// Default createApp implementation (registry 기반)
|
|
1257
|
-
function createDefaultAppFactory(registry: ServerRegistry) {
|
|
1258
|
-
return function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
1098
|
+
function createDefaultAppFactory(registry: ServerRegistry) {
|
|
1099
|
+
return function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
1259
1100
|
const Component = registry.routeComponents.get(context.routeId);
|
|
1260
1101
|
|
|
1261
1102
|
if (!Component) {
|
|
@@ -1265,14 +1106,53 @@ function createDefaultAppFactory(registry: ServerRegistry) {
|
|
|
1265
1106
|
);
|
|
1266
1107
|
}
|
|
1267
1108
|
|
|
1268
|
-
return React.createElement(Component, {
|
|
1269
|
-
params: context.params,
|
|
1270
|
-
loaderData: context.loaderData,
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1109
|
+
return React.createElement(Component, {
|
|
1110
|
+
params: context.params,
|
|
1111
|
+
loaderData: context.loaderData,
|
|
1112
|
+
__manduHydration: context.__manduHydration,
|
|
1113
|
+
});
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async function resolveInlineClientHydrationTarget(
|
|
1118
|
+
route: {
|
|
1119
|
+
id: string;
|
|
1120
|
+
clientModule?: string;
|
|
1121
|
+
clientExportName?: string;
|
|
1122
|
+
hydration?: HydrationConfig;
|
|
1123
|
+
},
|
|
1124
|
+
rootDir: string,
|
|
1125
|
+
src: string,
|
|
1126
|
+
): Promise<InlineClientHydrationTarget | undefined> {
|
|
1127
|
+
if (!route.clientModule || !route.clientExportName || !src) {
|
|
1128
|
+
return undefined;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
try {
|
|
1132
|
+
const module = await import(path.join(rootDir, route.clientModule));
|
|
1133
|
+
const exportName = route.clientExportName;
|
|
1134
|
+
const component = exportName === "default"
|
|
1135
|
+
? module.default
|
|
1136
|
+
: module[exportName] ?? module.default;
|
|
1137
|
+
|
|
1138
|
+
if (!component) return undefined;
|
|
1139
|
+
|
|
1140
|
+
return {
|
|
1141
|
+
routeId: route.id,
|
|
1142
|
+
src,
|
|
1143
|
+
priority: route.hydration?.priority ?? "visible",
|
|
1144
|
+
component,
|
|
1145
|
+
};
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
console.warn(
|
|
1148
|
+
`[Mandu] Failed to resolve inline client hydration target for "${route.id}":`,
|
|
1149
|
+
error,
|
|
1150
|
+
);
|
|
1151
|
+
return undefined;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
|
|
1276
1156
|
|
|
1277
1157
|
// ========== Request Handler ==========
|
|
1278
1158
|
|
|
@@ -2185,8 +2065,19 @@ function extractTitleText(titleHtml: string): string | null {
|
|
|
2185
2065
|
/**
|
|
2186
2066
|
* SSR 렌더링 (Streaming/Non-streaming)
|
|
2187
2067
|
*/
|
|
2188
|
-
async function renderPageSSR(
|
|
2189
|
-
route: {
|
|
2068
|
+
async function renderPageSSR(
|
|
2069
|
+
route: {
|
|
2070
|
+
id: string;
|
|
2071
|
+
pattern: string;
|
|
2072
|
+
layoutChain?: string[];
|
|
2073
|
+
streaming?: boolean;
|
|
2074
|
+
hydration?: HydrationConfig;
|
|
2075
|
+
errorModule?: string;
|
|
2076
|
+
loadingModule?: string;
|
|
2077
|
+
notFoundModule?: string;
|
|
2078
|
+
clientModule?: string;
|
|
2079
|
+
clientExportName?: string;
|
|
2080
|
+
},
|
|
2190
2081
|
params: Record<string, string>,
|
|
2191
2082
|
loaderData: unknown,
|
|
2192
2083
|
url: string,
|
|
@@ -2196,15 +2087,30 @@ async function renderPageSSR(
|
|
|
2196
2087
|
): Promise<Result<Response>> {
|
|
2197
2088
|
const settings = registry.settings;
|
|
2198
2089
|
const defaultAppCreator = createDefaultAppFactory(registry);
|
|
2199
|
-
const appCreator = registry.createAppFn || defaultAppCreator;
|
|
2200
|
-
|
|
2201
|
-
try {
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2090
|
+
const appCreator = registry.createAppFn || defaultAppCreator;
|
|
2091
|
+
|
|
2092
|
+
try {
|
|
2093
|
+
const useStreaming = route.streaming !== undefined
|
|
2094
|
+
? route.streaming
|
|
2095
|
+
: settings.streaming;
|
|
2096
|
+
const needsIslandHydration = !!(
|
|
2097
|
+
route.hydration &&
|
|
2098
|
+
route.hydration.strategy !== "none" &&
|
|
2099
|
+
settings.bundleManifest
|
|
2100
|
+
);
|
|
2101
|
+
const routeBundle = settings.bundleManifest?.bundles[route.id];
|
|
2102
|
+
const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
|
|
2103
|
+
const inlineClientHydration = needsIslandHydration && !useStreaming
|
|
2104
|
+
? await resolveInlineClientHydrationTarget(route, settings.rootDir, bundleSrc)
|
|
2105
|
+
: undefined;
|
|
2106
|
+
|
|
2107
|
+
let app = appCreator({
|
|
2108
|
+
routeId: route.id,
|
|
2109
|
+
url,
|
|
2110
|
+
params,
|
|
2111
|
+
loaderData,
|
|
2112
|
+
__manduHydration: inlineClientHydration,
|
|
2113
|
+
});
|
|
2208
2114
|
|
|
2209
2115
|
// Phase 18.β — per-route Suspense wrapper (Next.js `loading.tsx` parity).
|
|
2210
2116
|
// If the route declared a `loading.tsx`, wrap the page element in a
|
|
@@ -2232,20 +2138,13 @@ async function renderPageSSR(
|
|
|
2232
2138
|
|
|
2233
2139
|
// Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
|
|
2234
2140
|
// 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
|
|
2235
|
-
const
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0;
|
|
2243
|
-
|
|
2244
|
-
if (needsIslandHydration && !needsIslandWrap && settings.isDev) {
|
|
2245
|
-
console.warn(
|
|
2246
|
-
`[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
|
|
2247
|
-
`Run mandu build/generate and ensure the route has a clientModule.`,
|
|
2248
|
-
);
|
|
2141
|
+
const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0 && !inlineClientHydration;
|
|
2142
|
+
|
|
2143
|
+
if (needsIslandHydration && bundleSrc.length === 0 && settings.isDev) {
|
|
2144
|
+
console.warn(
|
|
2145
|
+
`[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
|
|
2146
|
+
`Run mandu build/generate and ensure the route has a clientModule.`,
|
|
2147
|
+
);
|
|
2249
2148
|
}
|
|
2250
2149
|
|
|
2251
2150
|
if (needsIslandWrap) {
|
|
@@ -2266,12 +2165,7 @@ async function renderPageSSR(
|
|
|
2266
2165
|
// #186: layout chain + page metadata 병합
|
|
2267
2166
|
const builtMeta = await buildSSRMetadata(route, params, url, registry);
|
|
2268
2167
|
|
|
2269
|
-
|
|
2270
|
-
const useStreaming = route.streaming !== undefined
|
|
2271
|
-
? route.streaming
|
|
2272
|
-
: settings.streaming;
|
|
2273
|
-
|
|
2274
|
-
const pageResponse = await renderPageResponse({
|
|
2168
|
+
const pageResponse = await renderPageResponse({
|
|
2275
2169
|
app,
|
|
2276
2170
|
useStreaming,
|
|
2277
2171
|
title: builtMeta.title,
|
|
@@ -2282,11 +2176,12 @@ async function renderPageSSR(
|
|
|
2282
2176
|
routePattern: route.pattern,
|
|
2283
2177
|
layoutChain: route.layoutChain,
|
|
2284
2178
|
hydration: route.hydration,
|
|
2285
|
-
bundleManifest: settings.bundleManifest,
|
|
2286
|
-
loaderData,
|
|
2287
|
-
cssPath: settings.cssPath,
|
|
2288
|
-
islandPreWrapped: needsIslandWrap,
|
|
2289
|
-
|
|
2179
|
+
bundleManifest: settings.bundleManifest,
|
|
2180
|
+
loaderData,
|
|
2181
|
+
cssPath: settings.cssPath,
|
|
2182
|
+
islandPreWrapped: needsIslandWrap,
|
|
2183
|
+
inlineClientHydration,
|
|
2184
|
+
transitions: settings.transitions,
|
|
2290
2185
|
prefetch: settings.prefetch,
|
|
2291
2186
|
spa: settings.spa,
|
|
2292
2187
|
devtools: settings.devtools,
|
|
@@ -3367,8 +3262,12 @@ async function handleRequestInternal(
|
|
|
3367
3262
|
}
|
|
3368
3263
|
|
|
3369
3264
|
// 1.5. Image optimization handler (/_mandu/image)
|
|
3370
|
-
if (
|
|
3371
|
-
const imageResponse = await
|
|
3265
|
+
if (pathname === "/_mandu/image") {
|
|
3266
|
+
const imageResponse = await maybeHandleImageFeatureRequest(req, {
|
|
3267
|
+
edge: settings.edge,
|
|
3268
|
+
rootDir: settings.rootDir,
|
|
3269
|
+
publicDir: settings.publicDir,
|
|
3270
|
+
});
|
|
3372
3271
|
if (imageResponse) return ok(imageResponse);
|
|
3373
3272
|
}
|
|
3374
3273
|
|
|
@@ -4149,43 +4048,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
4149
4048
|
}
|
|
4150
4049
|
}
|
|
4151
4050
|
|
|
4152
|
-
|
|
4153
|
-
// Boot the scheduler AFTER the HTTP listener is live so a malformed cron
|
|
4154
|
-
// expression (caught by validateCronExpression) surfaces alongside the
|
|
4155
|
-
// other boot errors rather than aborting the server. `startServer` is
|
|
4156
|
-
// synchronous by contract, so we use the already-imported scheduler
|
|
4157
|
-
// module rather than `await import`.
|
|
4158
|
-
let schedulerRegistration: CronRegistration | null = null;
|
|
4159
|
-
const jobDefs = schedulerOption?.jobs ?? [];
|
|
4160
|
-
const schedulerDisabled = schedulerOption?.disabled === true;
|
|
4161
|
-
if (jobDefs.length > 0 && !schedulerDisabled) {
|
|
4162
|
-
try {
|
|
4163
|
-
schedulerRegistration = schedulerDefineCron(jobDefs);
|
|
4164
|
-
schedulerRegistration.start();
|
|
4165
|
-
setActiveSchedulerRegistration(schedulerRegistration);
|
|
4166
|
-
const bunJobCount = Object.keys(schedulerRegistration.status()).filter(
|
|
4167
|
-
(name) => {
|
|
4168
|
-
const def = jobDefs.find((j) => j.name === name);
|
|
4169
|
-
const runOn = def?.runOn && def.runOn.length > 0 ? def.runOn : ["bun", "workers"];
|
|
4170
|
-
return runOn.includes("bun");
|
|
4171
|
-
},
|
|
4172
|
-
).length;
|
|
4173
|
-
console.log(
|
|
4174
|
-
`⏰ Scheduler: ${bunJobCount} cron job(s) registered on Bun runtime` +
|
|
4175
|
-
(jobDefs.length !== bunJobCount
|
|
4176
|
-
? ` (${jobDefs.length - bunJobCount} workers-only — see wrangler.toml)`
|
|
4177
|
-
: ""),
|
|
4178
|
-
);
|
|
4179
|
-
} catch (err) {
|
|
4180
|
-
// Scheduler failures MUST NOT crash the server — a bad cron string is
|
|
4181
|
-
// a developer error, but the HTTP surface should keep serving. Log
|
|
4182
|
-
// loudly and leave the registration null.
|
|
4183
|
-
console.error(
|
|
4184
|
-
"❌ [scheduler] failed to start — HTTP server continues without cron jobs:",
|
|
4185
|
-
err instanceof Error ? err.message : err,
|
|
4186
|
-
);
|
|
4187
|
-
}
|
|
4188
|
-
}
|
|
4051
|
+
const schedulerLifecycle = startRuntimeSchedulerLifecycle(schedulerOption);
|
|
4189
4052
|
|
|
4190
4053
|
return {
|
|
4191
4054
|
server,
|
|
@@ -4199,20 +4062,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
4199
4062
|
registry.devtoolsAdapter?.stop();
|
|
4200
4063
|
registry.devtoolsAdapter = null;
|
|
4201
4064
|
registry.kitchen = null;
|
|
4202
|
-
|
|
4203
|
-
// synchronous for backwards compatibility with existing consumers.
|
|
4204
|
-
// Tests that need to await drain can reach for `registration.stop()`
|
|
4205
|
-
// directly; `server.stop()` triggers shutdown but doesn't block on
|
|
4206
|
-
// in-flight cron handler completion here.
|
|
4207
|
-
if (schedulerRegistration) {
|
|
4208
|
-
const reg = schedulerRegistration;
|
|
4209
|
-
schedulerRegistration = null;
|
|
4210
|
-
void reg.stop()
|
|
4211
|
-
.then(() => setActiveSchedulerRegistration(null))
|
|
4212
|
-
.catch((err) => {
|
|
4213
|
-
console.error("[scheduler] shutdown error:", err);
|
|
4214
|
-
});
|
|
4215
|
-
}
|
|
4065
|
+
schedulerLifecycle.stop();
|
|
4216
4066
|
void server.stop();
|
|
4217
4067
|
},
|
|
4218
4068
|
};
|
|
@@ -4341,58 +4191,3 @@ export function createAppFetchHandler(
|
|
|
4341
4191
|
handleRequest,
|
|
4342
4192
|
});
|
|
4343
4193
|
}
|
|
4344
|
-
|
|
4345
|
-
// ========== Rate Limiting Public API ==========
|
|
4346
|
-
|
|
4347
|
-
/**
|
|
4348
|
-
* Rate limiter 인스턴스 생성
|
|
4349
|
-
* API 핸들러에서 직접 사용 가능
|
|
4350
|
-
*
|
|
4351
|
-
* @example
|
|
4352
|
-
* ```typescript
|
|
4353
|
-
* import { createRateLimiter } from '@mandujs/core/runtime/server';
|
|
4354
|
-
*
|
|
4355
|
-
* const limiter = createRateLimiter({ max: 5, windowMs: 60000 });
|
|
4356
|
-
*
|
|
4357
|
-
* export async function POST(req: Request) {
|
|
4358
|
-
* const decision = limiter.check(req, 'my-api-route');
|
|
4359
|
-
* if (!decision.allowed) {
|
|
4360
|
-
* return limiter.createResponse(decision);
|
|
4361
|
-
* }
|
|
4362
|
-
* // ... 정상 로직
|
|
4363
|
-
* }
|
|
4364
|
-
* ```
|
|
4365
|
-
*/
|
|
4366
|
-
export function createRateLimiter(options?: RateLimitOptions) {
|
|
4367
|
-
const normalized = normalizeRateLimitOptions(options || true);
|
|
4368
|
-
if (!normalized) {
|
|
4369
|
-
throw new Error('Rate limiter options cannot be false');
|
|
4370
|
-
}
|
|
4371
|
-
|
|
4372
|
-
const limiter = new MemoryRateLimiter();
|
|
4373
|
-
|
|
4374
|
-
return {
|
|
4375
|
-
/**
|
|
4376
|
-
* Rate limit 체크
|
|
4377
|
-
* @param req Request 객체 (IP 추출용)
|
|
4378
|
-
* @param routeId 라우트 식별자 (동일 IP라도 라우트별로 독립적인 limit)
|
|
4379
|
-
*/
|
|
4380
|
-
check(req: Request, routeId: string): RateLimitDecision {
|
|
4381
|
-
return limiter.consume(req, routeId, normalized);
|
|
4382
|
-
},
|
|
4383
|
-
|
|
4384
|
-
/**
|
|
4385
|
-
* Rate limit 초과 시 429 응답 생성
|
|
4386
|
-
*/
|
|
4387
|
-
createResponse(decision: RateLimitDecision): Response {
|
|
4388
|
-
return createRateLimitResponse(decision, normalized);
|
|
4389
|
-
},
|
|
4390
|
-
|
|
4391
|
-
/**
|
|
4392
|
-
* 정상 응답에 Rate limit 헤더 추가
|
|
4393
|
-
*/
|
|
4394
|
-
addHeaders(response: Response, decision: RateLimitDecision): Response {
|
|
4395
|
-
return appendRateLimitHeaders(response, decision, normalized);
|
|
4396
|
-
},
|
|
4397
|
-
};
|
|
4398
|
-
}
|