chore: init

This commit is contained in:
Dorian Niemiec 2024-12-15 19:21:25 +01:00
commit e59d95bce0
18 changed files with 8015 additions and 0 deletions

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
# Build output
/dist/
# SVR.JS
/svrjs/
# Dependencies
node_modules
# ESLint cache
.eslintcache

2
.husky/commit-msg Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
npx --no -- commitlint --edit "$1"

2
.husky/pre-commit Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
npx lint-staged

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018-2024 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
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

29
README Normal file
View file

@ -0,0 +1,29 @@
This repository contains SVR.JS mod starter code and its build system
The mod will work for SVR.JS Nightly-GitNext.
Before doing anything, run "npm install".
To build SVR.JS mod, run "npm run build".
To check SVR.JS mod code for errors with ESLint, run "npm run lint".
To fix and beautify SVR.JS mod code with ESLint and Prettier, run "npm run lint:fix".
To perform unit tests with Jest, run "npm test".
To test the mod:
1. Clone the SVR.JS repository with "git clone https://git.svrjs.org/svrjs/svrjs.git" command.
2. Change the working directory to "svrjs" using "cd svrjs".
3. Build SVR.JS by first running "npm install" and then running "npm run build".
4. Copy the mod into mods directory in the dist directory using "cp ../dist/mod.js dist/mods" (GNU/Linux, Unix, BSD) or "copy ..\dist\mod.js dist\mods" (Windows).
5. Do the necessary mod configuration if the mod requires it.
6. Run SVR.JS by running "npm start".
7. Do some requests to the endpoints covered by the mod.
Structure:
- dist - contains the built SVR.JS mod
- src - contains SVR.JS mod source code
- index.js - entry point
- utils - utility functions
- tests - Jest unit tests
- utils - unit tests for utility functions
- esbuild.config.js - the build script
- eslint.config.js - ESLint configuration
- jest.config.js - Jest configuration
- modInfo.json - SVR.JS mod name and version

3
commitlint.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
extends: ["@commitlint/config-conventional"]
};

11
esbuild.config.js Normal file
View file

@ -0,0 +1,11 @@
const esbuild = require("esbuild");
esbuild.build({
entryPoints: ["src/index.js"],
bundle: true,
outfile: "dist/mod.js", // Mod output file
platform: "node",
target: "es2017",
}).catch((err) => {
throw err;
});

30
eslint.config.js Normal file
View file

@ -0,0 +1,30 @@
const globals = require("globals");
const pluginJs = require("@eslint/js");
const eslintPluginPrettierRecommended = require("eslint-plugin-prettier/recommended");
const jest = require("eslint-plugin-jest");
module.exports = [
{
files: ["**/*.js"],
languageOptions: {
sourceType: "commonjs"
}
},
{
files: ["tests/*.test.js", "tests/**/*.test.js"],
...jest.configs['flat/recommended'],
rules: {
...jest.configs['flat/recommended'].rules,
'jest/prefer-expect-assertions': 'off',
}
},
{
languageOptions: {
globals: {
...globals.node
}
}
},
pluginJs.configs.recommended,
eslintPluginPrettierRecommended
];

5
jest.config.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
verbose: true,
};

4
lint-staged.config.js Normal file
View file

@ -0,0 +1,4 @@
module.exports = {
"tests/**/*.js": "eslint --cache --fix",
"src/**/*.js": "eslint --cache --fix"
};

4
modInfo.json Normal file
View file

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

7308
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "svrjs-mod-starter",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "node esbuild.config.js",
"cz": "cz",
"lint": "eslint --no-error-on-unmatched-pattern src/**/*.js src/*.js tests/**/*.test.js tests/**/*.js",
"lint:fix": "npm run lint -- --fix",
"prepare": "husky",
"test": "jest"
},
"devDependencies": {
"@commitlint/cli": "^19.6.1",
"@commitlint/config-conventional": "^19.6.0",
"@eslint/js": "^9.9.1",
"commitizen": "^4.3.1",
"cz-conventional-changelog": "^3.3.0",
"esbuild": "^0.23.1",
"eslint": "^9.9.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.8.0",
"eslint-plugin-prettier": "^5.2.1",
"globals": "^15.9.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"lint-staged": "^15.2.11",
"prettier": "^3.3.3"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}

15
prettier.config.js Normal file
View file

@ -0,0 +1,15 @@
// prettier.config.js, .prettierrc.js, prettier.config.cjs, or .prettierrc.cjs
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import("prettier").Config}
*/
const config = {
trailingComma: "none",
tabWidth: 2,
semi: true,
singleQuote: false,
endOfLine: "lf"
};
module.exports = config;

219
src/index.js Normal file
View file

@ -0,0 +1,219 @@
const {
parseCacheControl,
parseVary,
shouldCacheResponse,
isCacheValid
} = require("./utils/cacheControlUtils.js");
const modInfo = require("../modInfo.json");
// Simple in-memory cache
const cache = new Map();
const varyCache = new Map();
module.exports = function (req, res, logFacilities, config, next) {
// Cache configuration
const cacheVaryHeadersConfigured = config.cacheVaryHeaders
? config.cacheVaryHeaders
: [];
const maximumCachedResponseSize = config.maximumCachedResponseSize
? config.maximumCachedResponseSize
: null;
const cacheKey =
req.method +
" " +
(req.socket.encrypted ? "https://" : "http://") +
(req.headers.host ? req.headers.host : "") +
req.url;
const requestCacheControl = parseCacheControl(req.headers["cache-control"]);
if (
(req.method != "GET" && req.method != "HEAD") ||
requestCacheControl["no-store"]
) {
res.setHeader("X-SVRJS-Cache", "BYPASS");
return next(); // Skip cache and proceed to the next middleware
}
// Check cache
if (!requestCacheControl["no-cache"] && varyCache.has(cacheKey)) {
const processedVary = varyCache.get(cacheKey);
const cacheKeyWithVary =
cacheKey +
"\n" +
processedVary
.map((headerName) =>
req.headers[headerName]
? `${headerName}: ${req.headers[headerName]}`
: ""
)
.join("\n");
varyCache.set(cacheKey, processedVary);
if (cache.has(cacheKeyWithVary)) {
const cachedEntry = cache.get(cacheKeyWithVary);
if (isCacheValid(cachedEntry, req.headers)) {
logFacilities.resmessage("The response is cached.");
res.getHeaderNames().forEach((headerName) => {
res.removeHeader(headerName);
});
res.setHeader("X-SVRJS-Cache", "HIT");
res.writeHead(cachedEntry.statusCode, cachedEntry.headers);
res.end(Buffer.from(cachedEntry.body, "latin1"));
return; // Serve cached response and stop further execution
} else {
cache.delete(cacheKey); // Cache expired
}
}
}
// Capture the response
const originalWriteHead = res.writeHead.bind(res);
const originalEnd = res.end.bind(res);
let writtenHeaders = res.getHeaders();
let writtenStatusCode = 200;
let responseBody = "";
let maximumCachedResponseSizeExceeded = false;
res.writeHead = function (statusCode, statusCodeDescription, headers) {
writtenHeaders = Object.assign(
writtenHeaders,
headers ? headers : statusCodeDescription
);
writtenStatusCode = statusCode;
res.setHeader("X-SVRJS-Cache", "MISS");
if (headers) {
originalWriteHead(
writtenStatusCode,
statusCodeDescription,
writtenHeaders
);
} else {
originalWriteHead(writtenStatusCode, writtenHeaders);
}
};
res.end = function (chunk, encoding, callback) {
if (req.method != "HEAD" && 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;
}
}
}
const responseCacheControl = parseCacheControl(
writtenHeaders[
Object.keys(writtenHeaders).find(
(key) => key.toLowerCase() == "cache-control"
)
]
);
if (
!maximumCachedResponseSizeExceeded &&
shouldCacheResponse(
responseCacheControl,
req.headers.authentication !== undefined
)
) {
if (!responseCacheControl["max-age"])
responseCacheControl["max-age"] = "300"; // Set the default max-age to 300 seconds (5 minutes)
const responseVary = parseVary(
writtenHeaders[
Object.keys(writtenHeaders).find((key) => key.toLowerCase() == "vary")
]
);
const processedVary = [
...new Set(
[...cacheVaryHeadersConfigured, ...responseVary].map((headerName) =>
headerName.toLowerCase()
)
)
];
if (!responseVary.find((headerName) => headerName == "*")) {
const cacheKeyWithVary =
cacheKey +
"\n" +
processedVary
.map((headerName) =>
req.headers[headerName]
? `${headerName}: ${req.headers[headerName]}`
: ""
)
.join("\n");
varyCache.set(cacheKey, processedVary);
cache.set(cacheKeyWithVary, {
body: responseBody,
headers: writtenHeaders,
timestamp: Date.now(),
statusCode: writtenStatusCode,
cacheControl: responseCacheControl
});
}
}
originalEnd(chunk, encoding, callback);
};
if (req.method != "HEAD") {
res.on("pipe", (src) => {
src.on("data", (chunk) => {
if (!maximumCachedResponseSizeExceeded) {
const processedChunk = Buffer.from(chunk).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;
}
}
}
});
});
}
next(); // Continue with normal processing
};
module.exports.commands = {
purgecache: (args, log) => {
// All commands are executed on workers
cache.clear();
varyCache.clear();
log("Cache cleared successfully.");
}
};
module.exports.configValidators = {
cacheVaryHeaders: (value) =>
Array.isArray(value) &&
value.every((element) => typeof element === "string"),
maximumCachedResponseSize: (value) =>
typeof value === "number" || value === null
};
module.exports.modInfo = modInfo;

View file

@ -0,0 +1,48 @@
function parseCacheControl(header) {
const directives = {};
if (!header) return directives;
header.split(",").forEach((directive) => {
const [key, value] = directive.trim().split("=");
directives[key.toLowerCase().trim()] = value ? value.trim() : true;
});
return directives;
}
function parseVary(header) {
if (!header) return [];
return header.split(",").map((headerName) => headerName.trim());
}
function shouldCacheResponse(cacheControl, isAuthenticated) {
if (cacheControl["no-store"] || cacheControl["private"]) {
return false;
}
if (cacheControl["public"]) {
return true;
}
return !isAuthenticated && cacheControl["max-age"] !== undefined;
}
function isCacheValid(entry, requestHeaders) {
const { timestamp, cacheControl } = entry;
const maxAge = parseInt(cacheControl["max-age"], 10);
if (Date.now() - timestamp > maxAge * 1000) {
return false;
}
if (
requestHeaders["cache-control"] &&
requestHeaders["cache-control"].includes("no-cache")
) {
return false;
}
return true;
}
module.exports = {
parseCacheControl: parseCacheControl,
parseVary: parseVary,
shouldCacheResponse: shouldCacheResponse,
isCacheValid: isCacheValid
};

151
tests/index.test.js Normal file
View file

@ -0,0 +1,151 @@
const mod = require("../src/index.js");
const {
parseCacheControl,
parseVary,
shouldCacheResponse,
isCacheValid
} = require("../src/utils/cacheControlUtils.js");
jest.mock("../src/utils/cacheControlUtils.js", () => ({
parseCacheControl: jest.fn(),
parseVary: jest.fn(),
shouldCacheResponse: jest.fn(),
isCacheValid: jest.fn()
}));
describe("SVR.JS Cache mod", () => {
let req, res, logFacilities, config, next, resWriteHead, resEnd;
beforeEach(() => {
resWriteHead = jest.fn();
resEnd = jest.fn();
req = {
method: "GET",
headers: {},
url: "/test",
socket: { encrypted: false }
};
res = {
headers: {},
writeHead: resWriteHead,
end: resEnd,
setHeader: jest.fn(),
getHeaderNames: jest.fn(() => []),
getHeaders: jest.fn(() => ({})),
removeHeader: jest.fn(),
on: jest.fn()
};
logFacilities = { resmessage: jest.fn() };
config = {
cacheVaryHeaders: ["accept"],
maximumCachedResponseSize: 1024
};
next = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
});
test("should bypass caching for non-GET requests", () => {
req.method = "POST";
mod(req, res, logFacilities, config, next);
expect(res.setHeader).toHaveBeenCalledWith("X-SVRJS-Cache", "BYPASS");
expect(next).toHaveBeenCalled();
});
test("should bypass caching if Cache-Control contains no-store", () => {
req.headers["cache-control"] = "no-store";
parseCacheControl.mockReturnValue({ "no-store": true });
mod(req, res, logFacilities, config, next);
expect(res.setHeader).toHaveBeenCalledWith("X-SVRJS-Cache", "BYPASS");
expect(next).toHaveBeenCalled();
});
test("should cache the response and serve it on subsequent requests", () => {
req.headers.host = "test.com";
req.headers.accept = "application/json";
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" });
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();
// 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(resWriteHead).toHaveBeenCalledWith(200, {
"cache-control": "max-age=300",
"content-type": "application/json"
});
expect(resEnd).toHaveBeenCalledWith(
Buffer.from("cached response body", "latin1"),
undefined,
undefined
);
expect(next).not.toHaveBeenCalled(); // No middleware should be called
});
test("should validate config values correctly", () => {
const validConfig = {
cacheVaryHeaders: ["accept", "user-agent"],
maximumCachedResponseSize: 2048
};
expect(
mod.configValidators.cacheVaryHeaders(validConfig.cacheVaryHeaders)
).toBe(true);
expect(
mod.configValidators.maximumCachedResponseSize(
validConfig.maximumCachedResponseSize
)
).toBe(true);
const invalidConfig = {
cacheVaryHeaders: "invalid",
maximumCachedResponseSize: "invalid"
};
expect(
mod.configValidators.cacheVaryHeaders(invalidConfig.cacheVaryHeaders)
).toBe(false);
expect(
mod.configValidators.maximumCachedResponseSize(
invalidConfig.maximumCachedResponseSize
)
).toBe(false);
});
});

View file

@ -0,0 +1,117 @@
const {
parseCacheControl,
parseVary,
shouldCacheResponse,
isCacheValid
} = require("../../src/utils/cacheControlUtils.js");
describe("parseCacheControl", () => {
test("should return an empty object if header is null or undefined", () => {
expect(parseCacheControl(null)).toEqual({});
expect(parseCacheControl(undefined)).toEqual({});
});
test("should parse cache-control header correctly", () => {
const header = "max-age=3600, no-cache, private";
expect(parseCacheControl(header)).toEqual({
"max-age": "3600",
"no-cache": true,
private: true
});
});
test("should handle directives without values", () => {
const header = "no-store, public";
expect(parseCacheControl(header)).toEqual({
"no-store": true,
public: true
});
});
test("should trim whitespace correctly", () => {
const header = " max-age = 3600 , no-cache , private ";
expect(parseCacheControl(header)).toEqual({
"max-age": "3600",
"no-cache": true,
private: true
});
});
});
describe("parseVary", () => {
test("should return an empty array if header is null or undefined", () => {
expect(parseVary(null)).toEqual([]);
expect(parseVary(undefined)).toEqual([]);
});
test("should parse vary header correctly", () => {
const header = "Accept-Encoding, User-Agent";
expect(parseVary(header)).toEqual(["Accept-Encoding", "User-Agent"]);
});
test("should trim whitespace correctly", () => {
const header = " Accept-Encoding , User-Agent ";
expect(parseVary(header)).toEqual(["Accept-Encoding", "User-Agent"]);
});
});
describe("shouldCacheResponse", () => {
test("should return false if no-store is present", () => {
const cacheControl = { "no-store": true };
expect(shouldCacheResponse(cacheControl, false)).toBe(false);
});
test("should return false if private is present", () => {
const cacheControl = { private: true };
expect(shouldCacheResponse(cacheControl, false)).toBe(false);
});
test("should return true if public is present", () => {
const cacheControl = { public: true };
expect(shouldCacheResponse(cacheControl, false)).toBe(true);
});
test("should return true if max-age is present and not authenticated", () => {
const cacheControl = { "max-age": "3600" };
expect(shouldCacheResponse(cacheControl, false)).toBe(true);
});
test("should return false if max-age is present and authenticated", () => {
const cacheControl = { "max-age": "3600" };
expect(shouldCacheResponse(cacheControl, true)).toBe(false);
});
test("should return false if no relevant directives are present", () => {
const cacheControl = {};
expect(shouldCacheResponse(cacheControl, false)).toBe(false);
});
});
describe("isCacheValid", () => {
test("should return false if cache is expired", () => {
const entry = {
timestamp: Date.now() - 4000,
cacheControl: { "max-age": "3" }
};
const requestHeaders = {};
expect(isCacheValid(entry, requestHeaders)).toBe(false);
});
test("should return false if no-cache is present in request headers", () => {
const entry = {
timestamp: Date.now(),
cacheControl: { "max-age": "3600" }
};
const requestHeaders = { "cache-control": "no-cache" };
expect(isCacheValid(entry, requestHeaders)).toBe(false);
});
test("should return true if cache is valid", () => {
const entry = {
timestamp: Date.now(),
cacheControl: { "max-age": "3600" }
};
const requestHeaders = {};
expect(isCacheValid(entry, requestHeaders)).toBe(true);
});
});