Skip to content

Commit 2b8ddad

Browse files
committed
feat: add etag
1 parent acccac6 commit 2b8ddad

File tree

3 files changed

+110
-5
lines changed

3 files changed

+110
-5
lines changed

src/app.test.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,24 @@ describe("GET /health", () => {
66
const app = createApp();
77
const request = new supertest.agent(app);
88

9-
it("responds with 200 OK", async () => {
10-
await expect(request.get("/health")).resolves.toMatchObject({
11-
status: 200,
12-
body: { status: "ok" }
13-
});
9+
it("responds with 200 OK and sets ETag", async () => {
10+
const res = await request.get("/health");
11+
expect(res.status).toBe(200);
12+
expect(res.body).toEqual({ status: "ok" });
13+
expect(res.headers).toHaveProperty("etag");
14+
expect(typeof res.headers["etag"]).toBe("string");
15+
});
16+
17+
it("responds with 304 Not Modified when If-None-Match matches", async () => {
18+
const first = await request.get("/health");
19+
const etag = first.headers["etag"] as string;
20+
expect(etag).toBeTruthy();
21+
22+
const second = await request.get("/health").set("If-None-Match", etag);
23+
expect(second.status).toBe(304);
24+
// No body on 304
25+
expect(second.body).toEqual({});
26+
// ETag should still be present (some servers echo it)
27+
expect(second.headers["etag"]).toBe(etag);
1428
});
1529
});

src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import cors from "cors";
33
import express, { Application } from "express";
44

55
import { errorMiddleware } from "./middleware/error";
6+
import etagMiddleware from "./middleware/etag";
67
import logMiddleware from "./middleware/logger";
78
import { router } from "./routes";
89

@@ -12,5 +13,6 @@ export const createApp = (): Application =>
1213
.use(bodyParser.json())
1314
.use(bodyParser.urlencoded({ extended: true }))
1415
.use(logMiddleware)
16+
.use(etagMiddleware)
1517
.use(errorMiddleware)
1618
.use(router);

src/middleware/etag.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import crypto from "crypto";
2+
3+
import { NextFunction, Request, Response } from "express";
4+
5+
/**
6+
* ETag middleware
7+
* Generates a strong ETag for JSON / text / javascript / html responses when one is not already set.
8+
* Skips if the response already has an ETag, is a HEAD request, or the status code implies no body.
9+
*/
10+
export function etagMiddleware(
11+
req: Request,
12+
res: Response,
13+
next: NextFunction
14+
) {
15+
// capture original send
16+
const originalSend = res.send.bind(res);
17+
18+
res.send = function patchedSend(body?: any): Response {
19+
try {
20+
if (
21+
req.method !== "HEAD" &&
22+
!res.getHeader("ETag") &&
23+
shouldHaveEntityBody(res.statusCode) &&
24+
body !== undefined
25+
) {
26+
const contentType = (res.getHeader("Content-Type") || "").toString();
27+
if (/json|text|javascript|xml|html/.test(contentType)) {
28+
const buf = toBuffer(body, contentType);
29+
const etag = generateStrongETag(buf);
30+
res.setHeader("ETag", etag);
31+
32+
// Conditional request handling (If-None-Match)
33+
const ifNoneMatch = req.headers["if-none-match"];
34+
if (ifNoneMatch && etagMatches(ifNoneMatch, etag)) {
35+
// Per RFC7232: 304 MUST NOT include message-body
36+
res.statusCode = 304;
37+
// Remove headers that only make sense with a body
38+
res.removeHeader("Content-Type");
39+
res.removeHeader("Content-Length");
40+
return originalSend();
41+
}
42+
}
43+
}
44+
} catch {
45+
// fail silently; do not block response on ETag failures
46+
}
47+
return originalSend(body);
48+
} as any;
49+
50+
next();
51+
}
52+
53+
function shouldHaveEntityBody(statusCode?: number) {
54+
if (!statusCode) return true;
55+
return ![204, 205, 304].includes(statusCode);
56+
}
57+
58+
function toBuffer(body: any, contentType: string): Buffer {
59+
if (Buffer.isBuffer(body)) return body;
60+
if (typeof body === "string") return Buffer.from(body);
61+
// assume json-like
62+
if (/json/.test(contentType)) return Buffer.from(JSON.stringify(body));
63+
return Buffer.from(String(body));
64+
}
65+
66+
function generateStrongETag(content: Buffer): string {
67+
const hash = crypto.createHash("sha256").update(content).digest("base64");
68+
// shorten without losing much uniqueness (optional)
69+
const short = hash.replace(/=+$/, "").slice(0, 27);
70+
return '"' + short + '"';
71+
}
72+
73+
function etagMatches(ifNoneMatchHeader: string | string[], current: string) {
74+
const header = Array.isArray(ifNoneMatchHeader)
75+
? ifNoneMatchHeader.join(",")
76+
: ifNoneMatchHeader;
77+
if (header.trim() === "*") return true;
78+
// Header may contain multiple comma-separated ETags possibly with weak validators (W/)
79+
return header
80+
.split(",")
81+
.map((v) => v.trim())
82+
.some((tag) => stripWeak(tag) === current);
83+
}
84+
85+
function stripWeak(tag: string) {
86+
return tag.startsWith("W/") ? tag.slice(2) : tag;
87+
}
88+
89+
export default etagMiddleware;

0 commit comments

Comments
 (0)