Skip to content

Commit ba20d9b

Browse files
committed
fix(server): honor static freshness validators
1 parent 057c6c6 commit ba20d9b

4 files changed

Lines changed: 174 additions & 8 deletions

File tree

packages/vinext/src/server/http-conditional.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,42 @@ export function matchesIfNoneMatch(ifNoneMatch: string | undefined, etag: string
1414
});
1515
}
1616

17+
type StaticConditionalHeaders = {
18+
cacheControl?: string | string[];
19+
ifNoneMatch?: string | string[];
20+
ifModifiedSince?: string | string[];
21+
};
22+
23+
/**
24+
* Determine whether a static representation is fresh for a GET/HEAD request.
25+
*
26+
* If-None-Match takes precedence over If-Modified-Since, and Cache-Control:
27+
* no-cache forces the representation to be transferred even when a validator
28+
* matches. Modification times are compared at HTTP-date (whole-second)
29+
* precision.
30+
*/
31+
export function isFreshStaticRequest(
32+
headers: StaticConditionalHeaders,
33+
etag: string,
34+
mtimeMs: number,
35+
): boolean {
36+
const cacheControl = joinHeader(headers.cacheControl);
37+
if (cacheControl && /(?:^|,)\s*no-cache\s*(?:,|$)/i.test(cacheControl)) return false;
38+
39+
const ifNoneMatch = joinHeader(headers.ifNoneMatch);
40+
if (ifNoneMatch !== undefined) return matchesIfNoneMatch(ifNoneMatch, etag);
41+
42+
const ifModifiedSince = joinHeader(headers.ifModifiedSince);
43+
if (!ifModifiedSince) return false;
44+
const modifiedSinceMs = Date.parse(ifModifiedSince);
45+
if (Number.isNaN(modifiedSinceMs)) return false;
46+
return Math.floor(mtimeMs / 1000) <= Math.floor(modifiedSinceMs / 1000);
47+
}
48+
49+
function joinHeader(value: string | string[] | undefined): string | undefined {
50+
return Array.isArray(value) ? value.join(", ") : value;
51+
}
52+
1753
function stripWeakPrefix(value: string): string {
1854
return value.startsWith("W/") ? value.slice(2) : value;
1955
}

packages/vinext/src/server/prod-server.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ import {
9393
selectContentEncoding,
9494
} from "./accept-encoding.js";
9595
import { ifRangeAllowsRange, parseByteRange, type ByteRange } from "./http-range.js";
96-
import { matchesIfNoneMatch } from "./http-conditional.js";
96+
import { isFreshStaticRequest } from "./http-conditional.js";
9797
import type { NextI18nConfig } from "../config/next-config.js";
9898
import { readTrustedRevalidationHostname } from "./revalidation-host.js";
9999

@@ -636,11 +636,17 @@ async function tryServeStatic(
636636
? entry.gz!
637637
: entry.original;
638638

639-
const ifNoneMatch = req.headers["if-none-match"];
640639
if (
641640
responseStatus === 200 &&
642-
typeof ifNoneMatch === "string" &&
643-
matchesIfNoneMatch(ifNoneMatch, entry.etag)
641+
isFreshStaticRequest(
642+
{
643+
cacheControl: req.headers["cache-control"],
644+
ifNoneMatch: req.headers["if-none-match"],
645+
ifModifiedSince: req.headers["if-modified-since"],
646+
},
647+
entry.etag,
648+
entry.mtimeMs,
649+
)
644650
) {
645651
const notModifiedHeaders = variesByEncoding
646652
? mergeVaryHeader({ ...entry.notModifiedHeaders, ...extraHeaders }, "Accept-Encoding")
@@ -766,11 +772,17 @@ async function tryServeStatic(
766772
);
767773

768774
const encoding = isCompressible ? negotiateEncoding(req) : "identity";
769-
const ifNoneMatch = req.headers["if-none-match"];
770775
if (
771776
responseStatus === 200 &&
772-
typeof ifNoneMatch === "string" &&
773-
matchesIfNoneMatch(ifNoneMatch, etag)
777+
isFreshStaticRequest(
778+
{
779+
cacheControl: req.headers["cache-control"],
780+
ifNoneMatch: req.headers["if-none-match"],
781+
ifModifiedSince: req.headers["if-modified-since"],
782+
},
783+
etag,
784+
resolved.mtimeMs,
785+
)
774786
) {
775787
const notModifiedHeaders = isCompressible
776788
? mergeVaryHeader(baseHeaders, "Accept-Encoding")

tests/http-conditional.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, it } from "vitest";
2-
import { matchesIfNoneMatch } from "../packages/vinext/src/server/http-conditional.js";
2+
import {
3+
isFreshStaticRequest,
4+
matchesIfNoneMatch,
5+
} from "../packages/vinext/src/server/http-conditional.js";
36

47
describe("matchesIfNoneMatch", () => {
58
it("matches an identical entity tag", () => {
@@ -33,3 +36,55 @@ describe("matchesIfNoneMatch", () => {
3336
expect(matchesIfNoneMatch("", '"asset"')).toBe(false);
3437
});
3538
});
39+
40+
describe("isFreshStaticRequest", () => {
41+
const mtimeMs = Date.parse("2026-07-26T12:34:56.789Z");
42+
43+
it("uses If-None-Match before If-Modified-Since", () => {
44+
expect(
45+
isFreshStaticRequest(
46+
{
47+
ifNoneMatch: '"different"',
48+
ifModifiedSince: "Mon, 27 Jul 2026 12:34:56 GMT",
49+
},
50+
'"asset"',
51+
mtimeMs,
52+
),
53+
).toBe(false);
54+
});
55+
56+
it("matches If-Modified-Since at whole-second precision", () => {
57+
expect(
58+
isFreshStaticRequest(
59+
{ ifModifiedSince: "Sun, 26 Jul 2026 12:34:56 GMT" },
60+
'"asset"',
61+
mtimeMs,
62+
),
63+
).toBe(true);
64+
expect(
65+
isFreshStaticRequest(
66+
{ ifModifiedSince: "Sun, 26 Jul 2026 12:34:55 GMT" },
67+
'"asset"',
68+
mtimeMs,
69+
),
70+
).toBe(false);
71+
});
72+
73+
it("ignores invalid If-Modified-Since values", () => {
74+
expect(isFreshStaticRequest({ ifModifiedSince: "not a date" }, '"asset"', mtimeMs)).toBe(false);
75+
});
76+
77+
it("honors Cache-Control: no-cache despite matching validators", () => {
78+
expect(
79+
isFreshStaticRequest(
80+
{
81+
cacheControl: "max-age=0, No-Cache",
82+
ifNoneMatch: 'W/"asset"',
83+
ifModifiedSince: "Mon, 27 Jul 2026 12:34:56 GMT",
84+
},
85+
'"asset"',
86+
mtimeMs,
87+
),
88+
).toBe(false);
89+
});
90+
});

tests/serve-static.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,39 @@ describe("tryServeStatic (with StaticFileCache)", () => {
424424
expect(captured.status).toBe(200);
425425
});
426426

427+
it("returns 304 when a cached asset has not changed since If-Modified-Since", async () => {
428+
await writeFile(clientDir, "conditional.txt", "cached content");
429+
const cache = await StaticFileCache.create(clientDir);
430+
const entry = cache.lookup("/conditional.txt")!;
431+
const req = mockReq(undefined, {
432+
"if-modified-since": entry.original.headers["Last-Modified"],
433+
});
434+
const { res, captured } = mockRes();
435+
436+
await tryServeStatic(req, res, clientDir, "/conditional.txt", true, cache);
437+
await captured.ended;
438+
439+
expect(captured.status).toBe(304);
440+
expect(captured.headers["Last-Modified"]).toBe(entry.original.headers["Last-Modified"]);
441+
});
442+
443+
it("honors Cache-Control: no-cache when a cached asset ETag matches", async () => {
444+
await writeFile(clientDir, "forced-revalidation.txt", "cached content");
445+
const cache = await StaticFileCache.create(clientDir);
446+
const entry = cache.lookup("/forced-revalidation.txt")!;
447+
const req = mockReq(undefined, {
448+
"cache-control": "no-cache",
449+
"if-none-match": entry.etag,
450+
});
451+
const { res, captured } = mockRes();
452+
453+
await tryServeStatic(req, res, clientDir, "/forced-revalidation.txt", true, cache);
454+
await captured.ended;
455+
456+
expect(captured.status).toBe(200);
457+
expect(captured.body.toString()).toBe("cached content");
458+
});
459+
427460
it("304 response excludes Content-Type per RFC 9110", async () => {
428461
await writeFile(clientDir, "_next/static/rfc-aaa111.js", "rfc content");
429462

@@ -1026,6 +1059,36 @@ describe("tryServeStatic (with StaticFileCache)", () => {
10261059
expect(captured.headers.Vary).toBe("Accept-Encoding");
10271060
});
10281061

1062+
it("supports If-Modified-Since on the slow path", async () => {
1063+
await writeFile(clientDir, "conditional-slow.txt", "slow content");
1064+
const stat = await fsp.stat(path.join(clientDir, "conditional-slow.txt"));
1065+
const req = mockReq(undefined, {
1066+
"if-modified-since": new Date(stat.mtimeMs).toUTCString(),
1067+
});
1068+
const { res, captured } = mockRes();
1069+
1070+
await tryServeStatic(req, res, clientDir, "/conditional-slow.txt", true);
1071+
await captured.ended;
1072+
1073+
expect(captured.status).toBe(304);
1074+
expect(captured.headers["Last-Modified"]).toBe(new Date(stat.mtimeMs).toUTCString());
1075+
});
1076+
1077+
it("honors Cache-Control: no-cache on the slow path", async () => {
1078+
await writeFile(clientDir, "_next/static/no-cache-slow-abc123.js", "slow content");
1079+
const req = mockReq(undefined, {
1080+
"cache-control": "no-cache",
1081+
"if-none-match": 'W/"abc123"',
1082+
});
1083+
const { res, captured } = mockRes();
1084+
1085+
await tryServeStatic(req, res, clientDir, "/_next/static/no-cache-slow-abc123.js", true);
1086+
await captured.ended;
1087+
1088+
expect(captured.status).toBe(200);
1089+
expect(captured.body.toString()).toBe("slow content");
1090+
});
1091+
10291092
it("slow path 304 omits Vary for non-compressible content (compress=false)", async () => {
10301093
await writeFile(clientDir, "photo.jpg", Buffer.alloc(100, 0xff));
10311094

0 commit comments

Comments
 (0)