release: WRNexusJS 0.7.0
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -486,13 +486,13 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
const url = new URL(request.url);
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin && origin !== url.origin) return false;
|
||||
const cookieToken = /(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(
|
||||
request.headers.get("cookie") ?? "",
|
||||
)?.[1];
|
||||
return (
|
||||
!cookieToken ||
|
||||
decodeURIComponent(cookieToken) === (request.headers.get("x-wrnexus-csrf") ?? "")
|
||||
);
|
||||
const cookieHeader = request.headers.get("cookie") ?? "";
|
||||
const cookieToken =
|
||||
/(?:^|;\s*)wire-csrf=([^;]+)/.exec(cookieHeader)?.[1] ??
|
||||
/(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(cookieHeader)?.[1];
|
||||
const headerToken =
|
||||
request.headers.get("x-csrf-token") ?? request.headers.get("x-wrnexus-csrf") ?? "";
|
||||
return !cookieToken || decodeURIComponent(cookieToken) === headerToken;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
||||
/**
|
||||
* Shared request runtime used by BOTH the dev server and the production server.
|
||||
*
|
||||
@@ -35,6 +36,13 @@ import {
|
||||
type SeoConfig,
|
||||
type TFunction,
|
||||
} from "@wrnexus/core";
|
||||
import { requestHardening } from "@wrnexus/security";
|
||||
import {
|
||||
createWebVitalsHandler,
|
||||
defaultMetrics,
|
||||
metricsMiddleware,
|
||||
webVitalsClient,
|
||||
} from "@wrnexus/observability";
|
||||
import type { Router } from "@wrnexus/router";
|
||||
import { renderDocument, type RenderScript, type ScriptAsset } from "@wrnexus/ssr";
|
||||
import {
|
||||
@@ -142,7 +150,7 @@ export interface RuntimeDeps {
|
||||
/** Package browser runtimes resolved by the plugin system. */
|
||||
clientRuntimes?: ClientRuntimeDefinition[];
|
||||
/** Page navigation strategy. `document` disables same-origin link interception. */
|
||||
navigation?: { mode?: "client" | "document" };
|
||||
navigation?: { mode?: "auto" | "client" | "document" };
|
||||
/** Raw HTML appended to every page head (e.g. CDN framework links). */
|
||||
head?: string;
|
||||
/** Global SEO defaults. */
|
||||
@@ -216,7 +224,12 @@ function tenantIdentityFromConfig(
|
||||
function frameworkMiddleware(deps: RuntimeDeps): Middleware[] {
|
||||
const middleware: Middleware[] = [];
|
||||
|
||||
if (deps.security?.requestLimits) {
|
||||
middleware.push(requestHardening(deps.security.requestLimits));
|
||||
}
|
||||
|
||||
if (deps.observability && deps.observability.enabled !== false) {
|
||||
middleware.push(metricsMiddleware({ registry: defaultMetrics, includePath: false }));
|
||||
middleware.push(
|
||||
tracingMiddleware(undefined, {
|
||||
sampleRate: deps.observability.sampleRate,
|
||||
@@ -855,6 +868,10 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
const pwaEnabled = deps.pwa !== false && deps.pwa?.enabled !== false;
|
||||
const pwaConfig: PwaConfig = deps.pwa && typeof deps.pwa === "object" ? deps.pwa : {};
|
||||
const pwaServiceWorkerEnabled = pwaEnabled && pwaConfig.serviceWorker !== false;
|
||||
const webVitalsEnabled =
|
||||
deps.observability?.enabled !== false && deps.observability?.webVitals === true;
|
||||
const webVitalsEndpoint = deps.observability?.webVitalsEndpoint ?? "/__wrnexus/metrics/vitals";
|
||||
const webVitalsHandler = createWebVitalsHandler({ registry: defaultMetrics });
|
||||
|
||||
const configuredPermissions = deps.security?.permissionsPolicy;
|
||||
const runtimeSecurity: SecurityConfig | undefined =
|
||||
@@ -883,6 +900,27 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
if (url.pathname === "/healthz" || url.pathname === "/__wrnexus/health") {
|
||||
return secure(Response.json({ status: "ok" }));
|
||||
}
|
||||
if (webVitalsEnabled && url.pathname === webVitalsEndpoint) {
|
||||
return secure(await webVitalsHandler(req));
|
||||
}
|
||||
if (webVitalsEnabled && url.pathname === "/__wrnexus/vitals.js") {
|
||||
return secure(
|
||||
new Response(
|
||||
webVitalsClient({
|
||||
endpoint: webVitalsEndpoint,
|
||||
sampleRate: deps.observability?.sampleRate,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"content-type": "text/javascript; charset=utf-8",
|
||||
"cache-control": url.searchParams.has("v")
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "no-cache",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === "/site.webmanifest" && pwaEnabled) {
|
||||
const pwa = pwaConfig;
|
||||
@@ -1455,6 +1493,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
const scripts = collectScripts(body, deps.clientRuntimes, deps.navigation).map((script) =>
|
||||
versionRenderScript(script, deps.assetVersion),
|
||||
);
|
||||
if (webVitalsEnabled) {
|
||||
scripts.push(versionAssetUrl("/__wrnexus/vitals.js", deps.assetVersion));
|
||||
}
|
||||
if (pwaServiceWorkerEnabled)
|
||||
scripts.push(versionAssetUrl("/__wrnexus/pwa.js", deps.assetVersion));
|
||||
if (deps.mobile?.enabled !== false && usesMobileRuntime(body))
|
||||
@@ -1653,8 +1694,10 @@ const COMPRESS_MIN_BYTES = 1024;
|
||||
* `Cache-Control: no-transform`, so they are never buffered here.
|
||||
*/
|
||||
async function compressResponse(req: Request, res: Response): Promise<Response> {
|
||||
const accept = req.headers.get("accept-encoding") ?? "";
|
||||
if (!accept.toLowerCase().includes("gzip")) return res;
|
||||
const accept = (req.headers.get("accept-encoding") ?? "").toLowerCase();
|
||||
const acceptsBrotli = /(?:^|,)\s*br(?:\s*;|\s*,|$)/.test(accept);
|
||||
const acceptsGzip = /(?:^|,)\s*gzip(?:\s*;|\s*,|$)/.test(accept);
|
||||
if (!acceptsBrotli && !acceptsGzip) return res;
|
||||
if (res.headers.get("content-encoding")) return res;
|
||||
if (res.status === 204 || res.status === 304) return res;
|
||||
if (!COMPRESSIBLE_TYPE.test(res.headers.get("content-type") ?? "")) return res;
|
||||
@@ -1668,14 +1711,34 @@ async function compressResponse(req: Request, res: Response): Promise<Response>
|
||||
headers: res.headers,
|
||||
});
|
||||
}
|
||||
const gzipped = Bun.gzipSync(body);
|
||||
|
||||
let encoded: Uint8Array;
|
||||
let encoding: "br" | "gzip";
|
||||
if (acceptsBrotli) {
|
||||
encoded = new Uint8Array(
|
||||
brotliCompressSync(body, {
|
||||
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
|
||||
}),
|
||||
);
|
||||
encoding = "br";
|
||||
} else {
|
||||
encoded = Bun.gzipSync(body);
|
||||
encoding = "gzip";
|
||||
}
|
||||
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("content-encoding", "gzip");
|
||||
headers.set("content-length", String(gzipped.length));
|
||||
headers.set("content-encoding", encoding);
|
||||
headers.set("content-length", String(encoded.length));
|
||||
const vary = headers.get("Vary");
|
||||
if (!vary) headers.set("Vary", "Accept-Encoding");
|
||||
else if (!/\baccept-encoding\b/i.test(vary)) headers.set("Vary", `${vary}, Accept-Encoding`);
|
||||
return new Response(gzipped, { status: res.status, statusText: res.statusText, headers });
|
||||
const responseBody = new ArrayBuffer(encoded.byteLength);
|
||||
new Uint8Array(responseBody).set(encoded);
|
||||
return new Response(responseBody, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/** Opening tag of a component mount: captures tag, attrs, name, self-close. */
|
||||
@@ -1842,11 +1905,9 @@ export function normalizeComponentName(name: string): string {
|
||||
export function collectScripts(
|
||||
body: string,
|
||||
clientRuntimes: readonly ClientRuntimeDefinition[] = [],
|
||||
navigation: { mode?: "client" | "document" } = {},
|
||||
navigation: { mode?: "auto" | "client" | "document" } = {},
|
||||
): RenderScript[] {
|
||||
// Client navigation is optional. In document mode, links retain native browser
|
||||
// behavior and each route receives a fresh server-rendered HTML document.
|
||||
const scripts: RenderScript[] = navigation.mode === "document" ? [] : ["/__wrnexus/nav.js"];
|
||||
const scripts: RenderScript[] = [];
|
||||
if (/\bdata-scope=/.test(body) || /\bdata-wrnexus-csr=/.test(body)) {
|
||||
scripts.push("/__wrnexus/reactive.js");
|
||||
}
|
||||
@@ -1877,6 +1938,15 @@ export function collectScripts(
|
||||
// `data-wrnexus-runtime="id"`; the corresponding package chunk is loaded
|
||||
// once, without requiring application-authored script tags or public copies.
|
||||
scripts.push(...runtimeScriptsForMarkup(body, clientRuntimes));
|
||||
|
||||
// `auto` is the performance-first default: a fully static page ships no
|
||||
// framework JavaScript and its links use native document navigation. Routes
|
||||
// that already need browser behavior also receive progressive navigation.
|
||||
// `client` preserves the explicit always-on behavior; `document` disables it.
|
||||
const mode = navigation.mode ?? "auto";
|
||||
if (mode === "client" || (mode === "auto" && scripts.length > 0)) {
|
||||
scripts.unshift("/__wrnexus/nav.js");
|
||||
}
|
||||
return scripts;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user