@mandujs/core 0.54.11 → 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.
Files changed (76) hide show
  1. package/package.json +208 -200
  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 +5 -4
  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/analyzer.ts +15 -15
  17. package/src/bundler/build.test.ts +73 -7
  18. package/src/bundler/build.ts +139 -31
  19. package/src/bundler/css.ts +42 -42
  20. package/src/bundler/manifest-schema.ts +21 -21
  21. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  22. package/src/bundler/plugins/block-generated-imports.ts +13 -13
  23. package/src/bundler/types.ts +31 -31
  24. package/src/client/island.ts +79 -79
  25. package/src/config/validate.ts +1 -1
  26. package/src/contract/schema.ts +7 -0
  27. package/src/deploy/inference/context.ts +82 -82
  28. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  29. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  30. package/src/error/formatter.ts +10 -1
  31. package/src/experimental/index.ts +10 -0
  32. package/src/filling/context.ts +17 -17
  33. package/src/filling/filling.ts +22 -1
  34. package/src/filling/index.ts +15 -1
  35. package/src/generator/generate.ts +30 -30
  36. package/src/generator/index.ts +3 -3
  37. package/src/generator/templates.ts +210 -210
  38. package/src/guard/check.ts +9 -9
  39. package/src/guard/config-guard.ts +13 -13
  40. package/src/guard/fs-routes-policy.ts +51 -51
  41. package/src/guard/index.ts +11 -11
  42. package/src/index.ts +0 -10
  43. package/src/internal/index.ts +25 -0
  44. package/src/kitchen/api/file-api.ts +11 -11
  45. package/src/report/index.ts +1 -1
  46. package/src/resource/__tests__/generator.test.ts +6 -6
  47. package/src/resource/__tests__/schema.test.ts +14 -14
  48. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  49. package/src/resource/ddl/emit.ts +146 -146
  50. package/src/resource/generator-schema.ts +11 -11
  51. package/src/resource/generators/slot.ts +72 -72
  52. package/src/resource/schema.ts +21 -21
  53. package/src/router/client-entry.test.ts +69 -33
  54. package/src/router/client-entry.ts +134 -74
  55. package/src/router/fs-routes.ts +24 -22
  56. package/src/router/fs-scanner.ts +21 -17
  57. package/src/router/fs-types.ts +8 -5
  58. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  59. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  60. package/src/runtime/__tests__/page-render-response.test.ts +103 -103
  61. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  62. package/src/runtime/devtools-adapter.ts +68 -68
  63. package/src/runtime/escape.ts +34 -34
  64. package/src/runtime/image-feature.ts +15 -0
  65. package/src/runtime/observability-lifecycle.ts +290 -290
  66. package/src/runtime/page-render-response.ts +106 -106
  67. package/src/runtime/rate-limit.ts +231 -0
  68. package/src/runtime/request-middleware.ts +31 -31
  69. package/src/runtime/scheduler-lifecycle.ts +64 -0
  70. package/src/runtime/server.ts +27 -295
  71. package/src/runtime/ssr.ts +59 -59
  72. package/src/runtime/static-files.ts +289 -289
  73. package/src/runtime/streaming-ssr.ts +22 -22
  74. package/src/spec/schema.ts +4 -3
  75. package/src/watcher/__tests__/watcher.test.ts +59 -59
  76. package/src/watcher/watcher.ts +61 -61
@@ -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,
@@ -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
  *
@@ -3367,8 +3199,12 @@ async function handleRequestInternal(
3367
3199
  }
3368
3200
 
3369
3201
  // 1.5. Image optimization handler (/_mandu/image)
3370
- if (!settings.edge && pathname === "/_mandu/image") {
3371
- const imageResponse = await handleImageRequest(req, settings.rootDir, settings.publicDir);
3202
+ if (pathname === "/_mandu/image") {
3203
+ const imageResponse = await maybeHandleImageFeatureRequest(req, {
3204
+ edge: settings.edge,
3205
+ rootDir: settings.rootDir,
3206
+ publicDir: settings.publicDir,
3207
+ });
3372
3208
  if (imageResponse) return ok(imageResponse);
3373
3209
  }
3374
3210
 
@@ -4149,43 +3985,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4149
3985
  }
4150
3986
  }
4151
3987
 
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
- }
3988
+ const schedulerLifecycle = startRuntimeSchedulerLifecycle(schedulerOption);
4189
3989
 
4190
3990
  return {
4191
3991
  server,
@@ -4199,20 +3999,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4199
3999
  registry.devtoolsAdapter?.stop();
4200
4000
  registry.devtoolsAdapter = null;
4201
4001
  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
- }
4002
+ schedulerLifecycle.stop();
4216
4003
  void server.stop();
4217
4004
  },
4218
4005
  };
@@ -4341,58 +4128,3 @@ export function createAppFetchHandler(
4341
4128
  handleRequest,
4342
4129
  });
4343
4130
  }
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
- }
@@ -6,7 +6,7 @@ import type { BundleManifest } from "../bundler/types";
6
6
  import { isSafeManduUrl } from "../bundler/manifest-schema";
7
7
  import type { HydrationConfig, HydrationPriority } from "../spec/schema";
8
8
  import { PORTS, TIMEOUTS } from "../constants";
9
- import { decodeHtmlText, escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
9
+ import { decodeHtmlText, escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
10
10
  import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
11
11
  import { generateFastRefreshPreamble } from "../bundler/dev";
12
12
  import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
@@ -46,7 +46,7 @@ export interface SSROptions {
46
46
  title?: string;
47
47
  lang?: string;
48
48
  /** 서버에서 로드한 데이터 (클라이언트로 전달) */
49
- serverData?: unknown;
49
+ serverData?: unknown;
50
50
  /** Hydration 설정 */
51
51
  hydration?: HydrationConfig;
52
52
  /** 번들 매니페스트 */
@@ -255,26 +255,26 @@ function generateHydrationScripts(
255
255
  ? Object.values(manifest.islands).filter((ib) => ib.route === routeId)
256
256
  : [];
257
257
 
258
- if (routeIslands.length > 0) {
259
- for (const ib of routeIslands) {
260
- const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
261
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
262
- }
258
+ if (routeIslands.length > 0) {
259
+ for (const ib of routeIslands) {
260
+ const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
261
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
262
+ }
263
263
  } else {
264
264
  // Fallback: route-level bundle (backward compat)
265
265
  const bundle = manifest.bundles[routeId];
266
266
  if (bundle) {
267
267
  const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
268
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
269
- }
270
- }
271
-
272
- if (manifest.partials) {
273
- for (const partial of Object.values(manifest.partials)) {
274
- const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
275
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
276
- }
277
- }
268
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
269
+ }
270
+ }
271
+
272
+ if (manifest.partials) {
273
+ for (const partial of Object.values(manifest.partials)) {
274
+ const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
275
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
276
+ }
277
+ }
278
278
 
279
279
  // Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
280
280
  if (manifest.shared.runtime) {
@@ -619,9 +619,9 @@ export async function resolveAsyncElement(node: ReactNode): Promise<ReactNode> {
619
619
  return React.cloneElement(element, undefined, resolvedChildren);
620
620
  }
621
621
 
622
- export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
623
- const hasExplicitTitle = options.title !== undefined;
624
- const {
622
+ export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
623
+ const hasExplicitTitle = options.title !== undefined;
624
+ const {
625
625
  title = "Mandu App",
626
626
  lang = "ko",
627
627
  serverData,
@@ -713,14 +713,14 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
713
713
  const needsHydration =
714
714
  hydration && hydration.strategy !== "none" && routeId && bundleManifest;
715
715
 
716
- if (needsHydration && !islandPreWrapped) {
717
- // v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
718
- const bundle = bundleManifest.bundles[routeId];
719
- const bundleSrc = bundle?.js;
720
- if (bundleSrc) {
721
- content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
722
- }
723
- }
716
+ if (needsHydration && !islandPreWrapped) {
717
+ // v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
718
+ const bundle = bundleManifest.bundles[routeId];
719
+ const bundleSrc = bundle?.js;
720
+ if (bundleSrc) {
721
+ content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
722
+ }
723
+ }
724
724
 
725
725
  // Zero-JS 모드: island이 없는 페이지에서는 클라이언트 JS 번들을 전송하지 않음
726
726
  // HMR/DevTools는 dev 환경에서만 유지 (CSS 핫리로드 등)
@@ -731,10 +731,10 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
731
731
 
732
732
  if (needsHydration) {
733
733
  // 서버 데이터 스크립트 (클라이언트 hydration에서 사용)
734
- if (serverData !== undefined && routeId) {
735
- const wrappedData = {
736
- [routeId]: {
737
- serverData,
734
+ if (serverData !== undefined && routeId) {
735
+ const wrappedData = {
736
+ [routeId]: {
737
+ serverData,
738
738
  timestamp: Date.now(),
739
739
  },
740
740
  };
@@ -803,7 +803,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
803
803
  ? `<script>window.__MANDU_SPA__=false;</script>`
804
804
  : "";
805
805
 
806
- // #179: body 내 <link> 태그를 <head>로 호이스팅
806
+ // #179: body 내 <link> 태그를 <head>로 호이스팅
807
807
  // React 컴포넌트(layout.tsx 등)에서 <link>를 렌더링하면 body 안에 위치하게 되는데,
808
808
  // 폰트/스타일시트는 <head>에 있어야 FOUT 없이 로드됨
809
809
  const linkTagPattern = /<link\s[^>]*(?:rel=["'](?:stylesheet|preconnect|preload|icon|dns-prefetch)["'][^>]*|href=["'][^"']+["'][^>]*)\/?\s*>/gi;
@@ -812,19 +812,19 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
812
812
  hoistedLinks.push(match);
813
813
  return "";
814
814
  });
815
- const hoistedLinkTags = hoistedLinks.join("\n ");
816
-
817
- // #273 F15 — React 19 renders document metadata such as <title> from a
818
- // page component into the body string in this SSR path. Hoist the first
819
- // body title into <head> when no metadata/generateMetadata title was
820
- // provided, and always strip body titles to avoid duplicate/invalid HTML.
821
- let effectiveTitle = title;
822
- const titleTagPattern = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i;
823
- const bodyTitleMatch = bodyContent.match(titleTagPattern);
824
- const bodyWithoutTitle = bodyContent.replace(/<title(?:\s[^>]*)?>[\s\S]*?<\/title>/gi, "");
825
- if (!hasExplicitTitle && bodyTitleMatch) {
826
- effectiveTitle = decodeHtmlText(bodyTitleMatch[1] ?? title);
827
- }
815
+ const hoistedLinkTags = hoistedLinks.join("\n ");
816
+
817
+ // #273 F15 — React 19 renders document metadata such as <title> from a
818
+ // page component into the body string in this SSR path. Hoist the first
819
+ // body title into <head> when no metadata/generateMetadata title was
820
+ // provided, and always strip body titles to avoid duplicate/invalid HTML.
821
+ let effectiveTitle = title;
822
+ const titleTagPattern = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i;
823
+ const bodyTitleMatch = bodyContent.match(titleTagPattern);
824
+ const bodyWithoutTitle = bodyContent.replace(/<title(?:\s[^>]*)?>[\s\S]*?<\/title>/gi, "");
825
+ if (!hasExplicitTitle && bodyTitleMatch) {
826
+ effectiveTitle = decodeHtmlText(bodyTitleMatch[1] ?? title);
827
+ }
828
828
 
829
829
  // Phase 18.α — Dev Error Overlay injection.
830
830
  // Only emitted when `isDev` AND the user has not opted out (via
@@ -842,7 +842,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
842
842
  <head>
843
843
  <meta charset="UTF-8">
844
844
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
845
- <title>${escapeHtmlText(effectiveTitle)}</title>
845
+ <title>${escapeHtmlText(effectiveTitle)}</title>
846
846
  ${cssLinkTag}
847
847
  ${viewTransitionTag}
848
848
  ${prefetchScriptTag}
@@ -854,7 +854,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
854
854
  ${devErrorOverlayTag}
855
855
  </head>
856
856
  <body>
857
- <div id="root"${rootAttrs}>${bodyWithoutTitle}</div>
857
+ <div id="root"${rootAttrs}>${bodyWithoutTitle}</div>
858
858
  ${dataScript}
859
859
  ${routeScript}
860
860
  ${hydrationScripts}
@@ -871,11 +871,11 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
871
871
  /**
872
872
  * Client-side Routing: 현재 라우트 정보 스크립트 생성
873
873
  */
874
- function generateRouteScript(
875
- routeId: string,
876
- pattern: string,
877
- _serverData?: unknown
878
- ): string {
874
+ function generateRouteScript(
875
+ routeId: string,
876
+ pattern: string,
877
+ _serverData?: unknown
878
+ ): string {
879
879
  const routeInfo = {
880
880
  id: routeId,
881
881
  pattern,
@@ -1176,12 +1176,12 @@ export function renderSSR(element: ReactElement, options: SSROptions = {}): Resp
1176
1176
  */
1177
1177
  export async function renderWithHydration(
1178
1178
  element: ReactElement,
1179
- options: SSROptions & {
1180
- routeId: string;
1181
- serverData: unknown;
1182
- hydration: HydrationConfig;
1183
- bundleManifest: BundleManifest;
1184
- }
1179
+ options: SSROptions & {
1180
+ routeId: string;
1181
+ serverData: unknown;
1182
+ hydration: HydrationConfig;
1183
+ bundleManifest: BundleManifest;
1184
+ }
1185
1185
  ): Promise<Response> {
1186
1186
  const html = renderToHTML(element, options);
1187
1187
  // Phase 7.2 R1 Agent C (H1) — same CSP header logic as renderSSR.