Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ nodes:
rateLimit:
windowMs: 30000
maxRequests: 600
version: "2025-03-07"
headers:
"https://api.justyy.com":
"X-Edge-Key": "${X_EDGE_KEY}"
"https://api2.justyy.com":
"X-Edge-Key": "${X_EDGE_KEY}"
version: "2026-01-14"
max_age: 3
logging: true
max_payload_size: "5mb"
Expand Down Expand Up @@ -112,6 +117,7 @@ strategy: "max_jussi_number" # options: first, random, max_jussi_number, latest
- debug: When set to debug, more messages are set e.g. in the response header.
- firstK: Choosing the node which has the max Jussi Number from the first `firstK` nodes that respond OK. Default is 1.
- strategy: The strategy to pick the chosen node. This can be one of: first, random, max_jussi_number (default), latest_version
- headers: The headers can be specified to pass to the downstream nodes. When proxying requests, the Load Balancer injects a shared-secret header so downstream nodes can identify trusted traffic and apply elevated (or exempt) rate-limit policies.

## Installation
Clone the Repository:
Expand Down Expand Up @@ -220,7 +226,7 @@ Use the following script i.e. [integration-tests.sh](./tests/integration-tests.s

```bash
source ./setup-env.sh
## on success, exit code is 0.
## on success, e1it code is 0.
## on failure, exit code is 1.
./tests/integration-tests.sh
```
Expand Down
5 changes: 5 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ nodes:
rateLimit:
windowMs: 10000
maxRequests: 600
headers:
"https://api.justyy.com":
"X-Edge-Key": "${X_EDGE_KEY}"
"https://api2.justyy.com":
"X-Edge-Key": "${X_EDGE_KEY}"
version: "${STEEM_LB_VERSION}"
max_age: 3
logging: true
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ services:
- CACHE_TTL=${CACHE_TTL}
- STEEM_LB_VERSION=${STEEM_LB_VERSION}
- DEBUG=${DEBUG}
- X_EDGE_KEY=${X_EDGE_KEY}
volumes:
- /root/.acme.sh/:/root/.acme.sh/
- ./config.yaml:/app/config.yaml
8 changes: 4 additions & 4 deletions jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ export default {
coverageReporters: ["text", "lcov", "json", "json-summary", "html"], // Specify desired coverage reporters
coverageThreshold: {
global: {
branches: 21,
functions: 45,
lines: 23,
statements: 24,
branches: 22.52,
functions: 48.52,
lines: 29.15,
statements: 30.35,
},
},
};
Expand Down
145 changes: 145 additions & 0 deletions js_tests/forward.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import fetch from "node-fetch";
import { sleep, limitStringMaxLength } from "../src/functions.js";
import { forwardRequestGET, forwardRequestPOST } from "../src/network.js";

jest.mock("node-fetch", () => jest.fn());
jest.mock("../src/functions.js", () => {
const actual = jest.requireActual("../src/functions.js");
return {
...actual,
sleep: jest.fn(),
limitStringMaxLength: jest.fn((s) => s),
log: jest.fn(),
};
});

describe("forwardRequestGET", () => {
const apiURL = "https://example.com/api";

beforeEach(() => {
jest.clearAllMocks();
});

test("passes headers and returns data", async () => {
// Mock fetch with a simple object implementing .text() and .status
fetch.mockResolvedValue({
status: 200,
text: jest.fn().mockResolvedValue("OK"),
});

const result = await forwardRequestGET(apiURL, {
agent: false,
timeout: 5000,
retry_count: 1,
user_agent: "jest-agent",
headers: { "X-Test": "123" },
});

expect(fetch).toHaveBeenCalledTimes(1);

const [url, options] = fetch.mock.calls[0];
expect(url).toBe(apiURL);
expect(options.headers).toMatchObject({
"Content-Type": "application/json",
"User-Agent": "jest-agent",
"X-Test": "123",
});

expect(result).toEqual({ statusCode: 200, data: "OK" });
});

test("retries on failure", async () => {
fetch
.mockRejectedValueOnce(new Error("network error"))
.mockResolvedValueOnce({
status: 200,
text: jest.fn().mockResolvedValue("OK"),
});

const result = await forwardRequestGET(apiURL, {
agent: false,
timeout: 5000,
retry_count: 2,
user_agent: "jest-agent",
headers: { "X-Retry": "yes" },
});

expect(fetch).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledTimes(1);
expect(result.statusCode).toBe(200);
});

test("throws after max retries", async () => {
fetch.mockRejectedValue(new Error("timeout"));

await expect(
forwardRequestGET(apiURL, {
agent: false,
timeout: 5000,
retry_count: 2,
user_agent: "jest-agent",
headers: {},
}),
).rejects.toThrow("timeout");

expect(fetch).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledTimes(1);
});
});

describe("forwardRequestPOST", () => {
const apiURL = "https://example.com/api";
const body = JSON.stringify({ hello: "world" });

beforeEach(() => {
jest.clearAllMocks();
limitStringMaxLength.mockImplementation((s) => s);
});

test("passes headers and body and returns data", async () => {
fetch.mockResolvedValue({
status: 201,
text: jest.fn().mockResolvedValue("CREATED"),
});

const result = await forwardRequestPOST(apiURL, body, {
agent: false,
timeout: 5000,
retry_count: 1,
user_agent: "jest-agent",
logging_max_body_len: 100,
headers: { Authorization: "Bearer token" },
});

expect(fetch).toHaveBeenCalledTimes(1);
const [url, options] = fetch.mock.calls[0];

expect(url).toBe(apiURL);
expect(options.body).toBe(body);
expect(options.headers).toMatchObject({
"Content-Type": "application/json",
"User-Agent": "jest-agent",
Authorization: "Bearer token",
});

expect(result).toEqual({ statusCode: 201, data: "CREATED" });
});

test("retries and throws after failure", async () => {
fetch.mockRejectedValue(new Error("server down"));

await expect(
forwardRequestPOST(apiURL, body, {
agent: false,
timeout: 5000,
retry_count: 2,
user_agent: "jest-agent",
logging_max_body_len: 100,
headers: {},
}),
).rejects.toThrow("server down");

expect(fetch).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledTimes(1);
});
});
14 changes: 8 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@babel/preset-env": "^7.26.9",
"@vitest/coverage-v8": "^3.2.4",
"babel-jest": "^29.7.0",
"baseline-browser-mapping": "^2.9.14",
"eslint": "^9.27.0",
"globals": "^16.0.0",
"jest": "^29.7.0",
Expand Down
1 change: 1 addition & 0 deletions run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ docker run \
-e CACHE_ENABLED=$CACHE_ENABLED \
-e CACHE_TTL=$CACHE_TTL \
-e STEEM_LB_VERSION=$STEEM_LB_VERSION \
-e X_EDGE_KEY=$X_EDGE_KEY \
--name $DOCKER_IMAGE \
--restart on-failure:$RETRY_COUNT \
-p $STEEM_LB_PORT:9091 \
Expand Down
4 changes: 3 additions & 1 deletion setup-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ export SSL_KEY_PATH=$SSL_KEY_PATH
export CACHE_ENABLED=true
export CACHE_TTL=3
export DEBUG=false
export X_EDGE_KEY=$X_EDGE_KEY

## if git is available, use the latest commit hash as version, otherwise use the current date
export STEEM_LB_VERSION=`git rev-parse HEAD 2>/dev/null || date +%F`

## listen to port 443 (HTTPS)
export STEEM_LB_PORT=443
export STEEM_LB_PORT=443
17 changes: 8 additions & 9 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -563,28 +563,27 @@ app.all("/", async (req, res) => {

try {
if (method === "GET") {
result = await forwardRequestGET(
chosenNode.server,
result = await forwardRequestGET(chosenNode.server, {
agent,
timeout,
retry_count,
user_agent,
timeout,
agent,
);
headers: config.headers?.[chosenNode.server] || {},
});
} else if (method === "POST") {
let reqbody = req.body;
const body = JSON.stringify(reqbody);
log(
`Request Body is ${limitStringMaxLength(body, logging_max_body_len)}`,
);
result = await forwardRequestPOST(
chosenNode.server,
body,
result = await forwardRequestPOST(chosenNode.server, body, {
agent,
timeout,
retry_count,
user_agent,
logging_max_body_len,
);
headers: config.headers?.[chosenNode.server] || {},
});
} else {
return res.status(405).json({ error: "Method Not Allowed" });
}
Expand Down
20 changes: 11 additions & 9 deletions src/network.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,7 @@ async function fetchWithTimeout(url, options = {}, timeout = 5000) {
// Forward GET request to the chosen node
async function forwardRequestGET(
apiURL,
retry_count,
user_agent,
timeout,
agent,
{ agent, timeout, retry_count, user_agent, headers } = {},
) {
for (let i = 0; i < retry_count; ++i) {
try {
Expand All @@ -48,6 +45,7 @@ async function forwardRequestGET(
headers: {
"Content-Type": "application/json",
"User-Agent": user_agent,
...headers,
},
redirect: "follow",
agent,
Expand All @@ -72,11 +70,14 @@ async function forwardRequestGET(
async function forwardRequestPOST(
apiURL,
body,
agent,
timeout,
retry_count,
user_agent,
logging_max_body_len,
{
agent,
timeout,
retry_count,
user_agent,
logging_max_body_len,
headers,
} = {},
) {
for (let i = 0; i < retry_count; ++i) {
try {
Expand All @@ -92,6 +93,7 @@ async function forwardRequestPOST(
headers: {
"Content-Type": "application/json",
"User-Agent": user_agent,
...headers,
},
redirect: "follow",
body: body,
Expand Down
Loading