@buenojs/bueno 0.8.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/.env.example +109 -0
- package/.github/workflows/ci.yml +31 -0
- package/LICENSE +21 -0
- package/README.md +892 -0
- package/architecture.md +652 -0
- package/bun.lock +70 -0
- package/dist/cli/index.js +3233 -0
- package/dist/index.js +9014 -0
- package/package.json +77 -0
- package/src/cache/index.ts +795 -0
- package/src/cli/ARCHITECTURE.md +837 -0
- package/src/cli/bin.ts +10 -0
- package/src/cli/commands/build.ts +425 -0
- package/src/cli/commands/dev.ts +248 -0
- package/src/cli/commands/generate.ts +541 -0
- package/src/cli/commands/help.ts +55 -0
- package/src/cli/commands/index.ts +112 -0
- package/src/cli/commands/migration.ts +355 -0
- package/src/cli/commands/new.ts +804 -0
- package/src/cli/commands/start.ts +208 -0
- package/src/cli/core/args.ts +283 -0
- package/src/cli/core/console.ts +349 -0
- package/src/cli/core/index.ts +60 -0
- package/src/cli/core/prompt.ts +424 -0
- package/src/cli/core/spinner.ts +265 -0
- package/src/cli/index.ts +135 -0
- package/src/cli/templates/deploy.ts +295 -0
- package/src/cli/templates/docker.ts +307 -0
- package/src/cli/templates/index.ts +24 -0
- package/src/cli/utils/fs.ts +428 -0
- package/src/cli/utils/index.ts +8 -0
- package/src/cli/utils/strings.ts +197 -0
- package/src/config/env.ts +408 -0
- package/src/config/index.ts +506 -0
- package/src/config/loader.ts +329 -0
- package/src/config/merge.ts +285 -0
- package/src/config/types.ts +320 -0
- package/src/config/validation.ts +441 -0
- package/src/container/forward-ref.ts +143 -0
- package/src/container/index.ts +386 -0
- package/src/context/index.ts +360 -0
- package/src/database/index.ts +1142 -0
- package/src/database/migrations/index.ts +371 -0
- package/src/database/schema/index.ts +619 -0
- package/src/frontend/api-routes.ts +640 -0
- package/src/frontend/bundler.ts +643 -0
- package/src/frontend/console-client.ts +419 -0
- package/src/frontend/console-stream.ts +587 -0
- package/src/frontend/dev-server.ts +846 -0
- package/src/frontend/file-router.ts +611 -0
- package/src/frontend/frameworks/index.ts +106 -0
- package/src/frontend/frameworks/react.ts +85 -0
- package/src/frontend/frameworks/solid.ts +104 -0
- package/src/frontend/frameworks/svelte.ts +110 -0
- package/src/frontend/frameworks/vue.ts +92 -0
- package/src/frontend/hmr-client.ts +663 -0
- package/src/frontend/hmr.ts +728 -0
- package/src/frontend/index.ts +342 -0
- package/src/frontend/islands.ts +552 -0
- package/src/frontend/isr.ts +555 -0
- package/src/frontend/layout.ts +475 -0
- package/src/frontend/ssr/react.ts +446 -0
- package/src/frontend/ssr/solid.ts +523 -0
- package/src/frontend/ssr/svelte.ts +546 -0
- package/src/frontend/ssr/vue.ts +504 -0
- package/src/frontend/ssr.ts +699 -0
- package/src/frontend/types.ts +2274 -0
- package/src/health/index.ts +604 -0
- package/src/index.ts +410 -0
- package/src/lock/index.ts +587 -0
- package/src/logger/index.ts +444 -0
- package/src/logger/transports/index.ts +969 -0
- package/src/metrics/index.ts +494 -0
- package/src/middleware/built-in.ts +360 -0
- package/src/middleware/index.ts +94 -0
- package/src/modules/filters.ts +458 -0
- package/src/modules/guards.ts +405 -0
- package/src/modules/index.ts +1256 -0
- package/src/modules/interceptors.ts +574 -0
- package/src/modules/lazy.ts +418 -0
- package/src/modules/lifecycle.ts +478 -0
- package/src/modules/metadata.ts +90 -0
- package/src/modules/pipes.ts +626 -0
- package/src/router/index.ts +339 -0
- package/src/router/linear.ts +371 -0
- package/src/router/regex.ts +292 -0
- package/src/router/tree.ts +562 -0
- package/src/rpc/index.ts +1263 -0
- package/src/security/index.ts +436 -0
- package/src/ssg/index.ts +631 -0
- package/src/storage/index.ts +456 -0
- package/src/telemetry/index.ts +1097 -0
- package/src/testing/index.ts +1586 -0
- package/src/types/index.ts +236 -0
- package/src/types/optional-deps.d.ts +219 -0
- package/src/validation/index.ts +276 -0
- package/src/websocket/index.ts +1004 -0
- package/tests/integration/cli.test.ts +1016 -0
- package/tests/integration/fullstack.test.ts +234 -0
- package/tests/unit/cache.test.ts +174 -0
- package/tests/unit/cli-commands.test.ts +892 -0
- package/tests/unit/cli.test.ts +1258 -0
- package/tests/unit/container.test.ts +279 -0
- package/tests/unit/context.test.ts +221 -0
- package/tests/unit/database.test.ts +183 -0
- package/tests/unit/linear-router.test.ts +280 -0
- package/tests/unit/lock.test.ts +336 -0
- package/tests/unit/middleware.test.ts +184 -0
- package/tests/unit/modules.test.ts +142 -0
- package/tests/unit/pubsub.test.ts +257 -0
- package/tests/unit/regex-router.test.ts +265 -0
- package/tests/unit/router.test.ts +373 -0
- package/tests/unit/rpc.test.ts +1248 -0
- package/tests/unit/security.test.ts +174 -0
- package/tests/unit/telemetry.test.ts +371 -0
- package/tests/unit/test-cache.test.ts +110 -0
- package/tests/unit/test-database.test.ts +282 -0
- package/tests/unit/tree-router.test.ts +325 -0
- package/tests/unit/validation.test.ts +794 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental Static Regeneration (ISR) Implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides ISR capabilities that extend SSG with:
|
|
5
|
+
* - Time-based revalidation
|
|
6
|
+
* - On-demand revalidation
|
|
7
|
+
* - Stale-while-revalidate strategy
|
|
8
|
+
* - Distributed cache support via Bun.redis
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createLogger, type Logger } from "../logger/index.js";
|
|
12
|
+
import type {
|
|
13
|
+
ISRConfig,
|
|
14
|
+
PartialISRConfig,
|
|
15
|
+
ISRCacheEntry,
|
|
16
|
+
ISRPageConfig,
|
|
17
|
+
ISRRevalidationResult,
|
|
18
|
+
ISRStats,
|
|
19
|
+
SSRRenderOptions,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
import { SSRRenderer, createSSRContext } from "./ssr.js";
|
|
22
|
+
import type { SSRContext, RenderResult } from "./types.js";
|
|
23
|
+
|
|
24
|
+
// ============= Constants =============
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CACHE_DIR = ".isr-cache";
|
|
27
|
+
const DEFAULT_REVALIDATE = 3600; // 1 hour
|
|
28
|
+
const DEFAULT_STALE_WHILE_REVALIDATE = 60; // 1 minute
|
|
29
|
+
|
|
30
|
+
// ============= ISR Manager Class =============
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ISR Manager handles incremental static regeneration
|
|
34
|
+
*
|
|
35
|
+
* Features:
|
|
36
|
+
* - Time-based revalidation with configurable TTL
|
|
37
|
+
* - Stale-while-revalidate for instant responses
|
|
38
|
+
* - On-demand revalidation via API or webhook
|
|
39
|
+
* - Distributed cache support via Redis
|
|
40
|
+
*/
|
|
41
|
+
export class ISRManager {
|
|
42
|
+
private config: ISRConfig;
|
|
43
|
+
private logger: Logger;
|
|
44
|
+
private cache: Map<string, ISRCacheEntry> = new Map();
|
|
45
|
+
private pendingRegenerations: Map<string, Promise<ISRRevalidationResult>> = new Map();
|
|
46
|
+
private ssrRenderer: SSRRenderer | null = null;
|
|
47
|
+
private stats = {
|
|
48
|
+
hits: 0,
|
|
49
|
+
misses: 0,
|
|
50
|
+
revalidations: 0,
|
|
51
|
+
staleHits: 0,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
constructor(config: PartialISRConfig) {
|
|
55
|
+
this.config = this.normalizeConfig(config);
|
|
56
|
+
this.logger = createLogger({
|
|
57
|
+
level: "debug",
|
|
58
|
+
pretty: true,
|
|
59
|
+
context: { component: "ISRManager" },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalize partial config to full config with defaults
|
|
65
|
+
*/
|
|
66
|
+
private normalizeConfig(config: PartialISRConfig): ISRConfig {
|
|
67
|
+
return {
|
|
68
|
+
cacheDir: config.cacheDir ?? DEFAULT_CACHE_DIR,
|
|
69
|
+
defaultRevalidate: config.defaultRevalidate ?? DEFAULT_REVALIDATE,
|
|
70
|
+
staleWhileRevalidate: config.staleWhileRevalidate ?? DEFAULT_STALE_WHILE_REVALIDATE,
|
|
71
|
+
maxCacheSize: config.maxCacheSize ?? 1000,
|
|
72
|
+
redis: config.redis,
|
|
73
|
+
redisKeyPrefix: config.redisKeyPrefix ?? "bueno:isr:",
|
|
74
|
+
enabled: config.enabled ?? true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Set the SSR renderer
|
|
80
|
+
*/
|
|
81
|
+
setSSRRenderer(renderer: SSRRenderer): void {
|
|
82
|
+
this.ssrRenderer = renderer;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get a page from cache or render it
|
|
87
|
+
*/
|
|
88
|
+
async getPage(
|
|
89
|
+
url: string,
|
|
90
|
+
request: Request,
|
|
91
|
+
pageConfig?: ISRPageConfig
|
|
92
|
+
): Promise<RenderResult> {
|
|
93
|
+
if (!this.config.enabled) {
|
|
94
|
+
return this.renderPage(url, request);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const cacheKey = this.getCacheKey(url);
|
|
98
|
+
const entry = await this.getCacheEntry(cacheKey);
|
|
99
|
+
|
|
100
|
+
if (entry) {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const age = (now - entry.timestamp) / 1000;
|
|
103
|
+
const revalidate = pageConfig?.revalidate ?? this.config.defaultRevalidate;
|
|
104
|
+
const staleWhileRevalidate = pageConfig?.staleWhileRevalidate ?? this.config.staleWhileRevalidate;
|
|
105
|
+
|
|
106
|
+
// Cache hit - check if stale
|
|
107
|
+
if (age < revalidate) {
|
|
108
|
+
// Fresh cache hit
|
|
109
|
+
this.stats.hits++;
|
|
110
|
+
this.logger.debug(`Cache hit (fresh): ${url}`);
|
|
111
|
+
return entry.result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Stale but within stale-while-revalidate window
|
|
115
|
+
if (age < revalidate + staleWhileRevalidate) {
|
|
116
|
+
this.stats.staleHits++;
|
|
117
|
+
this.logger.debug(`Cache hit (stale): ${url}, revalidating in background`);
|
|
118
|
+
|
|
119
|
+
// Trigger background revalidation
|
|
120
|
+
this.triggerBackgroundRevalidation(url, request, pageConfig);
|
|
121
|
+
|
|
122
|
+
// Return stale content
|
|
123
|
+
return entry.result;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Cache miss or expired - render and cache
|
|
128
|
+
this.stats.misses++;
|
|
129
|
+
this.logger.debug(`Cache miss: ${url}`);
|
|
130
|
+
|
|
131
|
+
const result = await this.renderPage(url, request);
|
|
132
|
+
await this.setCacheEntry(cacheKey, result, pageConfig);
|
|
133
|
+
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Render a page using SSR
|
|
139
|
+
*/
|
|
140
|
+
private async renderPage(url: string, request: Request): Promise<RenderResult> {
|
|
141
|
+
if (!this.ssrRenderer) {
|
|
142
|
+
throw new Error("SSR renderer not configured");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const options: SSRRenderOptions = {
|
|
146
|
+
url,
|
|
147
|
+
request,
|
|
148
|
+
params: {},
|
|
149
|
+
props: {},
|
|
150
|
+
skipStreaming: true,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return this.ssrRenderer.renderWithOptions(options);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Trigger background revalidation
|
|
158
|
+
*/
|
|
159
|
+
private triggerBackgroundRevalidation(
|
|
160
|
+
url: string,
|
|
161
|
+
request: Request,
|
|
162
|
+
pageConfig?: ISRPageConfig
|
|
163
|
+
): void {
|
|
164
|
+
const cacheKey = this.getCacheKey(url);
|
|
165
|
+
|
|
166
|
+
// Don't start if already pending
|
|
167
|
+
if (this.pendingRegenerations.has(cacheKey)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const promise = this.revalidatePage(url, request, pageConfig)
|
|
172
|
+
.finally(() => {
|
|
173
|
+
this.pendingRegenerations.delete(cacheKey);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
this.pendingRegenerations.set(cacheKey, promise);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Revalidate a page
|
|
181
|
+
*/
|
|
182
|
+
async revalidatePage(
|
|
183
|
+
url: string,
|
|
184
|
+
request: Request,
|
|
185
|
+
pageConfig?: ISRPageConfig
|
|
186
|
+
): Promise<ISRRevalidationResult> {
|
|
187
|
+
const cacheKey = this.getCacheKey(url);
|
|
188
|
+
const startTime = Date.now();
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
this.logger.info(`Revalidating: ${url}`);
|
|
192
|
+
this.stats.revalidations++;
|
|
193
|
+
|
|
194
|
+
const result = await this.renderPage(url, request);
|
|
195
|
+
await this.setCacheEntry(cacheKey, result, pageConfig);
|
|
196
|
+
|
|
197
|
+
const duration = Date.now() - startTime;
|
|
198
|
+
this.logger.info(`Revalidated: ${url} in ${duration}ms`);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
success: true,
|
|
202
|
+
url,
|
|
203
|
+
duration,
|
|
204
|
+
timestamp: Date.now(),
|
|
205
|
+
};
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const duration = Date.now() - startTime;
|
|
208
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
209
|
+
this.logger.error(`Revalidation failed: ${url}`, error);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
success: false,
|
|
213
|
+
url,
|
|
214
|
+
duration,
|
|
215
|
+
timestamp: Date.now(),
|
|
216
|
+
error: errorMessage,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Invalidate a specific page
|
|
223
|
+
*/
|
|
224
|
+
async invalidatePage(url: string): Promise<boolean> {
|
|
225
|
+
const cacheKey = this.getCacheKey(url);
|
|
226
|
+
|
|
227
|
+
if (this.config.redis) {
|
|
228
|
+
try {
|
|
229
|
+
await this.config.redis.del(`${this.config.redisKeyPrefix}${cacheKey}`);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
this.logger.error(`Failed to invalidate Redis cache: ${url}`, error);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const deleted = this.cache.delete(cacheKey);
|
|
236
|
+
if (deleted) {
|
|
237
|
+
this.logger.info(`Invalidated: ${url}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return deleted;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Invalidate multiple pages by pattern
|
|
245
|
+
*/
|
|
246
|
+
async invalidatePattern(pattern: string | RegExp): Promise<number> {
|
|
247
|
+
let count = 0;
|
|
248
|
+
const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
|
|
249
|
+
|
|
250
|
+
// Invalidate local cache
|
|
251
|
+
for (const key of this.cache.keys()) {
|
|
252
|
+
if (regex.test(key)) {
|
|
253
|
+
this.cache.delete(key);
|
|
254
|
+
count++;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Invalidate Redis cache if available
|
|
259
|
+
if (this.config.redis) {
|
|
260
|
+
try {
|
|
261
|
+
const keys = await this.config.redis.keys(`${this.config.redisKeyPrefix}*`);
|
|
262
|
+
for (const key of keys) {
|
|
263
|
+
const cacheKey = key.replace(this.config.redisKeyPrefix, "");
|
|
264
|
+
if (regex.test(cacheKey)) {
|
|
265
|
+
await this.config.redis.del(key);
|
|
266
|
+
count++;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch (error) {
|
|
270
|
+
this.logger.error("Failed to invalidate Redis cache by pattern", error);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
this.logger.info(`Invalidated ${count} pages matching pattern: ${pattern}`);
|
|
275
|
+
return count;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Invalidate all pages
|
|
280
|
+
*/
|
|
281
|
+
async invalidateAll(): Promise<void> {
|
|
282
|
+
this.cache.clear();
|
|
283
|
+
this.stats = { hits: 0, misses: 0, revalidations: 0, staleHits: 0 };
|
|
284
|
+
|
|
285
|
+
if (this.config.redis) {
|
|
286
|
+
try {
|
|
287
|
+
const keys = await this.config.redis.keys(`${this.config.redisKeyPrefix}*`);
|
|
288
|
+
if (keys.length > 0) {
|
|
289
|
+
await this.config.redis.del(...keys);
|
|
290
|
+
}
|
|
291
|
+
} catch (error) {
|
|
292
|
+
this.logger.error("Failed to clear Redis cache", error);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.logger.info("All ISR cache invalidated");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Get cache entry
|
|
301
|
+
*/
|
|
302
|
+
private async getCacheEntry(key: string): Promise<ISRCacheEntry | null> {
|
|
303
|
+
// Check local cache first
|
|
304
|
+
const localEntry = this.cache.get(key);
|
|
305
|
+
if (localEntry) {
|
|
306
|
+
return localEntry;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check Redis if available
|
|
310
|
+
if (this.config.redis) {
|
|
311
|
+
try {
|
|
312
|
+
const data = await this.config.redis.get(`${this.config.redisKeyPrefix}${key}`);
|
|
313
|
+
if (data) {
|
|
314
|
+
const entry = JSON.parse(data) as ISRCacheEntry;
|
|
315
|
+
// Cache locally for faster access
|
|
316
|
+
this.cache.set(key, entry);
|
|
317
|
+
return entry;
|
|
318
|
+
}
|
|
319
|
+
} catch (error) {
|
|
320
|
+
this.logger.error("Failed to get Redis cache entry", error);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Set cache entry
|
|
329
|
+
*/
|
|
330
|
+
private async setCacheEntry(
|
|
331
|
+
key: string,
|
|
332
|
+
result: RenderResult,
|
|
333
|
+
pageConfig?: ISRPageConfig
|
|
334
|
+
): Promise<void> {
|
|
335
|
+
const entry: ISRCacheEntry = {
|
|
336
|
+
result,
|
|
337
|
+
timestamp: Date.now(),
|
|
338
|
+
revalidate: pageConfig?.revalidate ?? this.config.defaultRevalidate,
|
|
339
|
+
tags: pageConfig?.tags ?? [],
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// Set local cache
|
|
343
|
+
this.cache.set(key, entry);
|
|
344
|
+
|
|
345
|
+
// Enforce max cache size
|
|
346
|
+
if (this.cache.size > this.config.maxCacheSize) {
|
|
347
|
+
this.evictOldestEntries();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Set Redis cache if available
|
|
351
|
+
if (this.config.redis) {
|
|
352
|
+
try {
|
|
353
|
+
const ttl = entry.revalidate + this.config.staleWhileRevalidate;
|
|
354
|
+
await this.config.redis.set(
|
|
355
|
+
`${this.config.redisKeyPrefix}${key}`,
|
|
356
|
+
JSON.stringify(entry),
|
|
357
|
+
{ EX: ttl }
|
|
358
|
+
);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
this.logger.error("Failed to set Redis cache entry", error);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Evict oldest entries when cache is full
|
|
367
|
+
*/
|
|
368
|
+
private evictOldestEntries(): void {
|
|
369
|
+
const entries = Array.from(this.cache.entries())
|
|
370
|
+
.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
371
|
+
|
|
372
|
+
const toEvict = entries.slice(0, Math.floor(this.config.maxCacheSize * 0.1));
|
|
373
|
+
for (const [key] of toEvict) {
|
|
374
|
+
this.cache.delete(key);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
this.logger.debug(`Evicted ${toEvict.length} cache entries`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Generate cache key from URL
|
|
382
|
+
*/
|
|
383
|
+
private getCacheKey(url: string): string {
|
|
384
|
+
try {
|
|
385
|
+
const parsed = new URL(url, "http://localhost");
|
|
386
|
+
// Normalize URL for caching
|
|
387
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
388
|
+
} catch {
|
|
389
|
+
return url;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Get ISR statistics
|
|
395
|
+
*/
|
|
396
|
+
getStats(): ISRStats {
|
|
397
|
+
return {
|
|
398
|
+
...this.stats,
|
|
399
|
+
cacheSize: this.cache.size,
|
|
400
|
+
pendingRevalidations: this.pendingRegenerations.size,
|
|
401
|
+
hitRate: this.stats.hits + this.stats.misses > 0
|
|
402
|
+
? this.stats.hits / (this.stats.hits + this.stats.misses)
|
|
403
|
+
: 0,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Get all cached URLs
|
|
409
|
+
*/
|
|
410
|
+
getCachedUrls(): string[] {
|
|
411
|
+
return Array.from(this.cache.keys());
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Check if a page is cached
|
|
416
|
+
*/
|
|
417
|
+
isCached(url: string): boolean {
|
|
418
|
+
return this.cache.has(this.getCacheKey(url));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Get cache entry info
|
|
423
|
+
*/
|
|
424
|
+
async getCacheInfo(url: string): Promise<{
|
|
425
|
+
cached: boolean;
|
|
426
|
+
timestamp?: number;
|
|
427
|
+
age?: number;
|
|
428
|
+
revalidate?: number;
|
|
429
|
+
tags?: string[];
|
|
430
|
+
} | null> {
|
|
431
|
+
const entry = await this.getCacheEntry(this.getCacheKey(url));
|
|
432
|
+
if (!entry) {
|
|
433
|
+
return { cached: false };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
cached: true,
|
|
438
|
+
timestamp: entry.timestamp,
|
|
439
|
+
age: (Date.now() - entry.timestamp) / 1000,
|
|
440
|
+
revalidate: entry.revalidate,
|
|
441
|
+
tags: entry.tags,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Revalidate pages by tag
|
|
447
|
+
*/
|
|
448
|
+
async revalidateTag(tag: string): Promise<number> {
|
|
449
|
+
let count = 0;
|
|
450
|
+
|
|
451
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
452
|
+
if (entry.tags.includes(tag)) {
|
|
453
|
+
this.cache.delete(key);
|
|
454
|
+
count++;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
this.logger.info(`Revalidated ${count} pages with tag: ${tag}`);
|
|
459
|
+
return count;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Prune expired entries
|
|
464
|
+
*/
|
|
465
|
+
pruneExpired(): number {
|
|
466
|
+
const now = Date.now();
|
|
467
|
+
let count = 0;
|
|
468
|
+
|
|
469
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
470
|
+
const age = (now - entry.timestamp) / 1000;
|
|
471
|
+
if (age > entry.revalidate + this.config.staleWhileRevalidate) {
|
|
472
|
+
this.cache.delete(key);
|
|
473
|
+
count++;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (count > 0) {
|
|
478
|
+
this.logger.debug(`Pruned ${count} expired cache entries`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return count;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Get configuration
|
|
486
|
+
*/
|
|
487
|
+
getConfig(): ISRConfig {
|
|
488
|
+
return { ...this.config };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Check if ISR is enabled
|
|
493
|
+
*/
|
|
494
|
+
isEnabled(): boolean {
|
|
495
|
+
return this.config.enabled;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ============= Factory Function =============
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Create an ISR manager
|
|
503
|
+
*/
|
|
504
|
+
export function createISRManager(config: PartialISRConfig): ISRManager {
|
|
505
|
+
return new ISRManager(config);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ============= Utility Functions =============
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Parse revalidation header
|
|
512
|
+
* Supports formats like:
|
|
513
|
+
* - "60" (seconds)
|
|
514
|
+
* - "60, stale-while-revalidate=30"
|
|
515
|
+
*/
|
|
516
|
+
export function parseRevalidateHeader(header: string): {
|
|
517
|
+
revalidate: number;
|
|
518
|
+
staleWhileRevalidate: number;
|
|
519
|
+
} {
|
|
520
|
+
const parts = header.split(",").map(p => p.trim());
|
|
521
|
+
let revalidate = DEFAULT_REVALIDATE;
|
|
522
|
+
let staleWhileRevalidate = DEFAULT_STALE_WHILE_REVALIDATE;
|
|
523
|
+
|
|
524
|
+
for (const part of parts) {
|
|
525
|
+
if (part.includes("stale-while-revalidate=")) {
|
|
526
|
+
staleWhileRevalidate = parseInt(part.split("=")[1], 10);
|
|
527
|
+
} else {
|
|
528
|
+
revalidate = parseInt(part, 10);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return { revalidate, staleWhileRevalidate };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Generate Cache-Control header for ISR
|
|
537
|
+
*/
|
|
538
|
+
export function generateCacheControlHeader(
|
|
539
|
+
revalidate: number,
|
|
540
|
+
staleWhileRevalidate: number
|
|
541
|
+
): string {
|
|
542
|
+
return `public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=${staleWhileRevalidate}`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Check if a page should be regenerated
|
|
547
|
+
*/
|
|
548
|
+
export function shouldRegenerate(
|
|
549
|
+
entry: ISRCacheEntry,
|
|
550
|
+
revalidate: number,
|
|
551
|
+
staleWhileRevalidate: number
|
|
552
|
+
): boolean {
|
|
553
|
+
const age = (Date.now() - entry.timestamp) / 1000;
|
|
554
|
+
return age > revalidate && age <= revalidate + staleWhileRevalidate;
|
|
555
|
+
}
|