@mandujs/core 0.29.1 → 0.31.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.
@@ -16,10 +16,26 @@ export interface CacheEntry {
16
16
  headers: Record<string, string>;
17
17
  /** 생성 시간 (ms) */
18
18
  createdAt: number;
19
- /** stale이 되는 시간 (ms) — createdAt + revalidate * 1000 */
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 ("recordStale" in store && typeof (store as MemoryCacheStore).recordStale === "function") {
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
- // STALE: LRU 승격하지 않음 — eviction 대상으로 유지
230
- return { status: "STALE", entry };
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
- revalidateSeconds: number,
240
- tags: string[] = [],
241
- status: number = 200,
242
- headers: Record<string, string> = {}
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: now + revalidateSeconds * 1000,
339
+ revalidateAfter,
340
+ staleUntil,
252
341
  tags,
342
+ maxAgeSeconds: maxAge,
343
+ swrSeconds: swr,
253
344
  };
254
345
  }
255
346
 
256
347
  /**
257
- * 캐시된 Response 생성
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 age = Math.floor((Date.now() - entry.createdAt) / 1000);
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
+ }
@@ -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";