Compare commits

...

6 commits
main ... stable

4 changed files with 147 additions and 21 deletions

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018-2024 SVR.JS
Copyright (c) 2018-2025 SVR.JS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -1,4 +1,4 @@
{
"name": "SVR.JS Cache mod",
"version": "Nightly-GitMain"
"version": "1.1.1"
}

View file

@ -15,6 +15,9 @@ module.exports = function (req, res, logFacilities, config, next) {
const cacheVaryHeadersConfigured = config.cacheVaryHeaders
? config.cacheVaryHeaders
: [];
const cacheIgnoreHeadersConfigured = config.cacheIgnoreHeaders
? config.cacheIgnoreHeaders
: [];
const maximumCachedResponseSize = config.maximumCachedResponseSize
? config.maximumCachedResponseSize
: null;
@ -68,28 +71,44 @@ module.exports = function (req, res, logFacilities, config, next) {
}
// Capture the response
const originalSetHeader = res.setHeader.bind(res);
const originalRemoveHeader = res.removeHeader.bind(res);
const originalWriteHead = res.writeHead.bind(res);
const originalWrite = res.write.bind(res);
const originalEnd = res.end.bind(res);
let writtenHeaders = res.getHeaders();
let writtenStatusCode = 200;
let responseBody = "";
let maximumCachedResponseSizeExceeded = false;
let piping = false;
res.setHeader = function (name, value) {
writtenHeaders[name.toLowerCase()] = value;
return originalSetHeader(name, value);
};
res.removeHeader = function (name) {
delete writtenHeaders[name.toLowerCase()];
return originalRemoveHeader(name);
};
res.writeHead = function (statusCode, statusCodeDescription, headers) {
const properHeaders = headers ? headers : statusCodeDescription;
if (typeof properHeaders === "object" && properHeaders !== null) {
Object.keys(properHeaders).forEach((key) => {
writtenHeaders[key.toLowerCase()] = properHeaders[key];
});
}
writtenStatusCode = statusCode;
res.setHeader("X-SVRJS-Cache", "MISS");
if (headers) {
originalWriteHead(
originalSetHeader("X-SVRJS-Cache", "MISS");
if (headers || typeof statusCodeDescription !== "object") {
return originalWriteHead(
writtenStatusCode,
statusCodeDescription,
writtenHeaders
);
} else {
originalWriteHead(writtenStatusCode, writtenHeaders);
return originalWriteHead(writtenStatusCode, writtenHeaders);
}
};
@ -158,6 +177,12 @@ module.exports = function (req, res, logFacilities, config, next) {
.join("\n");
varyCache.set(cacheKey, processedVary);
// Ignore headers
cacheIgnoreHeadersConfigured.forEach((header) => {
delete writtenHeaders[header.toLowerCase()];
});
cache.set(cacheKeyWithVary, {
body: responseBody,
headers: writtenHeaders,
@ -168,11 +193,38 @@ module.exports = function (req, res, logFacilities, config, next) {
}
}
originalEnd(chunk, encoding, callback);
return originalEnd(chunk, encoding, callback);
};
if (req.method != "HEAD") {
res.write = function (chunk, encoding, callback) {
if (!piping && chunk && !maximumCachedResponseSizeExceeded) {
const processedChunk = Buffer.from(
chunk,
typeof encoding === "string" ? encoding : undefined
).toString("latin1");
if (
maximumCachedResponseSize !== null &&
maximumCachedResponseSize !== undefined &&
responseBody.length + processedChunk.length >
maximumCachedResponseSize
) {
maximumCachedResponseSizeExceeded = true;
} else {
try {
responseBody += processedChunk;
// eslint-disable-next-line no-unused-vars
} catch (err) {
maximumCachedResponseSizeExceeded = true;
}
}
}
return originalWrite(chunk, encoding, callback);
};
res.on("pipe", (src) => {
piping = true;
src.on("data", (chunk) => {
if (!maximumCachedResponseSizeExceeded) {
const processedChunk = Buffer.from(chunk).toString("latin1");
@ -194,6 +246,10 @@ module.exports = function (req, res, logFacilities, config, next) {
}
});
});
res.on("unpipe", () => {
piping = false;
});
}
next(); // Continue with normal processing
@ -212,6 +268,9 @@ module.exports.configValidators = {
cacheVaryHeaders: (value) =>
Array.isArray(value) &&
value.every((element) => typeof element === "string"),
cacheIgnoreHeaders: (value) =>
Array.isArray(value) &&
value.every((element) => typeof element === "string"),
maximumCachedResponseSize: (value) =>
typeof value === "number" || value === null
};

View file

@ -14,12 +14,9 @@ jest.mock("../src/utils/cacheControlUtils.js", () => ({
}));
describe("SVR.JS Cache mod", () => {
let req, res, logFacilities, config, next, resWriteHead, resEnd;
let req, res, logFacilities, config, next;
beforeEach(() => {
resWriteHead = jest.fn();
resEnd = jest.fn();
req = {
method: "GET",
headers: {},
@ -29,8 +26,9 @@ describe("SVR.JS Cache mod", () => {
res = {
headers: {},
writeHead: resWriteHead,
end: resEnd,
writeHead: jest.fn(),
write: jest.fn(),
end: jest.fn(),
setHeader: jest.fn(),
getHeaderNames: jest.fn(() => []),
getHeaders: jest.fn(() => ({})),
@ -42,6 +40,7 @@ describe("SVR.JS Cache mod", () => {
config = {
cacheVaryHeaders: ["accept"],
cacheIgnoreHeaders: [],
maximumCachedResponseSize: 1024
};
@ -95,6 +94,11 @@ describe("SVR.JS Cache mod", () => {
// Reset mocks for the second invocation
jest.clearAllMocks();
next.mockReset();
res.setHeader = jest.fn();
res.removeHeader = jest.fn();
res.writeHead = jest.fn();
res.write = jest.fn();
res.end = jest.fn();
// Second request: retrieve from cache
parseCacheControl.mockReturnValue({});
@ -107,14 +111,69 @@ describe("SVR.JS Cache mod", () => {
"The response is cached."
);
expect(res.setHeader).toHaveBeenCalledWith("X-SVRJS-Cache", "HIT");
expect(resWriteHead).toHaveBeenCalledWith(200, {
expect(res.writeHead).toHaveBeenCalledWith(200, {
"cache-control": "max-age=300",
"content-type": "application/json"
});
expect(resEnd).toHaveBeenCalledWith(
Buffer.from("cached response body", "latin1"),
undefined,
undefined
expect(res.end).toHaveBeenCalledWith(
Buffer.from("cached response body", "latin1")
);
expect(next).not.toHaveBeenCalled(); // No middleware should be called
});
test("should cache the response and serve it on subsequent requests, while ignoring some headers", () => {
req.headers.host = "ignore.test.com";
req.headers.accept = "application/json";
// Headers to ignore
config.cacheIgnoreHeaders = ["x-header-ignored"];
parseCacheControl.mockReturnValue({});
parseVary.mockReturnValue(["accept"]);
shouldCacheResponse.mockReturnValue(true);
// Mock cache-control headers
res.getHeaders.mockReturnValue({ "cache-control": "max-age=300" });
// First request: cache the response
mod(req, res, logFacilities, config, next);
// Simulate the first response
res.writeHead(200, {
"content-type": "application/json",
"x-header-ignored": "no"
});
res.end("cached response body");
// Assertions for the first request
expect(next).toHaveBeenCalled(); // Proceed to next middleware during first request
// Reset mocks for the second invocation
jest.clearAllMocks();
next.mockReset();
res.setHeader = jest.fn();
res.removeHeader = jest.fn();
res.writeHead = jest.fn();
res.write = jest.fn();
res.end = jest.fn();
// Second request: retrieve from cache
parseCacheControl.mockReturnValue({});
isCacheValid.mockReturnValue(true); // Simulate a valid cache entry
mod(req, res, logFacilities, config, next);
// Assertions for the second request
expect(logFacilities.resmessage).toHaveBeenCalledWith(
"The response is cached."
);
expect(res.setHeader).toHaveBeenCalledWith("X-SVRJS-Cache", "HIT");
expect(res.writeHead).toHaveBeenCalledWith(200, {
"cache-control": "max-age=300",
"content-type": "application/json"
});
expect(res.end).toHaveBeenCalledWith(
Buffer.from("cached response body", "latin1")
);
expect(next).not.toHaveBeenCalled(); // No middleware should be called
});
@ -122,12 +181,16 @@ describe("SVR.JS Cache mod", () => {
test("should validate config values correctly", () => {
const validConfig = {
cacheVaryHeaders: ["accept", "user-agent"],
cacheIgnoreHeaders: ["set-cookie"],
maximumCachedResponseSize: 2048
};
expect(
mod.configValidators.cacheVaryHeaders(validConfig.cacheVaryHeaders)
).toBe(true);
expect(
mod.configValidators.cacheIgnoreHeaders(validConfig.cacheIgnoreHeaders)
).toBe(true);
expect(
mod.configValidators.maximumCachedResponseSize(
validConfig.maximumCachedResponseSize
@ -136,12 +199,16 @@ describe("SVR.JS Cache mod", () => {
const invalidConfig = {
cacheVaryHeaders: "invalid",
cacheIgnoreHeaders: "invalid",
maximumCachedResponseSize: "invalid"
};
expect(
mod.configValidators.cacheVaryHeaders(invalidConfig.cacheVaryHeaders)
).toBe(false);
expect(
mod.configValidators.cacheIgnoreHeaders(invalidConfig.cacheIgnoreHeaders)
).toBe(false);
expect(
mod.configValidators.maximumCachedResponseSize(
invalidConfig.maximumCachedResponseSize