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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,22 @@ It has been deployed live to [https://api2.steemyy.com](https://api2.steemyy.com
- Auto health-check and version validation
- CORS enabled
- Compatible with `fetch` API
- Instant failover using `Promise.any`
- Efficient sequential failover (optimized for Cloudflare snippet subrequest limits)
- 100% Serveless - Just need a DNS record and a Snippet (JS) in CloudFlare
- Unlimited Requests, compared to CloudFlare Workers which have a daily 100K quota for free plans.

## Cloudflare Plan Requirements
This snippet requires a **Cloudflare Pro plan or higher** due to subrequest limits:

| Plan | Subrequests | Snippets | Status |
|------|------------|----------|---------|
| Free | 0 | 0 | ❌ Not compatible |
| Pro | 2 | 25 | ✅ Works (1 version check + 1 forward request) |
| Business | 3 | 50 | ✅ Fully compatible |
| Enterprise | 5 | 300 | ✅ Fully compatible |

The snippet is optimized to use minimum subrequests: sequential node selection (1 subrequest) + forwarding request (1 subrequest) = 2 subrequests total in typical use.

## Development
```bash
npm install
Expand Down
39 changes: 32 additions & 7 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ export const CONFIG = {
USER_AGENT:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
MIN_VERSION: "0.23.0",
SERVERLESS_VERSION: "2026-01-12",
NODES: ["https://api2.justyy.com", "https://api.steemit.com"],
SERVERLESS_VERSION: "2026-05-13",
NODES: ["https://api.justyy.com", "https://api.steemit.com"],
FETCH_TIMEOUT_MS: 5000,
};

Expand All @@ -18,7 +18,7 @@ export const DOWNSTREAM_HEADERS = Object.freeze({
"https://api.steemit.com": {
"X-Edge-Key": "static_secret_value_here",
},
"https://api2.justyy.com": {
"https://api.justyy.com": {
"X-Edge-Key": "another_static_secret_value",
},
});
Expand All @@ -40,8 +40,17 @@ export async function fetchWithTimeout(url, options = {}, timeout = 5000, timer
const controller = new AbortController();
const t = timer(() => controller.abort(), timeout);
try {
const res = await fetch(url, { ...options, signal: controller.signal });
// Use redirect: "manual" to track redirect chains and avoid double-counting subrequests
const res = await fetch(url, { ...options, signal: controller.signal, redirect: "manual" });
clearTimeout(t);

// Follow redirect chain to final URL (each redirect already counts as a subrequest)
if ([301, 302, 303, 307, 308].includes(res.status)) {
const finalUrl = res.headers.get("location");
if (!finalUrl) throw new Error(`Redirect status ${res.status} but no location header`);
return fetchWithTimeout(finalUrl, { ...options, redirect: "manual" }, timeout, timer);
}

return res;
} catch (err) {
clearTimeout(t);
Expand Down Expand Up @@ -126,10 +135,26 @@ export default {
const country = request.headers.get("cf-ipcountry") || "UNKNOWN";
const ip = request.headers.get("CF-Connecting-IP") || "0.0.0.0";

// Try nodes sequentially to minimize subrequests (vs concurrent Promise.any which uses 1 subrequest per attempt)
// Cloudflare snippet limits: Free=0, Pro=2, Business=3, Enterprise=5 subrequests
// Sequential tries: 1 version check + 1 forward = 2 subrequests (Pro plan)
const shuffled = CONFIG.NODES.sort(() => Math.random() - 0.5);
let selected = await Promise.any(shuffled.map((s) => safeGetVersion(s, fetch))).catch(() => {
throw new Error("All upstream nodes failed");
});
let selected = null;
let lastError = null;

for (const node of shuffled) {
try {
selected = await safeGetVersion(node, fetch);
break; // Success - use this node
} catch (err) {
lastError = err;
// Try next node
}
}

if (!selected) {
throw lastError || new Error("All upstream nodes failed");
}
// === Forward the actual request ===
let respObj;

Expand Down
78 changes: 77 additions & 1 deletion tests/index.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import worker from "../src/index.js";
import worker, { CONFIG } from "../src/index.js";

describe("Cloudflare Worker", () => {
let globalFetch;
Expand Down Expand Up @@ -86,4 +86,80 @@ describe("Cloudflare Worker", () => {
expect(json.__country__).toBeDefined();
expect(json.data).toBe("success");
});

it("returns 502 when the selected node has no downstream headers", async () => {
const originalNodes = [...CONFIG.NODES];
CONFIG.NODES = ["https://api.unknown.com"];

try {
global.fetch = vi.fn(async (url, opts) => {
if (opts?.method === "POST" && opts.body?.includes("get_version")) {
return new Response(JSON.stringify({ result: { blockchain_version: "0.25.0" } }), {
status: 200,
});
}
return new Response(JSON.stringify({ error: "should not reach forward" }), { status: 200 });
});

const req = new Request("https://example.com", { method: "GET" });
const res = await worker.fetch(req);
const json = await res.json();

expect(res.status).toBe(502);
expect(json.message).toMatch(/No security headers defined/);
} finally {
CONFIG.NODES = originalNodes;
}
});

it("forwards POST requests and preserves JSON metadata", async () => {
global.fetch = vi.fn(async (url, opts) => {
if (opts?.method === "POST" && opts.body?.includes("get_version")) {
return new Response(JSON.stringify({ result: { blockchain_version: "0.25.0" } }), {
status: 200,
});
}

return new Response(JSON.stringify({ message: "posted" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});

const req = new Request("https://example.com", {
method: "POST",
body: JSON.stringify({ hello: "world" }),
headers: { "Content-Type": "application/json" },
});
const res = await worker.fetch(req);
const json = await res.json();

expect(res.status).toBe(200);
expect(json.message).toBe("posted");
expect(json.__server__).toBeDefined();
expect(json.__version__).toBe("0.25.0");
});

it("handles non-JSON upstream responses gracefully", async () => {
global.fetch = vi.fn(async (url, opts) => {
if (opts?.method === "POST" && opts.body?.includes("get_version")) {
return new Response(JSON.stringify({ result: { blockchain_version: "0.25.0" } }), {
status: 200,
});
}

return new Response("not valid json", {
status: 200,
headers: { "Content-Type": "text/plain" },
});
});

const req = new Request("https://example.com", { method: "GET" });
const res = await worker.fetch(req);
const json = await res.json();

expect(res.status).toBe(200);
expect(json.error).toBe("Upstream returned non-JSON");
expect(json.body).toBe("not valid json");
});
});
Loading