@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
@@ -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
+ }
@@ -78,14 +78,13 @@ import {
78
78
  type ComposedHandler,
79
79
  } from "./request-middleware";
80
80
  import type { Middleware } from "../middleware/define";
81
- // Phase 18.λ — scheduler wiring (statically imported so `startServer` stays
82
- // synchronous; the cost of unused code is trivial — `defineCron` is a thin
83
- // wrapper around `Bun.cron`).
84
- import { defineCron as schedulerDefineCron, type CronDef, type CronRegistration } from "../scheduler";
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 { handleImageRequest } from "./image-handler";
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
- export interface RateLimitOptions {
142
- windowMs?: number;
143
- max?: number;
144
- message?: string;
145
- statusCode?: number;
146
- headers?: boolean;
147
- /**
148
- * Reverse proxy 헤더를 신뢰할지 여부
149
- * - false(기본): X-Forwarded-For 등을 읽지만 spoofing 가능성을 표시
150
- * - true: 전달된 클라이언트 IP를 완전히 신뢰
151
- * 주의: trustProxy: false여도 클라이언트 구분을 위해 헤더를 사용하므로
152
- * IP spoofing이 가능합니다. 신뢰할 수 있는 프록시 뒤에서만 사용하세요.
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<{ params: Record<string, string>; loaderData?: unknown }>;
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
- type RouteComponent = (props: { params: Record<string, string>; loaderData?: unknown }) => React.ReactElement;
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
- const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
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: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig; errorModule?: string; loadingModule?: string; notFoundModule?: string },
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
- let app = appCreator({
2203
- routeId: route.id,
2204
- url,
2205
- params,
2206
- loaderData,
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 needsIslandHydration = !!(
2236
- route.hydration &&
2237
- route.hydration.strategy !== "none" &&
2238
- settings.bundleManifest
2239
- );
2240
- const routeBundle = settings.bundleManifest?.bundles[route.id];
2241
- const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
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
- // Streaming SSR 모드 결정
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
- transitions: settings.transitions,
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 (!settings.edge && pathname === "/_mandu/image") {
3371
- const imageResponse = await handleImageRequest(req, settings.rootDir, settings.publicDir);
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
- // ─── Phase 18.λ — declarative cron scheduler ────────────────────────────
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
- // Fire-and-forget the async scheduler drain so `stop()` stays
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
- }