Skip to content

Commit a47a069

Browse files
committed
Add generateETags, shouldGenerateETag, maxETagBodySize and skipETagContentTypes options in cache middleware
1 parent eaa324b commit a47a069

File tree

10 files changed

+661
-58
lines changed

10 files changed

+661
-58
lines changed

bun.lock

Lines changed: 9 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/deno.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@ const app = new Web();
66

77
app.use(cors());
88

9-
app.use(
10-
cache({
11-
//hashAlgorithm: "blake2b",
12-
})
13-
);
9+
app.use(cache());
1410

1511
app.get("/", async (ctx) => ctx.html(`<h1>Hello World from ${ctx.clientIp}</h1>`));
1612

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@rabbit-company/web-monorepo",
3-
"version": "0.13.0",
3+
"version": "0.14.0",
44
"description": "High-performance web framework monorepo",
55
"private": true,
66
"type": "module",
@@ -51,8 +51,8 @@
5151
"typescript": "^5.8.3",
5252
"@rabbit-company/logger": "^5.5.0",
5353
"bun-plugin-dts": "^0.3.0",
54-
"hono": "^4.7.11",
55-
"elysia": "^1.3.4"
54+
"hono": "^4.8.2",
55+
"elysia": "^1.3.5"
5656
},
5757
"dependencies": {
5858
"@rabbit-company/rate-limiter": "^3.0.0"

packages/core/jsr.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@rabbit-company/web",
3-
"version": "0.13.0",
3+
"version": "0.14.0",
44
"license": "MIT",
55
"exports": "./src/index.ts",
66
"publish": {

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@rabbit-company/web",
3-
"version": "0.13.0",
3+
"version": "0.14.0",
44
"description": "High-performance web framework",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

packages/middleware/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,10 @@ app.use(cache({ storage: new CustomStorage() }));
341341
- `staleWhileRevalidate`: Serve stale content while revalidating (default: false)
342342
- `maxStaleAge`: Maximum stale age in seconds (default: 86400)
343343
- `hashAlgorithm`: Hash algorithm to use for ETag generation (default: blake2b512)
344+
- `generateETags`: Whether to generate ETags for responses (default: true)
345+
- `shouldGenerateETag`: Function to determine if ETag should be generated for a specific response (default: Smart function that skips large files and certain content types)
346+
- `maxETagBodySize`: Maximum body size in bytes for ETag generation (default: 1048576)
347+
- `skipETagContentTypes`: Content types to skip ETag generation for (default: ['image/', 'video/', 'audio/', 'application/octet-stream', 'application/zip', 'application/pdf'])
344348
345349
#### Features:
346350

packages/middleware/jsr.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@rabbit-company/web-middleware",
3-
"version": "0.13.0",
3+
"version": "0.14.0",
44
"license": "MIT",
55
"exports": {
66
"./basic-auth": "./src/basic-auth.ts",

packages/middleware/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@rabbit-company/web-middleware",
3-
"version": "0.13.0",
3+
"version": "0.14.0",
44
"description": "Official middleware collection for Rabbit Company Web Framework",
55
"type": "module",
66
"homepage": "https://github.com/Rabbit-Company/Web-JS",
@@ -83,7 +83,7 @@
8383
"rate-limit"
8484
],
8585
"peerDependencies": {
86-
"@rabbit-company/web": "^0.13.0"
86+
"@rabbit-company/web": "^0.14.0"
8787
},
8888
"dependencies": {
8989
"@rabbit-company/rate-limiter": "^3.0.0",

packages/middleware/src/cache.ts

Lines changed: 131 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@ type KeyGenerator = (ctx: Context<any>) => string;
8787
*/
8888
type ShouldCacheFunction = (ctx: Context<any>, res: Response) => boolean;
8989

90+
/**
91+
* Function signature for determining if ETag should be generated
92+
* @callback ShouldGenerateETagFunction
93+
* @param {Context<any>} ctx - The request context
94+
* @param {Response} res - The response
95+
* @returns {boolean} True if ETag should be generated
96+
*/
97+
type ShouldGenerateETagFunction = (ctx: Context<any>, res: Response) => boolean;
98+
9099
/**
91100
* Cache middleware configuration options
92101
*
@@ -183,6 +192,30 @@ export interface CacheConfig {
183192
* @see getAvailableHashAlgorithms() for available algorithms
184193
*/
185194
hashAlgorithm?: string;
195+
196+
/**
197+
* Whether to generate ETags for responses
198+
* @default true
199+
*/
200+
generateETags?: boolean;
201+
202+
/**
203+
* Function to determine if ETag should be generated for a specific response
204+
* @default Smart function that skips large files and certain content types
205+
*/
206+
shouldGenerateETag?: ShouldGenerateETagFunction;
207+
208+
/**
209+
* Maximum body size for ETag generation (in bytes)
210+
* @default 1048576 (1MB)
211+
*/
212+
maxETagBodySize?: number;
213+
214+
/**
215+
* Content types to skip ETag generation for
216+
* @default ['image/', 'video/', 'audio/', 'application/octet-stream', 'application/zip', 'application/pdf']
217+
*/
218+
skipETagContentTypes?: string[];
186219
}
187220

188221
/**
@@ -548,6 +581,51 @@ function matchesPath(path: string, patterns: (string | RegExp)[]): boolean {
548581
});
549582
}
550583

584+
/**
585+
* Default function to determine if ETag should be generated
586+
* @param {Context<any>} ctx - The request context
587+
* @param {Response} res - The response
588+
* @param {number} maxBodySize - Maximum body size for ETag generation
589+
* @param {string[]} skipContentTypes - Content types to skip
590+
* @returns {boolean} True if ETag should be generated
591+
*/
592+
function defaultShouldGenerateETag(ctx: Context<any>, res: Response, maxBodySize: number, skipContentTypes: string[]): boolean {
593+
// Skip if already has ETag
594+
if (res.headers.get("etag")) {
595+
return false;
596+
}
597+
598+
// Check content type
599+
const contentType = res.headers.get("content-type")?.toLowerCase() || "";
600+
for (const skipType of skipContentTypes) {
601+
if (contentType.includes(skipType.toLowerCase())) {
602+
return false;
603+
}
604+
}
605+
606+
// Check content length if available
607+
const contentLength = res.headers.get("content-length");
608+
if (contentLength) {
609+
const size = parseInt(contentLength, 10);
610+
if (!isNaN(size) && size > maxBodySize) {
611+
return false;
612+
}
613+
}
614+
615+
// Check if it's a range request
616+
if (res.status === 206 || ctx.req.headers.get("range")) {
617+
return false;
618+
}
619+
620+
// Check if response is streaming
621+
const transferEncoding = res.headers.get("transfer-encoding");
622+
if (transferEncoding && transferEncoding.includes("chunked")) {
623+
return false;
624+
}
625+
626+
return true;
627+
}
628+
551629
/**
552630
* Cache middleware factory
553631
*
@@ -576,6 +654,22 @@ function matchesPath(path: string, patterns: (string | RegExp)[]): boolean {
576654
* staleWhileRevalidate: true,
577655
* maxStaleAge: 3600 // 1 hour
578656
* }));
657+
*
658+
* // Disable ETags for large files
659+
* app.use(cache({
660+
* generateETags: true,
661+
* maxETagBodySize: 512 * 1024, // 512KB
662+
* skipETagContentTypes: ['image/', 'video/', 'audio/']
663+
* }));
664+
*
665+
* // Custom ETag logic
666+
* app.use(cache({
667+
* shouldGenerateETag: (ctx, res) => {
668+
* // Only generate ETags for JSON responses
669+
* const contentType = res.headers.get('content-type') || '';
670+
* return contentType.includes('application/json');
671+
* }
672+
* }));
579673
* ```
580674
*/
581675
export function cache<T extends Record<string, unknown> = Record<string, unknown>, B extends Record<string, unknown> = Record<string, unknown>>(
@@ -598,6 +692,10 @@ export function cache<T extends Record<string, unknown> = Record<string, unknown
598692
staleWhileRevalidate: false,
599693
maxStaleAge: 86400,
600694
hashAlgorithm: "blake2b512",
695+
generateETags: true,
696+
shouldGenerateETag: (ctx: Context<any>, res: Response) => defaultShouldGenerateETag(ctx, res, options.maxETagBodySize, options.skipETagContentTypes),
697+
maxETagBodySize: 1048576, // 1MB
698+
skipETagContentTypes: ["image/", "video/", "audio/", "application/octet-stream", "application/zip", "application/pdf", "application/x-", "font/"],
601699
...config,
602700
};
603701

@@ -635,11 +733,7 @@ export function cache<T extends Record<string, unknown> = Record<string, unknown
635733
const cloned = response.clone();
636734
const body = await cloned.text();
637735

638-
// Generate ETag if not present
639-
let etag = response.headers.get("etag");
640-
if (!etag) {
641-
etag = await generateETag(body, options.hashAlgorithm);
642-
}
736+
const etag = response.headers.get("etag") || undefined;
643737

644738
// Extract headers
645739
const headers: Record<string, string> = {};
@@ -828,34 +922,39 @@ export function cache<T extends Record<string, unknown> = Record<string, unknown
828922
response.headers.set(options.cacheHeaderName, "MISS");
829923
}
830924

831-
// Generate and set ETag if not present
832-
if (options.shouldCache(ctx, response) && !response.headers.get("etag")) {
833-
// Clone response to read body for ETag generation
834-
const cloned = response.clone();
835-
const body = await cloned.text();
836-
const etag = await generateETag(body, options.hashAlgorithm);
837-
838-
// Create new response with ETag header
839-
const headers = new Headers(response.headers);
840-
headers.set("etag", etag);
841-
842-
// Store in background (don't block response)
843-
const responseWithEtag = new Response(body, {
844-
status: response.status,
845-
statusText: response.statusText,
846-
headers,
847-
});
848-
849-
// Cache the response with ETag
850-
storeCachedResponse(cacheKey, responseWithEtag).catch(() => {
851-
// Ignore storage errors
852-
});
853-
854-
return responseWithEtag;
855-
}
856-
857-
// Cache the original response if it already has an ETag
925+
// Check if we should cache this response
858926
if (options.shouldCache(ctx, response)) {
927+
// Check if we should generate ETag
928+
if (options.generateETags && options.shouldGenerateETag && options.shouldGenerateETag(ctx, response)) {
929+
// Clone response to read body for ETag generation
930+
const cloned = response.clone();
931+
const body = await cloned.text();
932+
933+
// Only generate ETag if body is within size limit
934+
if (body.length <= options.maxETagBodySize) {
935+
const etag = await generateETag(body, options.hashAlgorithm);
936+
937+
// Create new response with ETag header
938+
const headers = new Headers(response.headers);
939+
headers.set("etag", etag);
940+
941+
// Store in background (don't block response)
942+
const responseWithEtag = new Response(body, {
943+
status: response.status,
944+
statusText: response.statusText,
945+
headers,
946+
});
947+
948+
// Cache the response with ETag
949+
storeCachedResponse(cacheKey, responseWithEtag).catch(() => {
950+
// Ignore storage errors
951+
});
952+
953+
return responseWithEtag;
954+
}
955+
}
956+
957+
// Cache the original response without ETag generation
859958
storeCachedResponse(cacheKey, response.clone()).catch(() => {
860959
// Ignore storage errors
861960
});

0 commit comments

Comments
 (0)