release: WRNexusJS 0.7.0

This commit is contained in:
2026-08-01 10:04:42 +05:30
parent c54144f2e4
commit 87507edf59
207 changed files with 12607 additions and 679 deletions
+137 -4
View File
@@ -53,6 +53,23 @@ export interface GatewayApp {
}
/** Gateway-wide security controls, enforced for every app. */
export interface GatewayRequestLimits {
maxUrlLength?: number;
maxHeaderCount?: number;
maxHeaderBytes?: number;
maxQueryParameters?: number;
maxBodyBytes?: number;
timeoutMs?: number;
maxConcurrent?: number;
fetchMetadata?: boolean;
}
export interface GatewayWebSocketSecurity {
maxMessageBytes?: number;
maxQueuedMessages?: number;
allowedOrigins?: string[];
}
export interface GatewaySecurity {
/** Reject requests whose Host matches no app (404) instead of routing to the first. */
trustedHostsOnly?: boolean;
@@ -64,6 +81,10 @@ export interface GatewaySecurity {
forwardedHeaders?: boolean;
/** Log each request (host → app, method, path, status). */
accessLog?: boolean;
/** URL, header, body, timeout, concurrency, and Fetch Metadata limits. */
requestLimits?: GatewayRequestLimits;
/** WebSocket origin, payload, and pre-connect queue limits. */
websocket?: GatewayWebSocketSecurity;
}
export interface GatewayOptions {
@@ -93,6 +114,8 @@ interface WsBridge {
path: string;
backend?: WebSocket;
queue: Array<string | ArrayBuffer>;
maxMessageBytes: number;
maxQueuedMessages: number;
}
/** Decide whether a gateway child should be relaunched after it exits. */
@@ -121,6 +144,37 @@ function makeRateLimiter(max: number, windowMs: number) {
};
}
function requestHeaderBytes(headers: Headers): number {
let total = 0;
headers.forEach((value, name) => {
total += name.length + value.length + 4;
});
return total;
}
function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): number {
if (typeof value === "string") return new TextEncoder().encode(value).byteLength;
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
}
function gatewayWebSocketOriginAllowed(
req: Request,
target: Target,
configured: string[],
): boolean {
const origin = req.headers.get("origin");
if (!origin) return true;
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
return false;
}
if (configured.includes(origin)) return true;
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase());
}
/** Constant-time-ish string compare. */
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
@@ -486,16 +540,48 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
? makeRateLimiter(sec.rateLimit.max, sec.rateLimit.windowMs ?? 60_000)
: null;
const now = () => Date.now();
const limits = sec.requestLimits ?? {};
const websocketSecurity = sec.websocket ?? {};
const maxBodyBytes = limits.maxBodyBytes ?? 10 * 1024 * 1024;
const maxConcurrent = limits.maxConcurrent ?? 1_000;
let activeRequests = 0;
const createGatewayServer = () =>
Bun.serve<WsBridge>({
port,
hostname,
development: mode === "development",
maxRequestBodySize: 50 * 1024 * 1024,
maxRequestBodySize: maxBodyBytes,
async fetch(req, srv) {
const url = new URL(req.url);
const ip = srv.requestIP(req)?.address ?? "";
if (req.url.length > (limits.maxUrlLength ?? 8_192)) {
return new Response("URI Too Long", { status: 414 });
}
if ([...req.headers].length > (limits.maxHeaderCount ?? 100)) {
return new Response("Too Many Headers", { status: 431 });
}
if (requestHeaderBytes(req.headers) > (limits.maxHeaderBytes ?? 32 * 1024)) {
return new Response("Request Headers Too Large", { status: 431 });
}
if ([...url.searchParams].length > (limits.maxQueryParameters ?? 100)) {
return new Response("Too Many Query Parameters", { status: 400 });
}
const declaredLength = Number(req.headers.get("content-length") ?? 0);
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
return new Response("Payload Too Large", { status: 413 });
}
if (limits.fetchMetadata !== false) {
const site = req.headers.get("sec-fetch-site");
const mode = req.headers.get("sec-fetch-mode");
if (
site === "cross-site" &&
!["GET", "HEAD", "OPTIONS"].includes(req.method) &&
mode !== "cors"
) {
return new Response("Cross-site request denied", { status: 403 });
}
}
// Health/status endpoint (not proxied).
if (url.pathname === "/__gateway/health") {
@@ -539,8 +625,17 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
// WebSocket upgrade → proxy the socket to the app's realtime server.
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
if (!gatewayWebSocketOriginAllowed(req, target, websocketSecurity.allowedOrigins ?? [])) {
return new Response("WebSocket origin denied", { status: 403 });
}
const ok = srv.upgrade(req, {
data: { origin: target.origin, path: url.pathname + url.search, queue: [] },
data: {
origin: target.origin,
path: url.pathname + url.search,
queue: [],
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
},
});
return ok ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
}
@@ -550,19 +645,34 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
const body =
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
let res: Response;
if (activeRequests >= maxConcurrent) {
return new Response("Gateway overloaded", {
status: 503,
headers: { "retry-after": "1" },
});
}
activeRequests += 1;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), limits.timeoutMs ?? 30_000);
try {
res = await fetch(target.origin + url.pathname + url.search, {
method: req.method,
headers,
body,
redirect: "manual",
signal: controller.signal,
});
} catch (error) {
console.error(
`[wrnexus] gateway proxy error: ${req.method} ${url.pathname}${target.name} (${target.origin})`,
error instanceof Error ? (error.stack ?? error.message) : error,
);
res = new Response(`Gateway: app '${target.name}' is unavailable.`, { status: 502 });
res = new Response(`Gateway: app '${target.name}' is unavailable.`, {
status: error instanceof DOMException && error.name === "AbortError" ? 504 : 502,
});
} finally {
clearTimeout(timeout);
activeRequests -= 1;
}
const diagnostic = internalError(res);
if (res.status >= 500 && diagnostic) {
@@ -589,6 +699,21 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
});
backend.addEventListener("message", async (event) => {
const data = event.data;
const size =
typeof data === "string"
? new TextEncoder().encode(data).byteLength
: data instanceof Blob
? data.size
: data instanceof ArrayBuffer
? data.byteLength
: ArrayBuffer.isView(data)
? data.byteLength
: 0;
if (size > ws.data.maxMessageBytes) {
ws.close(1009, "Message too large");
backend.close(1009, "Message too large");
return;
}
if (typeof data === "string") {
ws.send(data);
@@ -619,6 +744,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
backend.addEventListener("error", () => ws.close());
},
message(ws, message) {
if (requestMessageBytes(message) > ws.data.maxMessageBytes) {
ws.close(1009, "Message too large");
ws.data.backend?.close(1009, "Message too large");
return;
}
let data: string | ArrayBuffer;
if (typeof message === "string") {
@@ -638,8 +768,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
if (backend && backend.readyState === WebSocket.OPEN) {
backend.send(data);
} else {
} else if (ws.data.queue.length < ws.data.maxQueuedMessages) {
ws.data.queue.push(data);
} else {
ws.close(1013, "WebSocket queue limit exceeded");
ws.data.backend?.close(1013, "WebSocket queue limit exceeded");
}
},
close(ws) {