@mandujs/core 0.29.0 → 0.30.0
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 +10 -1
- package/src/bundler/analyzer.ts +843 -0
- package/src/config/mandu.ts +68 -0
- package/src/config/validate.ts +44 -0
- package/src/filling/context.ts +166 -0
- package/src/filling/filling.ts +10 -1
- package/src/index.ts +32 -0
- package/src/observability/index.ts +26 -0
- package/src/observability/tracing.ts +694 -0
- package/src/runtime/cache.ts +197 -13
- package/src/runtime/index.ts +8 -0
- package/src/runtime/server.ts +387 -24
package/src/runtime/cache.ts
CHANGED
|
@@ -16,10 +16,26 @@ export interface CacheEntry {
|
|
|
16
16
|
headers: Record<string, string>;
|
|
17
17
|
/** 생성 시간 (ms) */
|
|
18
18
|
createdAt: number;
|
|
19
|
-
/** stale이 되는 시간 (ms) — createdAt +
|
|
19
|
+
/** stale이 되는 시간 (ms) — createdAt + maxAge * 1000 */
|
|
20
20
|
revalidateAfter: number;
|
|
21
|
+
/**
|
|
22
|
+
* Phase 18.ζ — stale-while-revalidate 창 종료 시점 (ms).
|
|
23
|
+
* `revalidateAfter <= now < staleUntil` 구간이면 STALE 로 서빙하면서
|
|
24
|
+
* 백그라운드 재생성. `now >= staleUntil` 이면 MISS 로 취급하여 동기 재생성.
|
|
25
|
+
* `revalidateAfter` 와 동일한 값이면 SWR 창이 없는 것.
|
|
26
|
+
*/
|
|
27
|
+
staleUntil: number;
|
|
21
28
|
/** 무효화 태그 */
|
|
22
29
|
tags: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Phase 18.ζ — maxAge (초). CDN 용 Cache-Control 헤더 계산에 사용.
|
|
32
|
+
*/
|
|
33
|
+
maxAgeSeconds: number;
|
|
34
|
+
/**
|
|
35
|
+
* Phase 18.ζ — stale-while-revalidate 창 길이 (초). Cache-Control 헤더의
|
|
36
|
+
* `stale-while-revalidate=` 지시자로 그대로 반영된다. 0 이면 생략.
|
|
37
|
+
*/
|
|
38
|
+
swrSeconds: number;
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
export type CacheStatus = "HIT" | "STALE" | "MISS";
|
|
@@ -201,6 +217,14 @@ function getCachePathname(key: string): string {
|
|
|
201
217
|
|
|
202
218
|
/**
|
|
203
219
|
* 캐시 조회 — HIT / STALE / MISS 판정
|
|
220
|
+
*
|
|
221
|
+
* Phase 18.ζ:
|
|
222
|
+
* - `now < revalidateAfter` → HIT (fresh, serve cached)
|
|
223
|
+
* - `revalidateAfter <= now < staleUntil` → STALE (serve + revalidate bg)
|
|
224
|
+
* - `now >= staleUntil` → MISS (drop entry, regen sync)
|
|
225
|
+
*
|
|
226
|
+
* SWR 창을 벗어난 만료 엔트리는 본 함수 호출 시점에 물리적으로 delete 하여
|
|
227
|
+
* 태그 인덱스 및 LRU 상에서도 제거한다.
|
|
204
228
|
*/
|
|
205
229
|
export function lookupCache(store: CacheStore, key: string): CacheLookupResult {
|
|
206
230
|
const entry = store.get(key);
|
|
@@ -223,46 +247,141 @@ export function lookupCache(store: CacheStore, key: string): CacheLookupResult {
|
|
|
223
247
|
return { status: "HIT", entry };
|
|
224
248
|
}
|
|
225
249
|
|
|
226
|
-
if (
|
|
227
|
-
(store as MemoryCacheStore).recordStale
|
|
250
|
+
if (now < entry.staleUntil) {
|
|
251
|
+
if ("recordStale" in store && typeof (store as MemoryCacheStore).recordStale === "function") {
|
|
252
|
+
(store as MemoryCacheStore).recordStale();
|
|
253
|
+
}
|
|
254
|
+
// STALE: LRU 승격하지 않음 — eviction 대상으로 유지
|
|
255
|
+
return { status: "STALE", entry };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// SWR 창 밖: 만료된 엔트리는 제거하고 MISS.
|
|
259
|
+
store.delete(key);
|
|
260
|
+
if ("recordMiss" in store && typeof (store as MemoryCacheStore).recordMiss === "function") {
|
|
261
|
+
(store as MemoryCacheStore).recordMiss();
|
|
228
262
|
}
|
|
229
|
-
|
|
230
|
-
|
|
263
|
+
return { status: "MISS", entry: null };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Phase 18.ζ — 엔트리 생성에 쓰이는 캐시 메타데이터.
|
|
268
|
+
*
|
|
269
|
+
* - `maxAge` : fresh 구간 길이 (초). 기존 `revalidate` 와 동의어.
|
|
270
|
+
* - `swr` : stale-while-revalidate 창 길이 (초). 기본 0.
|
|
271
|
+
* - `tags` : 태그 무효화용.
|
|
272
|
+
*
|
|
273
|
+
* `revalidate` 는 과거 API 호환용 별칭이며 `maxAge` 가 미지정이면 사용된다.
|
|
274
|
+
*/
|
|
275
|
+
export interface CacheMetadata {
|
|
276
|
+
maxAge?: number;
|
|
277
|
+
/** deprecated 별칭 — maxAge 로 매핑 */
|
|
278
|
+
revalidate?: number;
|
|
279
|
+
staleWhileRevalidate?: number;
|
|
280
|
+
/** deprecated 별칭 — staleWhileRevalidate 로 매핑 */
|
|
281
|
+
swr?: number;
|
|
282
|
+
tags?: string[];
|
|
231
283
|
}
|
|
232
284
|
|
|
233
285
|
/**
|
|
234
|
-
* 캐시 엔트리
|
|
286
|
+
* 캐시 엔트리 생성.
|
|
287
|
+
*
|
|
288
|
+
* Phase 18.ζ 시그니처는 overload 로 기존 호출부와 호환된다:
|
|
289
|
+
* - 구: `createCacheEntry(html, data, revalidate, tags?, status?, headers?)`
|
|
290
|
+
* - 신: `createCacheEntry(html, data, { maxAge, staleWhileRevalidate, tags }, status?, headers?)`
|
|
235
291
|
*/
|
|
236
292
|
export function createCacheEntry(
|
|
237
293
|
html: string,
|
|
238
294
|
loaderData: unknown,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
295
|
+
meta: number | CacheMetadata,
|
|
296
|
+
tagsOrStatus?: string[] | number,
|
|
297
|
+
statusOrHeaders?: number | Record<string, string>,
|
|
298
|
+
headersMaybe?: Record<string, string>
|
|
243
299
|
): CacheEntry {
|
|
300
|
+
let maxAge: number;
|
|
301
|
+
let swr: number;
|
|
302
|
+
let tags: string[];
|
|
303
|
+
let status: number;
|
|
304
|
+
let headers: Record<string, string>;
|
|
305
|
+
|
|
306
|
+
if (typeof meta === "number") {
|
|
307
|
+
// legacy positional signature
|
|
308
|
+
maxAge = meta;
|
|
309
|
+
swr = 0;
|
|
310
|
+
tags = Array.isArray(tagsOrStatus) ? tagsOrStatus : [];
|
|
311
|
+
status = typeof statusOrHeaders === "number" ? statusOrHeaders : 200;
|
|
312
|
+
headers = (typeof statusOrHeaders === "object" && statusOrHeaders !== null
|
|
313
|
+
? statusOrHeaders
|
|
314
|
+
: headersMaybe) ?? {};
|
|
315
|
+
} else {
|
|
316
|
+
maxAge = meta.maxAge ?? meta.revalidate ?? 0;
|
|
317
|
+
swr = meta.staleWhileRevalidate ?? meta.swr ?? 0;
|
|
318
|
+
tags = meta.tags ?? [];
|
|
319
|
+
status = typeof tagsOrStatus === "number" ? tagsOrStatus : 200;
|
|
320
|
+
headers = (typeof statusOrHeaders === "object" && statusOrHeaders !== null
|
|
321
|
+
? statusOrHeaders
|
|
322
|
+
: {}) as Record<string, string>;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!Number.isFinite(maxAge) || maxAge < 0) maxAge = 0;
|
|
326
|
+
if (!Number.isFinite(swr) || swr < 0) swr = 0;
|
|
327
|
+
|
|
244
328
|
const now = Date.now();
|
|
329
|
+
const revalidateAfter = now + maxAge * 1000;
|
|
330
|
+
// SWR 창 종료 시점은 fresh 구간 끝에서 swr 초 추가. swr=0 이면 revalidateAfter 와 동일.
|
|
331
|
+
const staleUntil = revalidateAfter + swr * 1000;
|
|
332
|
+
|
|
245
333
|
return {
|
|
246
334
|
html,
|
|
247
335
|
loaderData,
|
|
248
336
|
status,
|
|
249
337
|
headers,
|
|
250
338
|
createdAt: now,
|
|
251
|
-
revalidateAfter
|
|
339
|
+
revalidateAfter,
|
|
340
|
+
staleUntil,
|
|
252
341
|
tags,
|
|
342
|
+
maxAgeSeconds: maxAge,
|
|
343
|
+
swrSeconds: swr,
|
|
253
344
|
};
|
|
254
345
|
}
|
|
255
346
|
|
|
256
347
|
/**
|
|
257
|
-
*
|
|
348
|
+
* Phase 18.ζ — CDN 정렬용 Cache-Control 문자열 계산.
|
|
349
|
+
*
|
|
350
|
+
* `public, max-age=<fresh-remaining>, stale-while-revalidate=<swr>`
|
|
351
|
+
*
|
|
352
|
+
* `max-age` 는 **현재 시점에서의 남은 fresh 초** 를 반환하여 다운스트림
|
|
353
|
+
* 캐시(CDN, 브라우저)가 엔트리의 실제 TTL 을 정확히 계산하도록 한다.
|
|
354
|
+
* fresh 구간이 이미 지난 STALE 엔트리는 `max-age=0` 으로 계산된다.
|
|
355
|
+
*/
|
|
356
|
+
export function computeCacheControl(entry: CacheEntry, now: number = Date.now()): string {
|
|
357
|
+
const remainingFreshMs = Math.max(0, entry.revalidateAfter - now);
|
|
358
|
+
const remainingFreshSec = Math.floor(remainingFreshMs / 1000);
|
|
359
|
+
const parts = [`public`, `max-age=${remainingFreshSec}`];
|
|
360
|
+
if (entry.swrSeconds > 0) {
|
|
361
|
+
parts.push(`stale-while-revalidate=${entry.swrSeconds}`);
|
|
362
|
+
}
|
|
363
|
+
return parts.join(", ");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 캐시된 Response 생성.
|
|
368
|
+
*
|
|
369
|
+
* Phase 18.ζ 추가:
|
|
370
|
+
* - `Cache-Control: public, max-age=…, stale-while-revalidate=…` 헤더
|
|
371
|
+
* 자동 부착 (기존 entry.headers 에 Cache-Control 이 있으면 override).
|
|
372
|
+
* - `X-Mandu-Cache` 는 HIT / STALE / MISS / PRERENDERED 로 디버깅용.
|
|
373
|
+
* - `Age` 는 엔트리 생성 후 경과 초.
|
|
258
374
|
*/
|
|
259
375
|
export function createCachedResponse(entry: CacheEntry, cacheStatus: CacheStatus): Response {
|
|
260
|
-
const
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
const age = Math.floor((now - entry.createdAt) / 1000);
|
|
378
|
+
const cacheControl = computeCacheControl(entry, now);
|
|
261
379
|
return new Response(entry.html, {
|
|
262
380
|
status: entry.status,
|
|
263
381
|
headers: {
|
|
264
382
|
"Content-Type": "text/html; charset=utf-8",
|
|
265
383
|
...entry.headers,
|
|
384
|
+
"Cache-Control": cacheControl,
|
|
266
385
|
"X-Mandu-Cache": cacheStatus,
|
|
267
386
|
"Age": String(age),
|
|
268
387
|
},
|
|
@@ -308,3 +427,68 @@ export function getCacheStoreStats(store: CacheStore | null): CacheStoreStats |
|
|
|
308
427
|
entries: store.size,
|
|
309
428
|
};
|
|
310
429
|
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Phase 18.ζ — `revalidate()` 단항 헬퍼.
|
|
433
|
+
*
|
|
434
|
+
* `revalidateTag(tag)` 의 별칭. Next.js 의 `revalidateTag` / `revalidate`
|
|
435
|
+
* API 와 혼용 가능하도록 단일 엔트리 포인트를 제공한다.
|
|
436
|
+
*/
|
|
437
|
+
export function revalidate(tag: string): void {
|
|
438
|
+
revalidateTag(tag);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ========== Config-driven Construction ==========
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Phase 18.ζ — `ManduConfig.cache` 블록.
|
|
445
|
+
*
|
|
446
|
+
* - `defaultMaxAge` : 로더가 `_cache` 를 내지 않았을 때 적용할 기본 fresh TTL (초).
|
|
447
|
+
* `undefined` 또는 0 이면 자동 캐싱하지 않음.
|
|
448
|
+
* - `defaultSwr` : 로더가 swr 을 생략했을 때 적용할 기본 SWR 창 (초).
|
|
449
|
+
* - `maxEntries` : LRU 상한. 기본 1000.
|
|
450
|
+
* - `store` : 향후 redis 어댑터 자리. 현재는 `"memory"` 만 지원.
|
|
451
|
+
*/
|
|
452
|
+
export interface CacheConfig {
|
|
453
|
+
defaultMaxAge?: number;
|
|
454
|
+
defaultSwr?: number;
|
|
455
|
+
maxEntries?: number;
|
|
456
|
+
store?: "memory";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* `ManduConfig.cache` 값을 받아 적절한 `CacheStore` 인스턴스를 만든다.
|
|
461
|
+
*
|
|
462
|
+
* - `false` / `undefined` → null (캐시 disabled)
|
|
463
|
+
* - `true` → MemoryCacheStore(1000)
|
|
464
|
+
* - `CacheConfig` 객체 → MemoryCacheStore(maxEntries)
|
|
465
|
+
* - 이미 `CacheStore` 모양 객체 → 그대로 반환 (커스텀 어댑터 주입)
|
|
466
|
+
*/
|
|
467
|
+
export function createCacheStoreFromConfig(
|
|
468
|
+
value: boolean | CacheConfig | CacheStore | undefined
|
|
469
|
+
): CacheStore | null {
|
|
470
|
+
if (!value) return null;
|
|
471
|
+
if (value === true) return new MemoryCacheStore();
|
|
472
|
+
// Duck-type: CacheStore 모양이면 그대로 주입
|
|
473
|
+
if (typeof (value as CacheStore).get === "function" && typeof (value as CacheStore).set === "function") {
|
|
474
|
+
return value as CacheStore;
|
|
475
|
+
}
|
|
476
|
+
const cfg = value as CacheConfig;
|
|
477
|
+
return new MemoryCacheStore(cfg.maxEntries ?? 1000);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ========== Config defaults snapshot ==========
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Phase 18.ζ — `ManduConfig.cache` 에서 추출한 defaults. 서버가 매 요청의
|
|
484
|
+
* `_cache` 메타데이터를 보완할 때 참조한다. `null` 은 "캐시 disabled" 의미.
|
|
485
|
+
*/
|
|
486
|
+
let globalCacheDefaults: { defaultMaxAge?: number; defaultSwr?: number } | null = null;
|
|
487
|
+
|
|
488
|
+
export function setGlobalCacheDefaults(defaults: { defaultMaxAge?: number; defaultSwr?: number } | null): void {
|
|
489
|
+
globalCacheDefaults = defaults;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function getGlobalCacheDefaults(): { defaultMaxAge?: number; defaultSwr?: number } | null {
|
|
493
|
+
return globalCacheDefaults;
|
|
494
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -18,11 +18,19 @@ export * from "./logger";
|
|
|
18
18
|
export * from "./boundary";
|
|
19
19
|
export * from "./stable-selector";
|
|
20
20
|
export {
|
|
21
|
+
revalidate,
|
|
21
22
|
revalidatePath,
|
|
22
23
|
revalidateTag,
|
|
23
24
|
getCacheStoreStats,
|
|
25
|
+
computeCacheControl,
|
|
26
|
+
createCacheStoreFromConfig,
|
|
27
|
+
setGlobalCacheDefaults,
|
|
28
|
+
getGlobalCacheDefaults,
|
|
24
29
|
type CacheStore,
|
|
25
30
|
type CacheStoreStats,
|
|
31
|
+
type CacheConfig,
|
|
32
|
+
type CacheMetadata,
|
|
33
|
+
type CacheEntry,
|
|
26
34
|
MemoryCacheStore,
|
|
27
35
|
} from "./cache";
|
|
28
36
|
export { type MiddlewareContext, type MiddlewareNext, type MiddlewareFn, type MiddlewareConfig } from "./middleware";
|