feat: migrate from Next.js to pure Bun relay proxy

- Replace Next.js app router with standalone Bun.serve() entry point
- Add middleware stack: rate limiter, body limiter, structured logger, SSRF protection
- Add WebSocket bidirectional relay via x-relay-target header
- Implement error classification (DNS→502, timeouts→504, SSRF→403, rate→429)
- Remove all Next.js dependencies and config files
- Update deploy workflow, tsconfig, wrangler config for Bun deployment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-10 19:54:55 +07:00
co-authored by Claude Fable 5
parent 124dab98a2
commit f58a92e2aa
43 changed files with 2981 additions and 1659 deletions
+142
View File
@@ -0,0 +1,142 @@
/**
* Body limiter test suite.
*
* Covers default size limit, custom configuration,
* Content-Length checks, and edge cases.
*/
import { test, expect, describe, beforeEach } from "bun:test";
import {
checkBodySize,
getMaxBodySize,
setMaxBodySize,
} from "./body-limiter";
describe("body-limiter", () => {
beforeEach(() => {
// Reset to default before each test
setMaxBodySize(1_048_576); // 1 MB
});
describe("getMaxBodySize / setMaxBodySize", () => {
test("should default to 1MB", () => {
expect(getMaxBodySize()).toBe(1_048_576);
});
test("should allow setting a custom max size", () => {
setMaxBodySize(512);
expect(getMaxBodySize()).toBe(512);
});
test("should allow setting zero", () => {
setMaxBodySize(0);
expect(getMaxBodySize()).toBe(0);
});
test("should throw for negative values", () => {
expect(() => setMaxBodySize(-1)).toThrow(
"maxBodySize must be a non-negative number",
);
});
test("should allow setting a very large size", () => {
setMaxBodySize(100_000_000);
expect(getMaxBodySize()).toBe(100_000_000);
});
});
describe("checkBodySize", () => {
test("should return null when Content-Length is under the default limit", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "500" },
});
expect(checkBodySize(request)).toBeNull();
});
test("should return null when Content-Length is exactly at the default limit", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "1048576" },
});
expect(checkBodySize(request)).toBeNull();
});
test("should return 413 Response when Content-Length exceeds the default limit", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "1048577" },
});
const result = checkBodySize(request);
expect(result).toBeInstanceOf(Response);
expect(result!.status).toBe(413);
expect(result!.headers.get("Content-Type")).toBe("application/json");
});
test("413 response should include error details as JSON", async () => {
setMaxBodySize(100);
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "200" },
});
const result = checkBodySize(request);
const body = await result!.json();
expect(body.error).toBe("Payload Too Large");
expect(body.maxSizeBytes).toBe(100);
});
test("should return null when no Content-Length header is present", () => {
const request = new Request("http://localhost/test", {
method: "POST",
});
expect(checkBodySize(request)).toBeNull();
});
test("should return null for malformed Content-Length", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "not-a-number" },
});
expect(checkBodySize(request)).toBeNull();
});
test("should return null for negative Content-Length", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "-100" },
});
expect(checkBodySize(request)).toBeNull();
});
test("should use custom max body size when set", () => {
setMaxBodySize(500);
const underLimit = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "499" },
});
expect(checkBodySize(underLimit)).toBeNull();
const overLimit = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "501" },
});
expect(checkBodySize(overLimit)!.status).toBe(413);
});
test("should handle GET requests with Content-Length", () => {
const request = new Request("http://localhost/test", {
method: "GET",
headers: { "Content-Length": "10" },
});
expect(checkBodySize(request)).toBeNull();
});
test("should handle zero Content-Length", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "0" },
});
expect(checkBodySize(request)).toBeNull();
});
});
});
+69
View File
@@ -0,0 +1,69 @@
/**
* Request body size limiter.
*
* Checks the Content-Length header against a configurable maximum.
* Returns a 413 Payload Too Large response when the body exceeds the limit.
* Requests without a Content-Length header are passed through since
* the body size cannot be determined upfront with streaming.
*/
const DEFAULT_MAX_BODY_SIZE = 1_048_576; // 1 MB
let maxBodySize = DEFAULT_MAX_BODY_SIZE;
/**
* Check whether the request body exceeds the configured size limit.
*
* Returns a 413 Response if the Content-Length header indicates the body
* is too large. Returns `null` if the body is acceptable or if the size
* cannot be determined (no Content-Length header).
*/
export function checkBodySize(request: Request): Response | null {
const contentType = request.headers.get("content-length");
if (contentType === null) {
// Cannot determine size upfront — pass through (streaming body).
return null;
}
const contentLength = Number.parseInt(contentType, 10);
if (Number.isNaN(contentLength) || contentLength < 0) {
// Malformed Content-Length — pass through and let the server handle it.
return null;
}
if (contentLength > maxBodySize) {
return new Response(
JSON.stringify({
error: "Payload Too Large",
message: `Request body exceeds the maximum allowed size of ${maxBodySize} bytes`,
maxSizeBytes: maxBodySize,
}),
{
status: 413,
headers: {
"Content-Type": "application/json",
},
},
);
}
return null;
}
/**
* Update the maximum allowed body size.
*/
export function setMaxBodySize(bytes: number): void {
if (bytes < 0) {
throw new Error("maxBodySize must be a non-negative number");
}
maxBodySize = bytes;
}
/**
* Get the current maximum allowed body size in bytes.
*/
export function getMaxBodySize(): number {
return maxBodySize;
}
+5
View File
@@ -0,0 +1,5 @@
export { checkBodySize, getMaxBodySize, setMaxBodySize } from "./body-limiter";
export type { RelayLogEvent } from "./logger";
export { createRequestLogger, logRelayEvent } from "./logger";
export type { RateLimiter, RateLimiterOptions } from "./rate-limiter";
export { createRateLimiter } from "./rate-limiter";
+102
View File
@@ -0,0 +1,102 @@
/**
* Logger test suite.
*
* Covers structured logging, TTY vs JSON output,
* and the request logger factory.
*/
import { test, expect, describe, spyOn } from "bun:test";
import { logRelayEvent, createRequestLogger } from "./logger";
describe("logger", () => {
describe("logRelayEvent", () => {
test("should log all required fields without error", () => {
const spy = spyOn(console, "log");
spy.mockImplementation(() => {});
logRelayEvent({
method: "GET",
url: "/health",
status: 200,
durationMs: 15,
});
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
test("should log event with error field", () => {
const spy = spyOn(console, "log");
spy.mockImplementation(() => {});
logRelayEvent({
method: "POST",
url: "/relay",
status: 502,
durationMs: 5000,
error: "DNS resolution failed",
});
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
test("should log event with all optional fields", () => {
const spy = spyOn(console, "log");
spy.mockImplementation(() => {});
logRelayEvent({
method: "GET",
url: "/test",
status: 200,
durationMs: 42,
targetUrl: "https://example.com/api",
ip: "203.0.113.1",
});
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
test("should not throw for any valid event shape", () => {
expect(() =>
logRelayEvent({
method: "OPTIONS",
url: "/cors-test",
status: 204,
durationMs: 0,
}),
).not.toThrow();
});
});
describe("createRequestLogger", () => {
test("should return a function", () => {
const logger = createRequestLogger();
expect(typeof logger).toBe("function");
});
test("returned function should log without error", () => {
const logger = createRequestLogger();
const req = new Request("http://localhost/test", {
method: "POST",
});
const res = new Response("ok", { status: 200 });
expect(() => logger(req, res, performance.now())).not.toThrow();
});
test("returned function should accept extra fields", () => {
const logger = createRequestLogger();
const req = new Request("http://localhost/relay");
const res = new Response("relayed", { status: 200 });
expect(() =>
logger(req, res, performance.now(), {
targetUrl: "https://example.com",
ip: "10.0.0.1",
}),
).not.toThrow();
});
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* Structured logger for relay proxy events.
*
* Logs events as JSON lines for machine parsing and provides
* optional TTY colorization for local development.
*/
export interface RelayLogEvent {
method: string;
url: string;
status: number;
durationMs: number;
error?: string;
targetUrl?: string;
ip?: string;
}
type LogLevel = "info" | "warn" | "error";
/** Determine log level from HTTP status code. */
function levelFromStatus(status: number): LogLevel {
if (status >= 500) return "error";
if (status >= 400) return "warn";
return "info";
}
/** ANSI color codes for TTY output. */
const TTY_COLORS: Record<LogLevel, string> = {
info: "", // green
warn: "", // yellow
error: "", // red
};
const TTY_RESET = "";
const TTY_DIM = "";
/** Check if stdout is a TTY (for colorization). */
function isTTY(): boolean {
return process.stdout?.isTTY === true;
}
/**
* Log a structured relay event to stdout.
*
* When writing to a TTY the output includes ANSI colors for readability.
* When writing to a pipe/file it produces clean JSON lines.
*/
export function logRelayEvent(event: RelayLogEvent): void {
const { method, url, status, durationMs, error, targetUrl, ip } = event;
const level = levelFromStatus(status);
const timestamp = new Date().toISOString();
const durationFormatted = `${durationMs}ms`;
const tty = isTTY();
const color = tty ? (TTY_COLORS[level] ?? "") : "";
const reset = tty ? TTY_RESET : "";
const dim = tty ? TTY_DIM : "";
if (tty) {
const statusColor =
status >= 500 ? TTY_COLORS.error : status >= 400 ? TTY_COLORS.warn : "";
const parts: string[] = [
`${dim}${timestamp}${reset}`,
`${color}[${level.toUpperCase()}]${reset}`,
`${method}`,
`${statusColor}${status}${reset}`,
`${dim}${durationFormatted}${reset}`,
url,
];
if (targetUrl) parts.push(`${dim}-> ${targetUrl}${reset}`);
if (ip) parts.push(`${dim}(${ip})${reset}`);
if (error) parts.push(`${color}${error}${reset}`);
console.log(parts.join(" "));
} else {
const logEntry: Record<string, unknown> = {
timestamp,
level,
method,
url,
status,
durationMs: durationFormatted,
};
if (error) logEntry.error = error;
if (targetUrl) logEntry.targetUrl = targetUrl;
if (ip) logEntry.ip = ip;
console.log(JSON.stringify(logEntry));
}
}
/**
* Create a middleware-compatible request logger.
*
* Example usage in a Bun.serve() handler:
*
* const requestLogger = createRequestLogger();
* const start = performance.now();
* // ... handle request ...
* requestLogger(req, res, start, { targetUrl });
*/
export function createRequestLogger(): (
req: Request,
res: Response,
startTime: number,
extra?: Partial<RelayLogEvent>,
) => void {
return (req, res, startTime, extra) => {
const durationMs = Math.round(performance.now() - startTime);
logRelayEvent({
method: req.method,
url: req.url,
status: res.status,
durationMs,
error: extra?.error,
targetUrl: extra?.targetUrl,
ip: extra?.ip,
});
};
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Rate limiter test suite.
*
* Covers default and custom options, window enforcement,
* reset semantics, and edge cases.
*/
import { test, expect, describe } from "bun:test";
import { createRateLimiter } from "./rate-limiter";
describe("rate-limiter", () => {
describe("createRateLimiter", () => {
test("should allow requests up to the default limit", () => {
const limiter = createRateLimiter({ maxRequests: 5, windowMs: 60_000 });
for (let i = 0; i < 5; i++) {
const result = limiter.check("test-key");
expect(result.allowed).toBe(true);
}
});
test("should block requests exceeding the limit", () => {
const limiter = createRateLimiter({ maxRequests: 3, windowMs: 60_000 });
for (let i = 0; i < 3; i++) {
expect(limiter.check("block-key").allowed).toBe(true);
}
const blocked = limiter.check("block-key");
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterMs).toBeDefined();
expect(typeof blocked.retryAfterMs).toBe("number");
});
test("should return retryAfterMs when blocked", () => {
const limiter = createRateLimiter({
maxRequests: 1,
windowMs: 60_000,
});
limiter.check("retry-key");
const blocked = limiter.check("retry-key");
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterMs).toBeGreaterThan(0);
expect(blocked.retryAfterMs).toBeLessThanOrEqual(60_000);
});
test("reset() should clear the counter", () => {
const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 });
limiter.check("reset-key");
limiter.check("reset-key");
// would be blocked, but...
limiter.reset("reset-key");
// ...should be allowed again
expect(limiter.check("reset-key").allowed).toBe(true);
});
test("should isolate keys from each other", () => {
const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 });
expect(limiter.check("key-a").allowed).toBe(true);
expect(limiter.check("key-a").allowed).toBe(true);
expect(limiter.check("key-b").allowed).toBe(true); // key-b unaffected
expect(limiter.check("key-a").allowed).toBe(false); // key-a blocked
});
test("should create with default options", () => {
const limiter = createRateLimiter();
expect(limiter.check).toBeDefined();
expect(limiter.reset).toBeDefined();
});
test("should handle rapid sequential calls", () => {
const limiter = createRateLimiter({ maxRequests: 100, windowMs: 60_000 });
for (let i = 0; i < 100; i++) {
expect(limiter.check("rapid-key").allowed).toBe(true);
}
expect(limiter.check("rapid-key").allowed).toBe(false);
});
test("should allow requests after reset", () => {
const limiter = createRateLimiter({ maxRequests: 1, windowMs: 60_000 });
limiter.check("after-reset-key");
const blocked = limiter.check("after-reset-key");
expect(blocked.allowed).toBe(false);
limiter.reset("after-reset-key");
expect(limiter.check("after-reset-key").allowed).toBe(true);
});
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* In-memory sliding window rate limiter for Bun relay proxy.
*
* Uses a Map<string, number[]> where each key maps to an array of
* Unix-epoch millisecond timestamps. Older entries are purged on
* every check() call and periodically via a background interval.
*
* Bun is single-threaded so no locking is required.
*/
export interface RateLimiterOptions {
maxRequests?: number;
windowMs?: number;
}
export interface RateLimiter {
check(key: string): { allowed: boolean; retryAfterMs?: number };
reset(key: string): void;
}
const DEFAULT_MAX_REQUESTS = 100;
const DEFAULT_WINDOW_MS = 60_000; // 1 minute
const CLEANUP_INTERVAL_DIVISOR = 10;
export function createRateLimiter(options?: RateLimiterOptions): RateLimiter {
const maxRequests = options?.maxRequests ?? DEFAULT_MAX_REQUESTS;
const windowMs = options?.windowMs ?? DEFAULT_WINDOW_MS;
// Map of key -> sorted array of timestamps (ascending)
const store = new Map<string, number[]>();
// ── helpers ──────────────────────────────────────────────────────
/** Remove timestamps outside the sliding window. Returns the pruned slice. */
function prune(key: string, now: number): number[] {
const timestamps = store.get(key);
if (!timestamps) return [];
const cutoff = now - windowMs;
const result: number[] = [];
for (let i = 0; i < timestamps.length; i++) {
if (timestamps[i] >= cutoff) {
result.push(timestamps[i]);
}
}
if (result.length === 0) {
store.delete(key);
} else {
store.set(key, result);
}
return result;
}
/** Periodically sweep the entire store to free memory. */
function periodicCleanup(): void {
const now = Date.now();
const cutoff = now - windowMs;
for (const [key, timestamps] of store) {
const pruned: number[] = [];
for (let i = 0; i < timestamps.length; i++) {
if (timestamps[i] >= cutoff) {
pruned.push(timestamps[i]);
}
}
if (pruned.length === 0) {
store.delete(key);
} else {
store.set(key, pruned);
}
}
}
// Schedule periodic cleanup (every windowMs / 10)
const cleanupHandle = setInterval(
periodicCleanup,
windowMs / CLEANUP_INTERVAL_DIVISOR,
);
// Allow the process to exit even if the interval is still active
if (
cleanupHandle &&
typeof cleanupHandle === "object" &&
"unref" in cleanupHandle
) {
(cleanupHandle as NodeJS.Timeout).unref();
}
// ── public API ───────────────────────────────────────────────────
return {
check(key: string): { allowed: boolean; retryAfterMs?: number } {
const now = Date.now();
const timestamps = prune(key, now);
timestamps.push(now);
store.set(key, timestamps);
if (timestamps.length <= maxRequests) {
return { allowed: true };
}
// Not allowed — calculate retry-after from the oldest timestamp
const oldest = timestamps[0];
const retryAfterMs = oldest + windowMs - now;
console.warn(
`[rate-limiter] Rate limit exceeded for key="${key}": ${timestamps.length} requests in ${windowMs}ms (max ${maxRequests})`,
);
return { allowed: false, retryAfterMs };
},
reset(key: string): void {
store.delete(key);
},
};
}