@mandujs/core 0.53.3 → 0.54.1
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/README.md +654 -654
- package/package.json +1 -1
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
package/src/runtime/cache.ts
CHANGED
|
@@ -1,494 +1,494 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu SSR Cache Layer
|
|
3
|
-
* ISR(Incremental Static Regeneration) + SWR(Stale-While-Revalidate) 지원
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
// ========== Types ==========
|
|
7
|
-
|
|
8
|
-
export interface CacheEntry {
|
|
9
|
-
/** 렌더링된 HTML */
|
|
10
|
-
html: string;
|
|
11
|
-
/** 직렬화된 loader 데이터 */
|
|
12
|
-
loaderData: unknown;
|
|
13
|
-
/** 응답 상태 코드 */
|
|
14
|
-
status: number;
|
|
15
|
-
/** 응답 헤더 */
|
|
16
|
-
headers: Record<string, string>;
|
|
17
|
-
/** 생성 시간 (ms) */
|
|
18
|
-
createdAt: number;
|
|
19
|
-
/** stale이 되는 시간 (ms) — createdAt + maxAge * 1000 */
|
|
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;
|
|
28
|
-
/** 무효화 태그 */
|
|
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;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export type CacheStatus = "HIT" | "STALE" | "MISS";
|
|
42
|
-
|
|
43
|
-
export interface CacheLookupResult {
|
|
44
|
-
status: CacheStatus;
|
|
45
|
-
entry: CacheEntry | null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface CacheStoreStats {
|
|
49
|
-
entries: number;
|
|
50
|
-
maxEntries?: number;
|
|
51
|
-
staleEntries?: number;
|
|
52
|
-
hits?: number;
|
|
53
|
-
staleHits?: number;
|
|
54
|
-
misses?: number;
|
|
55
|
-
hitRate?: number;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface CacheStore {
|
|
59
|
-
get(key: string): CacheEntry | null;
|
|
60
|
-
set(key: string, entry: CacheEntry): void;
|
|
61
|
-
delete(key: string): void;
|
|
62
|
-
/** pathname 부분 매칭으로 캐시 삭제 (키 형식: "routeId:pathname") */
|
|
63
|
-
deleteByPath(pathname: string): void;
|
|
64
|
-
deleteByTag(tag: string): void;
|
|
65
|
-
clear(): void;
|
|
66
|
-
readonly size: number;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// ========== Memory Cache (LRU) ==========
|
|
70
|
-
|
|
71
|
-
export class MemoryCacheStore implements CacheStore {
|
|
72
|
-
private cache = new Map<string, CacheEntry>();
|
|
73
|
-
private tagIndex = new Map<string, Set<string>>();
|
|
74
|
-
private readonly maxEntries: number;
|
|
75
|
-
private hits = 0;
|
|
76
|
-
private staleHits = 0;
|
|
77
|
-
private misses = 0;
|
|
78
|
-
|
|
79
|
-
constructor(maxEntries: number = 1000) {
|
|
80
|
-
this.maxEntries = maxEntries;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
get size(): number {
|
|
84
|
-
return this.cache.size;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
get(key: string): CacheEntry | null {
|
|
88
|
-
return this.cache.get(key) ?? null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** LRU 접근 — HIT 확인 후에만 호출하여 stale 엔트리가 승격되지 않도록 */
|
|
92
|
-
touch(key: string): void {
|
|
93
|
-
const entry = this.cache.get(key);
|
|
94
|
-
if (entry) {
|
|
95
|
-
this.cache.delete(key);
|
|
96
|
-
this.cache.set(key, entry);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
set(key: string, entry: CacheEntry): void {
|
|
101
|
-
// 기존 엔트리 태그 인덱스 정리
|
|
102
|
-
if (this.cache.has(key)) {
|
|
103
|
-
this.removeFromTagIndex(key);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// LRU eviction
|
|
107
|
-
if (this.cache.size >= this.maxEntries) {
|
|
108
|
-
const oldest = this.cache.keys().next().value;
|
|
109
|
-
if (oldest !== undefined) {
|
|
110
|
-
this.removeFromTagIndex(oldest);
|
|
111
|
-
this.cache.delete(oldest);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
this.cache.set(key, entry);
|
|
116
|
-
|
|
117
|
-
// 태그 인덱스 업데이트
|
|
118
|
-
for (const tag of entry.tags) {
|
|
119
|
-
let keys = this.tagIndex.get(tag);
|
|
120
|
-
if (!keys) {
|
|
121
|
-
keys = new Set();
|
|
122
|
-
this.tagIndex.set(tag, keys);
|
|
123
|
-
}
|
|
124
|
-
keys.add(key);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
delete(key: string): void {
|
|
129
|
-
this.removeFromTagIndex(key);
|
|
130
|
-
this.cache.delete(key);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
deleteByPath(pathname: string): void {
|
|
134
|
-
// 캐시 키 형식: "routeId:pathname?query" — pathname 부분이 일치하는 모든 키 삭제
|
|
135
|
-
const keysToDelete: string[] = [];
|
|
136
|
-
for (const key of this.cache.keys()) {
|
|
137
|
-
const keyPath = getCachePathname(key);
|
|
138
|
-
if (keyPath === pathname) {
|
|
139
|
-
keysToDelete.push(key);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
for (const key of keysToDelete) {
|
|
143
|
-
this.delete(key);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
deleteByTag(tag: string): void {
|
|
148
|
-
const keys = this.tagIndex.get(tag);
|
|
149
|
-
if (!keys) return;
|
|
150
|
-
|
|
151
|
-
for (const key of keys) {
|
|
152
|
-
this.cache.delete(key);
|
|
153
|
-
// 해당 key가 다른 태그에도 있으면 거기서도 제거
|
|
154
|
-
for (const [otherTag, otherKeys] of this.tagIndex) {
|
|
155
|
-
if (otherTag !== tag) {
|
|
156
|
-
otherKeys.delete(key);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
this.tagIndex.delete(tag);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
clear(): void {
|
|
164
|
-
this.cache.clear();
|
|
165
|
-
this.tagIndex.clear();
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
recordHit(): void {
|
|
169
|
-
this.hits += 1;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
recordStale(): void {
|
|
173
|
-
this.staleHits += 1;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
recordMiss(): void {
|
|
177
|
-
this.misses += 1;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
getStats(): CacheStoreStats {
|
|
181
|
-
const now = Date.now();
|
|
182
|
-
const staleEntries = Array.from(this.cache.values()).filter((entry) => entry.revalidateAfter <= now).length;
|
|
183
|
-
const totalLookups = this.hits + this.staleHits + this.misses;
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
entries: this.cache.size,
|
|
187
|
-
maxEntries: this.maxEntries,
|
|
188
|
-
staleEntries,
|
|
189
|
-
hits: this.hits,
|
|
190
|
-
staleHits: this.staleHits,
|
|
191
|
-
misses: this.misses,
|
|
192
|
-
hitRate: totalLookups > 0 ? this.hits / totalLookups : undefined,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
private removeFromTagIndex(key: string): void {
|
|
197
|
-
const entry = this.cache.get(key);
|
|
198
|
-
if (!entry) return;
|
|
199
|
-
for (const tag of entry.tags) {
|
|
200
|
-
this.tagIndex.get(tag)?.delete(key);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function getCachePathname(key: string): string {
|
|
206
|
-
const colonIdx = key.indexOf(":");
|
|
207
|
-
const rawPath = colonIdx >= 0 ? key.slice(colonIdx + 1) : key;
|
|
208
|
-
try {
|
|
209
|
-
return new URL(rawPath, "http://mandu.local").pathname;
|
|
210
|
-
} catch {
|
|
211
|
-
const queryIdx = rawPath.indexOf("?");
|
|
212
|
-
return queryIdx >= 0 ? rawPath.slice(0, queryIdx) : rawPath;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// ========== Cache Lookup ==========
|
|
217
|
-
|
|
218
|
-
/**
|
|
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 상에서도 제거한다.
|
|
228
|
-
*/
|
|
229
|
-
export function lookupCache(store: CacheStore, key: string): CacheLookupResult {
|
|
230
|
-
const entry = store.get(key);
|
|
231
|
-
if (!entry) {
|
|
232
|
-
if ("recordMiss" in store && typeof (store as MemoryCacheStore).recordMiss === "function") {
|
|
233
|
-
(store as MemoryCacheStore).recordMiss();
|
|
234
|
-
}
|
|
235
|
-
return { status: "MISS", entry: null };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const now = Date.now();
|
|
239
|
-
if (now < entry.revalidateAfter) {
|
|
240
|
-
if ("recordHit" in store && typeof (store as MemoryCacheStore).recordHit === "function") {
|
|
241
|
-
(store as MemoryCacheStore).recordHit();
|
|
242
|
-
}
|
|
243
|
-
// HIT: LRU 승격 (MemoryCacheStore만 해당)
|
|
244
|
-
if ("touch" in store && typeof (store as MemoryCacheStore).touch === "function") {
|
|
245
|
-
(store as MemoryCacheStore).touch(key);
|
|
246
|
-
}
|
|
247
|
-
return { status: "HIT", entry };
|
|
248
|
-
}
|
|
249
|
-
|
|
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();
|
|
262
|
-
}
|
|
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[];
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* 캐시 엔트리 생성.
|
|
287
|
-
*
|
|
288
|
-
* Phase 18.ζ 시그니처는 overload 로 기존 호출부와 호환된다:
|
|
289
|
-
* - 구: `createCacheEntry(html, data, revalidate, tags?, status?, headers?)`
|
|
290
|
-
* - 신: `createCacheEntry(html, data, { maxAge, staleWhileRevalidate, tags }, status?, headers?)`
|
|
291
|
-
*/
|
|
292
|
-
export function createCacheEntry(
|
|
293
|
-
html: string,
|
|
294
|
-
loaderData: unknown,
|
|
295
|
-
meta: number | CacheMetadata,
|
|
296
|
-
tagsOrStatus?: string[] | number,
|
|
297
|
-
statusOrHeaders?: number | Record<string, string>,
|
|
298
|
-
headersMaybe?: Record<string, string>
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
return {
|
|
334
|
-
html,
|
|
335
|
-
loaderData,
|
|
336
|
-
status,
|
|
337
|
-
headers,
|
|
338
|
-
createdAt: now,
|
|
339
|
-
revalidateAfter,
|
|
340
|
-
staleUntil,
|
|
341
|
-
tags,
|
|
342
|
-
maxAgeSeconds: maxAge,
|
|
343
|
-
swrSeconds: swr,
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
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` 는 엔트리 생성 후 경과 초.
|
|
374
|
-
*/
|
|
375
|
-
export function createCachedResponse(entry: CacheEntry, cacheStatus: CacheStatus): Response {
|
|
376
|
-
const now = Date.now();
|
|
377
|
-
const age = Math.floor((now - entry.createdAt) / 1000);
|
|
378
|
-
const cacheControl = computeCacheControl(entry, now);
|
|
379
|
-
return new Response(entry.html, {
|
|
380
|
-
status: entry.status,
|
|
381
|
-
headers: {
|
|
382
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
383
|
-
...entry.headers,
|
|
384
|
-
"Cache-Control": cacheControl,
|
|
385
|
-
"X-Mandu-Cache": cacheStatus,
|
|
386
|
-
"Age": String(age),
|
|
387
|
-
},
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// ========== Global Cache + Revalidation API ==========
|
|
392
|
-
|
|
393
|
-
let globalCacheStore: CacheStore | null = null;
|
|
394
|
-
|
|
395
|
-
export function setGlobalCache(store: CacheStore): void {
|
|
396
|
-
globalCacheStore = store;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
export function getGlobalCache(): CacheStore | null {
|
|
400
|
-
return globalCacheStore;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* 특정 경로의 캐시 무효화
|
|
405
|
-
*/
|
|
406
|
-
export function revalidatePath(path: string): void {
|
|
407
|
-
if (!globalCacheStore) return;
|
|
408
|
-
globalCacheStore.deleteByPath(path);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* 특정 태그의 모든 캐시 무효화
|
|
413
|
-
*/
|
|
414
|
-
export function revalidateTag(tag: string): void {
|
|
415
|
-
if (!globalCacheStore) return;
|
|
416
|
-
globalCacheStore.deleteByTag(tag);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
export function getCacheStoreStats(store: CacheStore | null): CacheStoreStats | null {
|
|
420
|
-
if (!store) return null;
|
|
421
|
-
|
|
422
|
-
if ("getStats" in store && typeof (store as MemoryCacheStore).getStats === "function") {
|
|
423
|
-
return (store as MemoryCacheStore).getStats();
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
return {
|
|
427
|
-
entries: store.size,
|
|
428
|
-
};
|
|
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
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Mandu SSR Cache Layer
|
|
3
|
+
* ISR(Incremental Static Regeneration) + SWR(Stale-While-Revalidate) 지원
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ========== Types ==========
|
|
7
|
+
|
|
8
|
+
export interface CacheEntry {
|
|
9
|
+
/** 렌더링된 HTML */
|
|
10
|
+
html: string;
|
|
11
|
+
/** 직렬화된 loader 데이터 */
|
|
12
|
+
loaderData: unknown;
|
|
13
|
+
/** 응답 상태 코드 */
|
|
14
|
+
status: number;
|
|
15
|
+
/** 응답 헤더 */
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
/** 생성 시간 (ms) */
|
|
18
|
+
createdAt: number;
|
|
19
|
+
/** stale이 되는 시간 (ms) — createdAt + maxAge * 1000 */
|
|
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;
|
|
28
|
+
/** 무효화 태그 */
|
|
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;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type CacheStatus = "HIT" | "STALE" | "MISS";
|
|
42
|
+
|
|
43
|
+
export interface CacheLookupResult {
|
|
44
|
+
status: CacheStatus;
|
|
45
|
+
entry: CacheEntry | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CacheStoreStats {
|
|
49
|
+
entries: number;
|
|
50
|
+
maxEntries?: number;
|
|
51
|
+
staleEntries?: number;
|
|
52
|
+
hits?: number;
|
|
53
|
+
staleHits?: number;
|
|
54
|
+
misses?: number;
|
|
55
|
+
hitRate?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CacheStore {
|
|
59
|
+
get(key: string): CacheEntry | null;
|
|
60
|
+
set(key: string, entry: CacheEntry): void;
|
|
61
|
+
delete(key: string): void;
|
|
62
|
+
/** pathname 부분 매칭으로 캐시 삭제 (키 형식: "routeId:pathname") */
|
|
63
|
+
deleteByPath(pathname: string): void;
|
|
64
|
+
deleteByTag(tag: string): void;
|
|
65
|
+
clear(): void;
|
|
66
|
+
readonly size: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ========== Memory Cache (LRU) ==========
|
|
70
|
+
|
|
71
|
+
export class MemoryCacheStore implements CacheStore {
|
|
72
|
+
private cache = new Map<string, CacheEntry>();
|
|
73
|
+
private tagIndex = new Map<string, Set<string>>();
|
|
74
|
+
private readonly maxEntries: number;
|
|
75
|
+
private hits = 0;
|
|
76
|
+
private staleHits = 0;
|
|
77
|
+
private misses = 0;
|
|
78
|
+
|
|
79
|
+
constructor(maxEntries: number = 1000) {
|
|
80
|
+
this.maxEntries = maxEntries;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get size(): number {
|
|
84
|
+
return this.cache.size;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get(key: string): CacheEntry | null {
|
|
88
|
+
return this.cache.get(key) ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** LRU 접근 — HIT 확인 후에만 호출하여 stale 엔트리가 승격되지 않도록 */
|
|
92
|
+
touch(key: string): void {
|
|
93
|
+
const entry = this.cache.get(key);
|
|
94
|
+
if (entry) {
|
|
95
|
+
this.cache.delete(key);
|
|
96
|
+
this.cache.set(key, entry);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
set(key: string, entry: CacheEntry): void {
|
|
101
|
+
// 기존 엔트리 태그 인덱스 정리
|
|
102
|
+
if (this.cache.has(key)) {
|
|
103
|
+
this.removeFromTagIndex(key);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// LRU eviction
|
|
107
|
+
if (this.cache.size >= this.maxEntries) {
|
|
108
|
+
const oldest = this.cache.keys().next().value;
|
|
109
|
+
if (oldest !== undefined) {
|
|
110
|
+
this.removeFromTagIndex(oldest);
|
|
111
|
+
this.cache.delete(oldest);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.cache.set(key, entry);
|
|
116
|
+
|
|
117
|
+
// 태그 인덱스 업데이트
|
|
118
|
+
for (const tag of entry.tags) {
|
|
119
|
+
let keys = this.tagIndex.get(tag);
|
|
120
|
+
if (!keys) {
|
|
121
|
+
keys = new Set();
|
|
122
|
+
this.tagIndex.set(tag, keys);
|
|
123
|
+
}
|
|
124
|
+
keys.add(key);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
delete(key: string): void {
|
|
129
|
+
this.removeFromTagIndex(key);
|
|
130
|
+
this.cache.delete(key);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
deleteByPath(pathname: string): void {
|
|
134
|
+
// 캐시 키 형식: "routeId:pathname?query" — pathname 부분이 일치하는 모든 키 삭제
|
|
135
|
+
const keysToDelete: string[] = [];
|
|
136
|
+
for (const key of this.cache.keys()) {
|
|
137
|
+
const keyPath = getCachePathname(key);
|
|
138
|
+
if (keyPath === pathname) {
|
|
139
|
+
keysToDelete.push(key);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const key of keysToDelete) {
|
|
143
|
+
this.delete(key);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
deleteByTag(tag: string): void {
|
|
148
|
+
const keys = this.tagIndex.get(tag);
|
|
149
|
+
if (!keys) return;
|
|
150
|
+
|
|
151
|
+
for (const key of keys) {
|
|
152
|
+
this.cache.delete(key);
|
|
153
|
+
// 해당 key가 다른 태그에도 있으면 거기서도 제거
|
|
154
|
+
for (const [otherTag, otherKeys] of this.tagIndex) {
|
|
155
|
+
if (otherTag !== tag) {
|
|
156
|
+
otherKeys.delete(key);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.tagIndex.delete(tag);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
clear(): void {
|
|
164
|
+
this.cache.clear();
|
|
165
|
+
this.tagIndex.clear();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
recordHit(): void {
|
|
169
|
+
this.hits += 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
recordStale(): void {
|
|
173
|
+
this.staleHits += 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
recordMiss(): void {
|
|
177
|
+
this.misses += 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getStats(): CacheStoreStats {
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
const staleEntries = Array.from(this.cache.values()).filter((entry) => entry.revalidateAfter <= now).length;
|
|
183
|
+
const totalLookups = this.hits + this.staleHits + this.misses;
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
entries: this.cache.size,
|
|
187
|
+
maxEntries: this.maxEntries,
|
|
188
|
+
staleEntries,
|
|
189
|
+
hits: this.hits,
|
|
190
|
+
staleHits: this.staleHits,
|
|
191
|
+
misses: this.misses,
|
|
192
|
+
hitRate: totalLookups > 0 ? this.hits / totalLookups : undefined,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private removeFromTagIndex(key: string): void {
|
|
197
|
+
const entry = this.cache.get(key);
|
|
198
|
+
if (!entry) return;
|
|
199
|
+
for (const tag of entry.tags) {
|
|
200
|
+
this.tagIndex.get(tag)?.delete(key);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getCachePathname(key: string): string {
|
|
206
|
+
const colonIdx = key.indexOf(":");
|
|
207
|
+
const rawPath = colonIdx >= 0 ? key.slice(colonIdx + 1) : key;
|
|
208
|
+
try {
|
|
209
|
+
return new URL(rawPath, "http://mandu.local").pathname;
|
|
210
|
+
} catch {
|
|
211
|
+
const queryIdx = rawPath.indexOf("?");
|
|
212
|
+
return queryIdx >= 0 ? rawPath.slice(0, queryIdx) : rawPath;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ========== Cache Lookup ==========
|
|
217
|
+
|
|
218
|
+
/**
|
|
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 상에서도 제거한다.
|
|
228
|
+
*/
|
|
229
|
+
export function lookupCache(store: CacheStore, key: string): CacheLookupResult {
|
|
230
|
+
const entry = store.get(key);
|
|
231
|
+
if (!entry) {
|
|
232
|
+
if ("recordMiss" in store && typeof (store as MemoryCacheStore).recordMiss === "function") {
|
|
233
|
+
(store as MemoryCacheStore).recordMiss();
|
|
234
|
+
}
|
|
235
|
+
return { status: "MISS", entry: null };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
if (now < entry.revalidateAfter) {
|
|
240
|
+
if ("recordHit" in store && typeof (store as MemoryCacheStore).recordHit === "function") {
|
|
241
|
+
(store as MemoryCacheStore).recordHit();
|
|
242
|
+
}
|
|
243
|
+
// HIT: LRU 승격 (MemoryCacheStore만 해당)
|
|
244
|
+
if ("touch" in store && typeof (store as MemoryCacheStore).touch === "function") {
|
|
245
|
+
(store as MemoryCacheStore).touch(key);
|
|
246
|
+
}
|
|
247
|
+
return { status: "HIT", entry };
|
|
248
|
+
}
|
|
249
|
+
|
|
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();
|
|
262
|
+
}
|
|
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[];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 캐시 엔트리 생성.
|
|
287
|
+
*
|
|
288
|
+
* Phase 18.ζ 시그니처는 overload 로 기존 호출부와 호환된다:
|
|
289
|
+
* - 구: `createCacheEntry(html, data, revalidate, tags?, status?, headers?)`
|
|
290
|
+
* - 신: `createCacheEntry(html, data, { maxAge, staleWhileRevalidate, tags }, status?, headers?)`
|
|
291
|
+
*/
|
|
292
|
+
export function createCacheEntry(
|
|
293
|
+
html: string,
|
|
294
|
+
loaderData: unknown,
|
|
295
|
+
meta: number | CacheMetadata,
|
|
296
|
+
tagsOrStatus?: string[] | number,
|
|
297
|
+
statusOrHeaders?: number | Record<string, string>,
|
|
298
|
+
headersMaybe?: Record<string, string>
|
|
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
|
+
|
|
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
|
+
|
|
333
|
+
return {
|
|
334
|
+
html,
|
|
335
|
+
loaderData,
|
|
336
|
+
status,
|
|
337
|
+
headers,
|
|
338
|
+
createdAt: now,
|
|
339
|
+
revalidateAfter,
|
|
340
|
+
staleUntil,
|
|
341
|
+
tags,
|
|
342
|
+
maxAgeSeconds: maxAge,
|
|
343
|
+
swrSeconds: swr,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
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` 는 엔트리 생성 후 경과 초.
|
|
374
|
+
*/
|
|
375
|
+
export function createCachedResponse(entry: CacheEntry, cacheStatus: CacheStatus): Response {
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
const age = Math.floor((now - entry.createdAt) / 1000);
|
|
378
|
+
const cacheControl = computeCacheControl(entry, now);
|
|
379
|
+
return new Response(entry.html, {
|
|
380
|
+
status: entry.status,
|
|
381
|
+
headers: {
|
|
382
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
383
|
+
...entry.headers,
|
|
384
|
+
"Cache-Control": cacheControl,
|
|
385
|
+
"X-Mandu-Cache": cacheStatus,
|
|
386
|
+
"Age": String(age),
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ========== Global Cache + Revalidation API ==========
|
|
392
|
+
|
|
393
|
+
let globalCacheStore: CacheStore | null = null;
|
|
394
|
+
|
|
395
|
+
export function setGlobalCache(store: CacheStore): void {
|
|
396
|
+
globalCacheStore = store;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function getGlobalCache(): CacheStore | null {
|
|
400
|
+
return globalCacheStore;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* 특정 경로의 캐시 무효화
|
|
405
|
+
*/
|
|
406
|
+
export function revalidatePath(path: string): void {
|
|
407
|
+
if (!globalCacheStore) return;
|
|
408
|
+
globalCacheStore.deleteByPath(path);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 특정 태그의 모든 캐시 무효화
|
|
413
|
+
*/
|
|
414
|
+
export function revalidateTag(tag: string): void {
|
|
415
|
+
if (!globalCacheStore) return;
|
|
416
|
+
globalCacheStore.deleteByTag(tag);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function getCacheStoreStats(store: CacheStore | null): CacheStoreStats | null {
|
|
420
|
+
if (!store) return null;
|
|
421
|
+
|
|
422
|
+
if ("getStats" in store && typeof (store as MemoryCacheStore).getStats === "function") {
|
|
423
|
+
return (store as MemoryCacheStore).getStats();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
entries: store.size,
|
|
428
|
+
};
|
|
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
|
+
}
|