@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
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,
|
|
@@ -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
|
*
|
|
@@ -3367,8 +3199,12 @@ async function handleRequestInternal(
|
|
|
3367
3199
|
}
|
|
3368
3200
|
|
|
3369
3201
|
// 1.5. Image optimization handler (/_mandu/image)
|
|
3370
|
-
if (
|
|
3371
|
-
const imageResponse = await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -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.
|