release: WRNexusJS 0.7.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# @wrnexus/benchmark
|
||||
|
||||
Deterministic benchmark execution, percentiles, baseline comparisons, and regression budgets for builds, SSR, hydration, stores, and application hot paths.
|
||||
|
||||
```ts
|
||||
import { runBenchmark, assertBenchmarkBudget } from "@wrnexus/benchmark";
|
||||
const result = await runBenchmark("render", render, { iterations: 100 });
|
||||
assertBenchmarkBudget(result, baseline, { p95Percent: 5 });
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@wrnexus/benchmark",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"description": "Deterministic benchmark runner and performance regression budgets for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
export interface BenchmarkOptions {
|
||||
iterations?: number;
|
||||
warmup?: number;
|
||||
clock?: () => number;
|
||||
setup?: () => void | Promise<void>;
|
||||
teardown?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface BenchmarkResult {
|
||||
name: string;
|
||||
iterations: number;
|
||||
totalMs: number;
|
||||
meanMs: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
p99Ms: number;
|
||||
operationsPerSecond: number;
|
||||
samples: number[];
|
||||
}
|
||||
|
||||
export interface RegressionBudget {
|
||||
meanPercent?: number;
|
||||
p95Percent?: number;
|
||||
maxAbsoluteMs?: number;
|
||||
minOperationsPerSecond?: number;
|
||||
}
|
||||
|
||||
export interface RegressionViolation {
|
||||
metric: "meanMs" | "p95Ms" | "maxMs" | "operationsPerSecond";
|
||||
baseline?: number;
|
||||
current: number;
|
||||
limit: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function percentile(values: readonly number[], quantile: number): number {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(quantile * sorted.length) - 1));
|
||||
return sorted[index]!;
|
||||
}
|
||||
|
||||
export async function runBenchmark(
|
||||
name: string,
|
||||
operation: () => void | Promise<void>,
|
||||
options: BenchmarkOptions = {},
|
||||
): Promise<BenchmarkResult> {
|
||||
const iterations = Math.max(1, Math.floor(options.iterations ?? 100));
|
||||
const warmup = Math.max(0, Math.floor(options.warmup ?? Math.min(10, iterations)));
|
||||
const clock = options.clock ?? (() => performance.now());
|
||||
await options.setup?.();
|
||||
try {
|
||||
for (let index = 0; index < warmup; index++) await operation();
|
||||
const samples: number[] = [];
|
||||
for (let index = 0; index < iterations; index++) {
|
||||
const start = clock();
|
||||
await operation();
|
||||
samples.push(Math.max(0, clock() - start));
|
||||
}
|
||||
const totalMs = samples.reduce((sum, sample) => sum + sample, 0);
|
||||
const meanMs = totalMs / samples.length;
|
||||
return {
|
||||
name,
|
||||
iterations,
|
||||
totalMs,
|
||||
meanMs,
|
||||
minMs: Math.min(...samples),
|
||||
maxMs: Math.max(...samples),
|
||||
p50Ms: percentile(samples, 0.5),
|
||||
p95Ms: percentile(samples, 0.95),
|
||||
p99Ms: percentile(samples, 0.99),
|
||||
operationsPerSecond: meanMs === 0 ? Number.POSITIVE_INFINITY : 1000 / meanMs,
|
||||
samples,
|
||||
};
|
||||
} finally {
|
||||
await options.teardown?.();
|
||||
}
|
||||
}
|
||||
|
||||
export function compareBenchmark(
|
||||
current: BenchmarkResult,
|
||||
baseline: BenchmarkResult | undefined,
|
||||
budget: RegressionBudget = {},
|
||||
): RegressionViolation[] {
|
||||
const violations: RegressionViolation[] = [];
|
||||
if (baseline && budget.meanPercent !== undefined) {
|
||||
const limit = baseline.meanMs * (1 + budget.meanPercent / 100);
|
||||
if (current.meanMs > limit) {
|
||||
violations.push({
|
||||
metric: "meanMs",
|
||||
baseline: baseline.meanMs,
|
||||
current: current.meanMs,
|
||||
limit,
|
||||
message: `Mean latency regressed by more than ${budget.meanPercent}%.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (baseline && budget.p95Percent !== undefined) {
|
||||
const limit = baseline.p95Ms * (1 + budget.p95Percent / 100);
|
||||
if (current.p95Ms > limit) {
|
||||
violations.push({
|
||||
metric: "p95Ms",
|
||||
baseline: baseline.p95Ms,
|
||||
current: current.p95Ms,
|
||||
limit,
|
||||
message: `P95 latency regressed by more than ${budget.p95Percent}%.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (budget.maxAbsoluteMs !== undefined && current.maxMs > budget.maxAbsoluteMs) {
|
||||
violations.push({
|
||||
metric: "maxMs",
|
||||
current: current.maxMs,
|
||||
limit: budget.maxAbsoluteMs,
|
||||
message: `Maximum latency exceeds ${budget.maxAbsoluteMs} ms.`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
budget.minOperationsPerSecond !== undefined &&
|
||||
current.operationsPerSecond < budget.minOperationsPerSecond
|
||||
) {
|
||||
violations.push({
|
||||
metric: "operationsPerSecond",
|
||||
current: current.operationsPerSecond,
|
||||
limit: budget.minOperationsPerSecond,
|
||||
message: `Throughput is below ${budget.minOperationsPerSecond} operations/second.`,
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function assertBenchmarkBudget(
|
||||
current: BenchmarkResult,
|
||||
baseline: BenchmarkResult | undefined,
|
||||
budget: RegressionBudget,
|
||||
): void {
|
||||
const violations = compareBenchmark(current, baseline, budget);
|
||||
if (violations.length) {
|
||||
throw new Error(
|
||||
`Benchmark '${current.name}' failed:\n${violations.map((item) => `- ${item.message}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { compareBenchmark, percentile, runBenchmark } from "../src/index.ts";
|
||||
|
||||
describe("@wrnexus/benchmark", () => {
|
||||
test("calculates percentiles and deterministic samples", async () => {
|
||||
let now = 0;
|
||||
const result = await runBenchmark(
|
||||
"clock",
|
||||
() => {
|
||||
now += 2;
|
||||
},
|
||||
{
|
||||
iterations: 3,
|
||||
warmup: 0,
|
||||
clock: () => now,
|
||||
},
|
||||
);
|
||||
expect(result.samples).toEqual([2, 2, 2]);
|
||||
expect(result.p95Ms).toBe(2);
|
||||
expect(percentile([1, 2, 3, 4], 0.5)).toBe(2);
|
||||
});
|
||||
|
||||
test("reports regression budget violations", () => {
|
||||
const baseline = { meanMs: 10, p95Ms: 20 } as any;
|
||||
const current = { meanMs: 12, p95Ms: 30, maxMs: 40, operationsPerSecond: 50 } as any;
|
||||
expect(compareBenchmark(current, baseline, { meanPercent: 10, p95Percent: 20 })).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
});
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# @wrnexus/cache
|
||||
|
||||
Bounded in-memory/tag caching and HTTP response caching for WRNexusJS. Supports request deduplication, tag invalidation, ETags, fresh/stale states, and optional detached stale revalidation.
|
||||
|
||||
```ts
|
||||
import { TagCache, responseCache } from "@wrnexus/cache";
|
||||
const cache = new TagCache({ ttlMs: 60_000, staleWhileRevalidateMs: 300_000 });
|
||||
export default responseCache({ cache, tags: ["products"] });
|
||||
```
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@wrnexus/cache",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"description": "Tag-aware memory and response caching with stale-while-revalidate for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export { TagCache } from "./memory.ts";
|
||||
export type { CacheEntry, CacheLookup, CacheSetOptions, TagCacheOptions } from "./memory.ts";
|
||||
export { responseCache } from "./response.ts";
|
||||
export type { CachedResponse, ResponseCacheOptions } from "./response.ts";
|
||||
Vendored
+151
@@ -0,0 +1,151 @@
|
||||
export interface CacheEntry<V> {
|
||||
value: V;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
staleUntil: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export type CacheLookup<V> = { state: "miss" } | { state: "fresh" | "stale"; entry: CacheEntry<V> };
|
||||
|
||||
export interface CacheSetOptions {
|
||||
ttlMs?: number;
|
||||
staleWhileRevalidateMs?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface TagCacheOptions {
|
||||
ttlMs?: number;
|
||||
staleWhileRevalidateMs?: number;
|
||||
maxEntries?: number;
|
||||
clock?: () => number;
|
||||
}
|
||||
|
||||
export class TagCache<V = unknown> {
|
||||
private entries = new Map<string, CacheEntry<V>>();
|
||||
private tagIndex = new Map<string, Set<string>>();
|
||||
private pending = new Map<string, Promise<V>>();
|
||||
private readonly ttlMs: number;
|
||||
private readonly staleMs: number;
|
||||
private readonly maxEntries: number;
|
||||
private readonly clock: () => number;
|
||||
|
||||
constructor(options: TagCacheOptions = {}) {
|
||||
this.ttlMs = options.ttlMs ?? 60_000;
|
||||
this.staleMs = options.staleWhileRevalidateMs ?? 0;
|
||||
this.maxEntries = Math.max(1, options.maxEntries ?? 10_000);
|
||||
this.clock = options.clock ?? Date.now;
|
||||
}
|
||||
|
||||
lookup(key: string): CacheLookup<V> {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return { state: "miss" };
|
||||
const now = this.clock();
|
||||
if (entry.staleUntil <= now) {
|
||||
this.delete(key);
|
||||
return { state: "miss" };
|
||||
}
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, entry);
|
||||
return { state: entry.expiresAt > now ? "fresh" : "stale", entry };
|
||||
}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const hit = this.lookup(key);
|
||||
return hit.state === "miss" ? undefined : hit.entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: V, options: CacheSetOptions = {}): void {
|
||||
this.delete(key);
|
||||
const now = this.clock();
|
||||
const ttlMs = Math.max(0, options.ttlMs ?? this.ttlMs);
|
||||
const staleMs = Math.max(0, options.staleWhileRevalidateMs ?? this.staleMs);
|
||||
const tags = [...new Set(options.tags ?? [])];
|
||||
const entry: CacheEntry<V> = {
|
||||
value,
|
||||
createdAt: now,
|
||||
expiresAt: now + ttlMs,
|
||||
staleUntil: now + ttlMs + staleMs,
|
||||
tags,
|
||||
};
|
||||
this.entries.set(key, entry);
|
||||
for (const tag of tags) {
|
||||
const keys = this.tagIndex.get(tag) ?? new Set<string>();
|
||||
keys.add(key);
|
||||
this.tagIndex.set(tag, keys);
|
||||
}
|
||||
while (this.entries.size > this.maxEntries) {
|
||||
const oldest = this.entries.keys().next().value as string | undefined;
|
||||
if (oldest === undefined) break;
|
||||
this.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
async getOrLoad(
|
||||
key: string,
|
||||
loader: () => V | Promise<V>,
|
||||
options: CacheSetOptions = {},
|
||||
): Promise<V> {
|
||||
const hit = this.lookup(key);
|
||||
if (hit.state === "fresh") return hit.entry.value;
|
||||
if (hit.state === "stale") {
|
||||
if (!this.pending.has(key)) {
|
||||
const refresh = Promise.resolve()
|
||||
.then(loader)
|
||||
.then((value) => {
|
||||
this.set(key, value, options);
|
||||
return value;
|
||||
})
|
||||
.finally(() => this.pending.delete(key));
|
||||
this.pending.set(key, refresh);
|
||||
}
|
||||
return hit.entry.value;
|
||||
}
|
||||
const existing = this.pending.get(key);
|
||||
if (existing) return existing;
|
||||
const pending = Promise.resolve()
|
||||
.then(loader)
|
||||
.then((value) => {
|
||||
this.set(key, value, options);
|
||||
return value;
|
||||
})
|
||||
.finally(() => this.pending.delete(key));
|
||||
this.pending.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return false;
|
||||
this.entries.delete(key);
|
||||
for (const tag of entry.tags) {
|
||||
const keys = this.tagIndex.get(tag);
|
||||
keys?.delete(key);
|
||||
if (keys?.size === 0) this.tagIndex.delete(tag);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
invalidateTag(tag: string): number {
|
||||
const keys = [...(this.tagIndex.get(tag) ?? [])];
|
||||
for (const key of keys) this.delete(key);
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
invalidateTags(tags: Iterable<string>): number {
|
||||
const keys = new Set<string>();
|
||||
for (const tag of tags) for (const key of this.tagIndex.get(tag) ?? []) keys.add(key);
|
||||
for (const key of keys) this.delete(key);
|
||||
return keys.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
this.tagIndex.clear();
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
}
|
||||
Vendored
+109
@@ -0,0 +1,109 @@
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
import { etag, notModified } from "@wrnexus/core";
|
||||
import { TagCache, type CacheSetOptions } from "./memory.ts";
|
||||
|
||||
export interface CachedResponse {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: [string, string][];
|
||||
body: Uint8Array;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
export interface ResponseCacheOptions extends CacheSetOptions {
|
||||
cache?: TagCache<CachedResponse>;
|
||||
key?: (ctx: Context) => string;
|
||||
vary?: string[];
|
||||
shouldCache?: (ctx: Context, response: Response) => boolean;
|
||||
/**
|
||||
* Optional detached revalidator used for stale-while-revalidate. Middleware
|
||||
* `next()` is deliberately never called after a response has been returned,
|
||||
* because many middleware pipelines are single-use.
|
||||
*/
|
||||
revalidate?: (ctx: Context) => Promise<Response>;
|
||||
onRevalidateError?: (error: unknown, ctx: Context) => void;
|
||||
}
|
||||
|
||||
function defaultKey(ctx: Context, vary: string[]): string {
|
||||
const values = vary.map((name) => `${name.toLowerCase()}=${ctx.req.headers.get(name) ?? ""}`);
|
||||
return `${ctx.req.method}:${ctx.url.origin}${ctx.url.pathname}${ctx.url.search}|${values.join("|")}`;
|
||||
}
|
||||
|
||||
function cacheable(ctx: Context, response: Response): boolean {
|
||||
if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") return false;
|
||||
if (response.status < 200 || response.status >= 400) return false;
|
||||
if (response.headers.has("set-cookie")) return false;
|
||||
const control = response.headers.get("cache-control") ?? "";
|
||||
return !/(?:^|,)\s*(?:no-store|private)(?:\s|,|$)/i.test(control);
|
||||
}
|
||||
|
||||
function fromCached(ctx: Context, cached: CachedResponse, state: "fresh" | "stale"): Response {
|
||||
const headers = new Headers(cached.headers);
|
||||
headers.set("x-wrnexus-cache", state === "fresh" ? "HIT" : "STALE");
|
||||
headers.set("age", "0");
|
||||
if (notModified(ctx.req, cached.etag)) {
|
||||
return new Response(null, { status: 304, headers });
|
||||
}
|
||||
return new Response(ctx.req.method === "HEAD" ? null : cached.body.slice(), {
|
||||
status: cached.status,
|
||||
statusText: cached.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function capture(response: Response): Promise<CachedResponse> {
|
||||
const body = new Uint8Array(await response.clone().arrayBuffer());
|
||||
const tag = response.headers.get("etag") ?? etag(body);
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("etag", tag);
|
||||
return {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: [...headers.entries()],
|
||||
body,
|
||||
etag: tag,
|
||||
};
|
||||
}
|
||||
|
||||
export function responseCache(options: ResponseCacheOptions = {}): Middleware {
|
||||
const cache = options.cache ?? new TagCache<CachedResponse>();
|
||||
const vary = options.vary ?? ["accept-encoding", "accept-language"];
|
||||
return async (ctx, next) => {
|
||||
if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") return next();
|
||||
const key = options.key?.(ctx) ?? defaultKey(ctx, vary);
|
||||
const hit = cache.lookup(key);
|
||||
if (hit.state === "fresh") return fromCached(ctx, hit.entry.value, "fresh");
|
||||
if (hit.state === "stale") {
|
||||
if (options.revalidate) {
|
||||
void options
|
||||
.revalidate(ctx)
|
||||
.then(async (response) => {
|
||||
if ((options.shouldCache ?? cacheable)(ctx, response)) {
|
||||
cache.set(key, await capture(response), options);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => options.onRevalidateError?.(error, ctx));
|
||||
}
|
||||
return fromCached(ctx, hit.entry.value, "stale");
|
||||
}
|
||||
|
||||
const response = await next();
|
||||
if (!(options.shouldCache ?? cacheable)(ctx, response)) {
|
||||
try {
|
||||
response.headers.set("x-wrnexus-cache", "BYPASS");
|
||||
} catch {
|
||||
// Immutable response.
|
||||
}
|
||||
return response;
|
||||
}
|
||||
const stored = await capture(response);
|
||||
cache.set(key, stored, options);
|
||||
const headers = new Headers(stored.headers);
|
||||
headers.set("x-wrnexus-cache", "MISS");
|
||||
return new Response(ctx.req.method === "HEAD" ? null : stored.body.slice(), {
|
||||
status: stored.status,
|
||||
statusText: stored.statusText,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
}
|
||||
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { TagCache, responseCache } from "../src/index.ts";
|
||||
|
||||
describe("@wrnexus/cache", () => {
|
||||
test("supports fresh, stale, and tag invalidation", async () => {
|
||||
let now = 0;
|
||||
const cache = new TagCache<number>({ ttlMs: 10, staleWhileRevalidateMs: 10, clock: () => now });
|
||||
cache.set("a", 1, { tags: ["users"] });
|
||||
expect(cache.lookup("a").state).toBe("fresh");
|
||||
now = 11;
|
||||
expect(cache.lookup("a").state).toBe("stale");
|
||||
expect(cache.invalidateTag("users")).toBe(1);
|
||||
expect(cache.lookup("a").state).toBe("miss");
|
||||
});
|
||||
|
||||
test("deduplicates concurrent loaders", async () => {
|
||||
const cache = new TagCache<number>();
|
||||
let calls = 0;
|
||||
const loader = async () => ++calls;
|
||||
const [a, b] = await Promise.all([cache.getOrLoad("x", loader), cache.getOrLoad("x", loader)]);
|
||||
expect(a).toBe(1);
|
||||
expect(b).toBe(1);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("response middleware does not call next twice for stale entries", async () => {
|
||||
let now = 0;
|
||||
const cache = new TagCache<any>({ ttlMs: 1, staleWhileRevalidateMs: 100, clock: () => now });
|
||||
const middleware = responseCache({ cache, ttlMs: 1, staleWhileRevalidateMs: 100 });
|
||||
let calls = 0;
|
||||
const ctx = {
|
||||
req: new Request("https://example.com/a"),
|
||||
url: new URL("https://example.com/a"),
|
||||
} as any;
|
||||
await middleware(ctx, async () => {
|
||||
calls++;
|
||||
return new Response("one");
|
||||
});
|
||||
now = 2;
|
||||
const stale = await middleware(ctx, async () => {
|
||||
calls++;
|
||||
return new Response("two");
|
||||
});
|
||||
expect(await stale.text()).toBe("one");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -24,7 +24,13 @@ import {
|
||||
import { basename, extname, join, resolve } from "node:path";
|
||||
import { buildRouter, type Route } from "@wrnexus/router";
|
||||
import { getReactiveRuntime } from "@wrnexus/csr";
|
||||
import { assertValidAst, generate, parse } from "@wrnexus/compiler";
|
||||
import {
|
||||
analyzeRuntimeRequirements,
|
||||
assertValidAst,
|
||||
generate,
|
||||
parse,
|
||||
type RuntimeRequirements,
|
||||
} from "@wrnexus/compiler";
|
||||
import {
|
||||
loadAppConfig,
|
||||
headToString,
|
||||
@@ -102,12 +108,14 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
// keep the exact compiler path and output contract by default.
|
||||
let compiledCount = 0;
|
||||
const compiledFiles = new Map<string, string>();
|
||||
const runtimeAnalysis = new Map<string, RuntimeRequirements>();
|
||||
const compileWrn = async (file: string): Promise<void> => {
|
||||
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
|
||||
const source = readFileSync(file, "utf8");
|
||||
let ast = parse(source);
|
||||
assertValidAst(ast, { file, accessibility: true });
|
||||
ast = await pluginRunner.transformAst(ast, file);
|
||||
runtimeAnalysis.set(file, analyzeRuntimeRequirements(ast));
|
||||
const pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
|
||||
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
|
||||
@@ -453,6 +461,7 @@ await createProductionServer(
|
||||
],
|
||||
runtimeFile: reactivePath,
|
||||
cssFile: hasStyles ? join(distDir, "styles.css") : join(distDir, "framework.css"),
|
||||
runtimeAnalysis,
|
||||
});
|
||||
report.pluginAssets = emittedPluginAssets.assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
@@ -636,6 +645,11 @@ interface BuildReport {
|
||||
source: string;
|
||||
sourceBytes: number;
|
||||
dynamicParams: string[];
|
||||
execution: RuntimeRequirements["kind"];
|
||||
canPrerender: boolean;
|
||||
needsClientRuntime: boolean;
|
||||
needsServerRuntime: boolean;
|
||||
hydrationStrategy: string | null;
|
||||
}>;
|
||||
assets: Array<{ file: string; bytes: number }>;
|
||||
measurements: { routeJsBytes: number; routeCssBytes: number; imageBytes: number };
|
||||
@@ -666,6 +680,7 @@ function createBuildReport(input: {
|
||||
routes: Array<{ kind: "page" | "api" | "realtime"; route: Route }>;
|
||||
runtimeFile: string;
|
||||
cssFile: string;
|
||||
runtimeAnalysis: ReadonlyMap<string, RuntimeRequirements>;
|
||||
}): BuildReport {
|
||||
const assets = walkFiles(input.distDir)
|
||||
.filter((file) => !file.endsWith("build-report.json") && !fwd(file).includes("/compiled/"))
|
||||
@@ -689,6 +704,11 @@ function createBuildReport(input: {
|
||||
source: fwd(route.file.replace(input.root, "").replace(/^\//, "")),
|
||||
sourceBytes: fileBytes(route.file),
|
||||
dynamicParams: route.paramNames,
|
||||
execution: input.runtimeAnalysis.get(route.file)?.kind ?? "dynamic",
|
||||
canPrerender: input.runtimeAnalysis.get(route.file)?.canPrerender ?? false,
|
||||
needsClientRuntime: input.runtimeAnalysis.get(route.file)?.needsClientRuntime ?? true,
|
||||
needsServerRuntime: input.runtimeAnalysis.get(route.file)?.needsServerRuntime ?? true,
|
||||
hydrationStrategy: input.runtimeAnalysis.get(route.file)?.hydrationStrategy ?? null,
|
||||
})),
|
||||
assets,
|
||||
measurements: {
|
||||
|
||||
@@ -563,6 +563,71 @@ function writeMigrationReport(ctx: MigrationCtx): void {
|
||||
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run. Source
|
||||
* migrations must preserve semantics and are protected by update backups.
|
||||
*/
|
||||
|
||||
function updateV070Config(ctx: MigrationCtx): void {
|
||||
const candidates = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
|
||||
const file = candidates.map((name) => join(ctx.appRoot, name)).find(existsSync);
|
||||
if (!file) return;
|
||||
const current = readFileSync(file, "utf8");
|
||||
const additions: string[] = [];
|
||||
if (!/\bnavigation\s*:/.test(current)) {
|
||||
additions.push(` navigation: {
|
||||
mode: "auto",
|
||||
}`);
|
||||
}
|
||||
if (!/\bsecurity\s*:/.test(current)) {
|
||||
additions.push(` security: {
|
||||
headers: true,
|
||||
requestLimits: {
|
||||
maxUrlLength: 8192,
|
||||
maxHeaderCount: 100,
|
||||
maxHeaderBytes: 32768,
|
||||
maxQueryParameters: 100,
|
||||
maxBodyBytes: 10485760,
|
||||
timeoutMs: 30000,
|
||||
maxConcurrent: 1000,
|
||||
fetchMetadata: true,
|
||||
},
|
||||
contentSecurityPolicy: { enabled: true, useDefaults: true },
|
||||
trustedTypes: { enabled: true, requireForScript: true },
|
||||
hsts: { enabled: true, maxAge: 63072000, includeSubDomains: true, preload: true },
|
||||
}`);
|
||||
}
|
||||
if (!/\bperformance\s*:/.test(current)) {
|
||||
additions.push(` performance: {
|
||||
enforcement: "warn",
|
||||
analyze: true,
|
||||
budgets: {
|
||||
routeJsBytes: 51200,
|
||||
routeCssBytes: 25600,
|
||||
htmlBytes: 204800,
|
||||
hydrationMs: 200,
|
||||
serverRenderMs: 500,
|
||||
lcpMs: 2500,
|
||||
inpMs: 200,
|
||||
cls: 0.1,
|
||||
ttfbMs: 800,
|
||||
},
|
||||
}`);
|
||||
}
|
||||
if (!/\bobservability\s*:/.test(current)) {
|
||||
additions.push(` observability: {
|
||||
enabled: true,
|
||||
serverTiming: true,
|
||||
sampleRate: 0.1,
|
||||
exporter: "none",
|
||||
webVitals: true,
|
||||
}`);
|
||||
}
|
||||
if (!additions.length) return;
|
||||
const index = current.lastIndexOf("}");
|
||||
if (index < 0) return;
|
||||
const before = current.slice(0, index).replace(/,?\s*$/, "");
|
||||
const next = `${before},\n${additions.join(",\n")}\n${current.slice(index)}`;
|
||||
ctx.log(`~ ${file.slice(ctx.appRoot.length + 1)}: security/performance production defaults`);
|
||||
if (!ctx.dryRun) writeFileSync(file, next, "utf8");
|
||||
}
|
||||
|
||||
const MIGRATIONS: Migration[] = [
|
||||
{
|
||||
version: "0.2.8",
|
||||
@@ -1737,6 +1802,61 @@ const MIGRATIONS: Migration[] = [
|
||||
writeMigrationReport(ctx);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.7.0",
|
||||
id: "0.7.0-01-security-performance-foundation",
|
||||
description:
|
||||
"Adds secure-by-default request limits, caching, image optimization, observability, benchmarks, and production hardening packages.",
|
||||
apply(ctx) {
|
||||
const file = join(ctx.appRoot, "package.json");
|
||||
if (existsSync(file)) {
|
||||
const pkg = JSON.parse(readFileSync(file, "utf8")) as Record<string, any>;
|
||||
const dependencies = (pkg.dependencies ??= {});
|
||||
const devDependencies = (pkg.devDependencies ??= {});
|
||||
const added: string[] = [];
|
||||
for (const name of [
|
||||
"@wrnexus/security",
|
||||
"@wrnexus/cache",
|
||||
"@wrnexus/image",
|
||||
"@wrnexus/observability",
|
||||
]) {
|
||||
if (dependencies[name] === `^${ctx.to}`) continue;
|
||||
dependencies[name] = `^${ctx.to}`;
|
||||
added.push(name);
|
||||
}
|
||||
if (devDependencies["@wrnexus/benchmark"] !== `^${ctx.to}`) {
|
||||
devDependencies["@wrnexus/benchmark"] = `^${ctx.to}`;
|
||||
added.push("@wrnexus/benchmark (dev)");
|
||||
}
|
||||
if (added.length) {
|
||||
ctx.log(`+ security/performance dependencies: ${added.join(", ")}`);
|
||||
if (!ctx.dryRun) writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
}
|
||||
}
|
||||
updateV070Config(ctx);
|
||||
const gitignore = join(ctx.appRoot, ".gitignore");
|
||||
const currentIgnore = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "";
|
||||
const ignoreEntries = [".env", ".env.*", "!.env.example", "!.env.*.example"];
|
||||
const knownIgnore = new Set(currentIgnore.split(/\r?\n/).map((line) => line.trim()));
|
||||
const missingIgnore = ignoreEntries.filter((entry) => !knownIgnore.has(entry));
|
||||
if (missingIgnore.length) {
|
||||
ctx.log(`+ .gitignore secure environment templates: ${missingIgnore.join(", ")}`);
|
||||
if (!ctx.dryRun) {
|
||||
writeFileSync(
|
||||
gitignore,
|
||||
`${currentIgnore.replace(/\s*$/, "")}\n${missingIgnore.join("\n")}\n`.replace(
|
||||
/^\n+/,
|
||||
"",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
const review =
|
||||
"Review WRNexusJS 0.7 security policy, CSP allowlists, tenant authorization, upload scanners, cache adapters, and production performance budgets.";
|
||||
if (!ctx.report.needsReview.includes(review)) ctx.report.needsReview.push(review);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { PageAst, ViewNode } from "@wrnexus/syntax";
|
||||
|
||||
export type RouteExecutionKind =
|
||||
| "static"
|
||||
| "static-interactive"
|
||||
| "request-ssr"
|
||||
| "authenticated-ssr"
|
||||
| "streaming-ssr"
|
||||
| "dynamic";
|
||||
|
||||
export interface RuntimeRequirements {
|
||||
kind: RouteExecutionKind;
|
||||
canPrerender: boolean;
|
||||
needsClientRuntime: boolean;
|
||||
needsServerRuntime: boolean;
|
||||
hydrationStrategy: string | null;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
function hasEvent(nodes: ViewNode[]): boolean {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "element") {
|
||||
if (node.attrs.some((attribute) => attribute.event)) return true;
|
||||
if (hasEvent(node.children)) return true;
|
||||
} else if (node.type === "each") {
|
||||
if (hasEvent(node.body) || hasEvent(node.empty)) return true;
|
||||
} else if (node.type === "if") {
|
||||
if (node.branches.some((branch) => hasEvent(branch.body))) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
|
||||
const reasons: string[] = [];
|
||||
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
|
||||
const clientState = ast.states.some((state) => state.runtime !== "server");
|
||||
const interactive =
|
||||
clientFunctions ||
|
||||
clientState ||
|
||||
ast.effects.length > 0 ||
|
||||
ast.watches.length > 0 ||
|
||||
hasEvent(ast.view);
|
||||
if (interactive) reasons.push("client interactivity");
|
||||
|
||||
const requestData =
|
||||
ast.loads.length > 0 ||
|
||||
ast.actions.length > 0 ||
|
||||
ast.dataApis.length > 0 ||
|
||||
ast.apis.length > 0 ||
|
||||
ast.realtimes.length > 0 ||
|
||||
ast.runtimeFunctions.some((fn) => fn.runtime === "server") ||
|
||||
ast.states.some((state) => state.runtime === "server");
|
||||
if (requestData) reasons.push("server/request data");
|
||||
|
||||
const authenticated = /^(?:required|true)$/i.test(ast.security.auth ?? "");
|
||||
if (authenticated) reasons.push("authentication required");
|
||||
|
||||
const streaming = /^(?:true|required)$/i.test(ast.security.streaming ?? "");
|
||||
if (streaming) reasons.push("streaming enabled");
|
||||
|
||||
let kind: RouteExecutionKind;
|
||||
if (streaming) kind = "streaming-ssr";
|
||||
else if (authenticated) kind = "authenticated-ssr";
|
||||
else if (requestData && interactive) kind = "dynamic";
|
||||
else if (requestData) kind = "request-ssr";
|
||||
else if (interactive) kind = "static-interactive";
|
||||
else kind = "static";
|
||||
|
||||
return {
|
||||
kind,
|
||||
canPrerender: kind === "static" || kind === "static-interactive",
|
||||
needsClientRuntime: interactive && ast.hydrate !== "none" && ast.runtime !== "server",
|
||||
needsServerRuntime: requestData || authenticated || streaming || ast.runtime === "server",
|
||||
hydrationStrategy: interactive ? (ast.hydrate ?? "load") : null,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
@@ -77,6 +77,38 @@ function isHtmlBooleanAttribute(name: string): boolean {
|
||||
return HTML_BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
const URL_ATTRIBUTES = new Set([
|
||||
"href",
|
||||
"src",
|
||||
"action",
|
||||
"formaction",
|
||||
"poster",
|
||||
"cite",
|
||||
"background",
|
||||
"xlink:href",
|
||||
]);
|
||||
|
||||
function stripAsciiControlAndSpace(value: string): string {
|
||||
let result = "";
|
||||
for (const character of value) {
|
||||
if (character.charCodeAt(0) > 0x20) result += character;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sanitizeUrlAttribute(value: string): string {
|
||||
const compact = stripAsciiControlAndSpace(value.trim());
|
||||
const lower = compact.toLowerCase();
|
||||
if (/^(?:javascript|vbscript|file):/.test(lower)) return "about:blank";
|
||||
if (/^data:(?!image\/(?:png|gif|jpeg|webp|avif);)/.test(lower)) return "about:blank";
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeAttributeValue(name: string, value: string): string {
|
||||
if (!URL_ATTRIBUTES.has(name.toLowerCase()) || value.includes("{")) return value;
|
||||
return sanitizeUrlAttribute(value);
|
||||
}
|
||||
|
||||
/** Escape a value placed inside a double-quoted HTML attribute. */
|
||||
function attrEscape(value: string): string {
|
||||
return value
|
||||
@@ -110,7 +142,9 @@ function renderAttr(attr: Attr): string {
|
||||
case "csrText":
|
||||
return "";
|
||||
default:
|
||||
return attr.boolean ? ` ${attr.name}` : ` ${attr.name}="${attrEscape(attr.value)}"`;
|
||||
return attr.boolean
|
||||
? ` ${attr.name}`
|
||||
: ` ${attr.name}="${attrEscape(safeAttributeValue(attr.name, attr.value))}"`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +210,7 @@ function renderAttrs(
|
||||
const initial = reactiveAttrValue(attr.value, reactive);
|
||||
if (initial === null) return base;
|
||||
const marker = JSON.stringify([attr.name, attr.value]);
|
||||
return ` ${attr.name}="${attrEscape(initial)}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
|
||||
return ` ${attr.name}="${attrEscape(URL_ATTRIBUTES.has(attr.name.toLowerCase()) ? sanitizeUrlAttribute(initial) : initial)}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
|
||||
})
|
||||
.join("");
|
||||
return csrId ? `${rendered} data-wrnexus-csr="${attrEscape(csrId)}"` : rendered;
|
||||
@@ -2148,7 +2182,7 @@ function renderPageComponentAttr(attr: Attr, dynamicExpressions: string[]): stri
|
||||
const expression = wholeAttributeExpression(attr.value);
|
||||
|
||||
if (!expression) {
|
||||
return ` ${attr.name}="${attrEscape(attr.value)}"`;
|
||||
return ` ${attr.name}="${attrEscape(safeAttributeValue(attr.name, attr.value))}"`;
|
||||
}
|
||||
|
||||
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
|
||||
|
||||
@@ -35,6 +35,8 @@ export { generateStoreBrowserModule, generateStoreModule } from "./store-codegen
|
||||
export { createComponentContract } from "./component-contract.ts";
|
||||
export { resolveWrnImport, resolveWrnImports } from "./import-resolver.ts";
|
||||
export { createWrnSourceMap } from "./source-map.ts";
|
||||
export { analyzeRuntimeRequirements } from "./analysis.ts";
|
||||
export type { RouteExecutionKind, RuntimeRequirements } from "./analysis.ts";
|
||||
export { generateNative, NativeCompileError } from "./native-codegen.ts";
|
||||
export { Lexer, LexError } from "@wrnexus/syntax";
|
||||
export { eraseFunctionTypes, inferredRuntimeType, runtimeTypeOf } from "@wrnexus/syntax";
|
||||
|
||||
@@ -204,7 +204,7 @@ function __diagnostic(code, message, details) {
|
||||
}
|
||||
function __csrfToken() {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
const match = /(?:^|;\\s*)wrnexus_csrf=([^;]+)/.exec(document.cookie || "");
|
||||
const match = /(?:^|;\\s*)wire-csrf=([^;]+)/.exec(document.cookie || "");
|
||||
return match ? decodeURIComponent(match[1]) : undefined;
|
||||
}
|
||||
async function __callServerFunction(storeName, functionName, args, options) {
|
||||
@@ -215,7 +215,7 @@ async function __callServerFunction(storeName, functionName, args, options) {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
signal: options.signal,
|
||||
headers: Object.assign({ "content-type": "application/json", "x-request-id": traceId }, csrf ? { "x-wrnexus-csrf": csrf } : {}, options.headers || {}),
|
||||
headers: Object.assign({ "content-type": "application/json", "x-request-id": traceId }, csrf ? { "x-csrf-token": csrf } : {}, options.headers || {}),
|
||||
body: JSON.stringify({ component: storeName, function: functionName, args: args }),
|
||||
});
|
||||
const payload = await response.json().catch(function () { return null; });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface CacheControlOptions {
|
||||
noCache?: boolean;
|
||||
/** stale-while-revalidate window in seconds. */
|
||||
staleWhileRevalidate?: number;
|
||||
/** stale-if-error window in seconds. */
|
||||
staleIfError?: number;
|
||||
immutable?: boolean;
|
||||
}
|
||||
|
||||
@@ -104,6 +106,9 @@ export function cacheControl(options: CacheControlOptions): string {
|
||||
if (options.staleWhileRevalidate !== undefined) {
|
||||
parts.push(`stale-while-revalidate=${Math.max(0, Math.floor(options.staleWhileRevalidate))}`);
|
||||
}
|
||||
if (options.staleIfError !== undefined) {
|
||||
parts.push(`stale-if-error=${Math.max(0, Math.floor(options.staleIfError))}`);
|
||||
}
|
||||
if (options.immutable) parts.push("immutable");
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
+32
-24
@@ -1,11 +1,6 @@
|
||||
/**
|
||||
* CSRF protection via the double-submit cookie pattern.
|
||||
*
|
||||
* The framework sets a readable `wire-csrf` cookie on page loads; the client
|
||||
* echoes it in an `x-csrf-token` header on unsafe requests (the Wire UI form
|
||||
* runtime does this automatically). The server checks header === cookie. A
|
||||
* cross-site attacker can't read the cookie to forge the header, so the request
|
||||
* is rejected — while same-origin requests pass.
|
||||
* CSRF protection via the double-submit cookie pattern plus origin/fetch
|
||||
* metadata validation for unsafe requests.
|
||||
*/
|
||||
|
||||
import type { Context, Middleware } from "./context.ts";
|
||||
@@ -15,12 +10,20 @@ export const CSRF_HEADER = "x-csrf-token";
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
export interface CsrfProtectionOptions {
|
||||
/** Validate Origin when present. Defaults to true. */
|
||||
verifyOrigin?: boolean;
|
||||
/** Additional exact origins permitted for trusted cross-origin clients. */
|
||||
trustedOrigins?: string[];
|
||||
/** Reject Sec-Fetch-Site: cross-site on unsafe requests. Defaults to true. */
|
||||
verifyFetchMetadata?: boolean;
|
||||
}
|
||||
|
||||
/** Ensure the CSRF cookie exists (readable by JS) and return its token. */
|
||||
export function csrfToken(ctx: Context): string {
|
||||
let token = ctx.cookies.get(CSRF_COOKIE);
|
||||
if (!token) {
|
||||
token = crypto.randomUUID().replace(/-/g, "");
|
||||
// Readable by JS (double-submit needs it) but Secure on HTTPS.
|
||||
ctx.cookies.set(CSRF_COOKIE, token, {
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
@@ -30,33 +33,38 @@ export function csrfToken(ctx: Context): string {
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an unsafe request's CSRF token against the cookie. Safe methods
|
||||
* (GET/HEAD/OPTIONS) always pass. The token may arrive in the `x-csrf-token`
|
||||
* header or a `_csrf` field already parsed onto `ctx.locals`.
|
||||
*/
|
||||
export function verifyCsrf(ctx: Context): boolean {
|
||||
/** Verify an unsafe request's token, origin, and browser fetch metadata. */
|
||||
export function verifyCsrf(ctx: Context, options: CsrfProtectionOptions = {}): boolean {
|
||||
if (SAFE_METHODS.has(ctx.req.method.toUpperCase())) return true;
|
||||
|
||||
if (options.verifyFetchMetadata !== false) {
|
||||
const site = ctx.req.headers.get("sec-fetch-site");
|
||||
if (site === "cross-site") return false;
|
||||
}
|
||||
|
||||
if (options.verifyOrigin !== false) {
|
||||
const origin = ctx.req.headers.get("origin");
|
||||
if (origin) {
|
||||
const trusted = new Set([ctx.url.origin, ...(options.trustedOrigins ?? [])]);
|
||||
if (!trusted.has(origin)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
const cookie = ctx.cookies.get(CSRF_COOKIE);
|
||||
const sent = ctx.req.headers.get(CSRF_HEADER) ?? (ctx.locals._csrf as string | undefined);
|
||||
return !!cookie && !!sent && timingSafeEqual(cookie, sent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time string comparison — the running time does not depend on where
|
||||
* the first differing byte is, so an attacker can't time-probe the token.
|
||||
*/
|
||||
/** Constant-time string comparison. */
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
let diff = a.length ^ b.length;
|
||||
const max = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < max; i++) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
for (let i = 0; i < max; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** Middleware that 403s unsafe requests with a missing/mismatched CSRF token. */
|
||||
export function csrfProtection(): Middleware {
|
||||
/** Middleware that 403s unsafe requests with a missing/mismatched token. */
|
||||
export function csrfProtection(options: CsrfProtectionOptions = {}): Middleware {
|
||||
return (ctx, next) =>
|
||||
verifyCsrf(ctx) ? next() : new Response("Invalid CSRF token", { status: 403 });
|
||||
verifyCsrf(ctx, options) ? next() : new Response("Invalid CSRF token", { status: 403 });
|
||||
}
|
||||
|
||||
@@ -60,9 +60,23 @@ export interface TrustedTypesConfig {
|
||||
|
||||
export type PermissionsPolicyConfig = Record<string, string | string[] | false | null | undefined>;
|
||||
|
||||
export interface RequestLimitsConfig {
|
||||
maxUrlLength?: number;
|
||||
maxHeaderCount?: number;
|
||||
maxHeaderBytes?: number;
|
||||
maxQueryParameters?: number;
|
||||
maxBodyBytes?: number;
|
||||
timeoutMs?: number;
|
||||
maxConcurrent?: number;
|
||||
trustedHosts?: string[];
|
||||
fetchMetadata?: boolean;
|
||||
}
|
||||
|
||||
export interface SecurityConfig {
|
||||
/** Set false to skip all framework security headers except explicitly enabled CORS. */
|
||||
headers?: boolean;
|
||||
/** Built-in request size, timeout, concurrency, host, and Fetch Metadata limits. */
|
||||
requestLimits?: RequestLimitsConfig;
|
||||
/**
|
||||
* Trust `X-Forwarded-Proto` / `X-Forwarded-Host` when building `ctx.url` — set
|
||||
* this when the app runs behind a TLS-terminating reverse proxy (nginx, the
|
||||
@@ -81,6 +95,12 @@ export interface SecurityConfig {
|
||||
frameOptions?: false | "DENY" | "SAMEORIGIN";
|
||||
/** Defaults to "strict-origin-when-cross-origin". */
|
||||
referrerPolicy?: false | string;
|
||||
/** Defaults to "same-origin". */
|
||||
crossOriginResourcePolicy?: false | "same-origin" | "same-site" | "cross-origin";
|
||||
/** Isolate the origin in its own agent cluster. Defaults to true. */
|
||||
originAgentCluster?: boolean;
|
||||
/** Disable speculative DNS prefetching. Defaults to true. */
|
||||
disableDnsPrefetch?: boolean;
|
||||
/** Defaults to a restrictive browser capability policy. */
|
||||
permissionsPolicy?: false | PermissionsPolicyConfig;
|
||||
/** Extra static headers applied last. */
|
||||
@@ -238,6 +258,11 @@ function applyBaseSecurityHeaders(
|
||||
const referrerPolicy = security?.referrerPolicy ?? "strict-origin-when-cross-origin";
|
||||
if (referrerPolicy !== false) headers.set("Referrer-Policy", referrerPolicy);
|
||||
|
||||
const corp = security?.crossOriginResourcePolicy ?? "same-origin";
|
||||
if (corp !== false) headers.set("Cross-Origin-Resource-Policy", corp);
|
||||
if (security?.originAgentCluster !== false) headers.set("Origin-Agent-Cluster", "?1");
|
||||
if (security?.disableDnsPrefetch !== false) headers.set("X-DNS-Prefetch-Control", "off");
|
||||
|
||||
const configuredPermissions = security?.permissionsPolicy;
|
||||
const permissionsPolicy =
|
||||
configuredPermissions === false
|
||||
|
||||
@@ -14,6 +14,7 @@ export { createContext, withContextHeaders } from "./context.ts";
|
||||
|
||||
export { escapeHtml, isSafeIslandName, isSafeRequestPath } from "./security.ts";
|
||||
export { csrfToken, verifyCsrf, csrfProtection, CSRF_COOKIE, CSRF_HEADER } from "./csrf.ts";
|
||||
export type { CsrfProtectionOptions } from "./csrf.ts";
|
||||
|
||||
export {
|
||||
hashPassword,
|
||||
@@ -36,8 +37,23 @@ export type { RequestLoggerOptions, RequestRecord } from "./logging.ts";
|
||||
export { TTLCache, cacheControl, withCacheControl, etag, notModified } from "./cache.ts";
|
||||
export type { CacheControlOptions } from "./cache.ts";
|
||||
|
||||
export { saveUpload, collectUploads, sanitizeFilename, UploadError } from "./uploads.ts";
|
||||
export type { SaveUploadOptions, SavedUpload } from "./uploads.ts";
|
||||
export {
|
||||
saveUpload,
|
||||
saveUploadSecure,
|
||||
collectUploads,
|
||||
sanitizeFilename,
|
||||
randomUploadFilename,
|
||||
secureDownloadHeaders,
|
||||
UploadError,
|
||||
} from "./uploads.ts";
|
||||
export type {
|
||||
SaveUploadOptions,
|
||||
SecureUploadOptions,
|
||||
SavedUpload,
|
||||
UploadInspectionResult,
|
||||
UploadInspector,
|
||||
UploadScanner,
|
||||
} from "./uploads.ts";
|
||||
|
||||
export { streamResponse, sse } from "./stream.ts";
|
||||
export type { StreamResponseInit, ServerSentEvent } from "./stream.ts";
|
||||
@@ -63,6 +79,8 @@ export type {
|
||||
RealtimeConnectMeta,
|
||||
RealtimeBridge,
|
||||
RealtimeEnvelope,
|
||||
RealtimeSecurityOptions,
|
||||
RealtimeRegistryOptions,
|
||||
} from "./realtime.ts";
|
||||
|
||||
export type { Mode } from "./errors.ts";
|
||||
@@ -81,6 +99,7 @@ export type {
|
||||
CspDirectiveValue,
|
||||
HstsConfig,
|
||||
PermissionsPolicyConfig,
|
||||
RequestLimitsConfig,
|
||||
SecurityConfig,
|
||||
TrustedTypesConfig,
|
||||
} from "./headers.ts";
|
||||
@@ -99,8 +118,9 @@ export type {
|
||||
SessionBackend,
|
||||
SessionEntry,
|
||||
AsyncSessionBackend,
|
||||
SessionPolicy,
|
||||
} from "./storage.ts";
|
||||
export { setSessionBackend, loadSession } from "./storage.ts";
|
||||
export { setSessionBackend, setSessionPolicy, loadSession } from "./storage.ts";
|
||||
|
||||
export { Fragment, Html, jsx, jsxs, mustache } from "./jsx-runtime.ts";
|
||||
export type { Component as JSXComponent, Props as JSXProps, Renderable } from "./jsx-runtime.ts";
|
||||
@@ -132,7 +152,7 @@ export type { Span, SpanRecord, Tracer } from "./observability.ts";
|
||||
export { defineFeatureFlags } from "./features.ts";
|
||||
export type { FeatureFlags, FeatureRule, FeatureValue } from "./features.ts";
|
||||
|
||||
export { checkPerformanceBudgets } from "./performance.ts";
|
||||
export { checkPerformanceBudgets, recommendedWebBudgets } from "./performance.ts";
|
||||
export type { BudgetViolation, PerformanceBudgets, PerformanceMeasurement } from "./performance.ts";
|
||||
export {
|
||||
problem,
|
||||
|
||||
@@ -5,6 +5,20 @@ export interface PerformanceBudgets {
|
||||
imageBytes?: number;
|
||||
hydrationMs?: number;
|
||||
serverRenderMs?: number;
|
||||
/** Largest Contentful Paint in milliseconds. Recommended <= 2500. */
|
||||
lcpMs?: number;
|
||||
/** Interaction to Next Paint in milliseconds. Recommended <= 200. */
|
||||
inpMs?: number;
|
||||
/** Cumulative Layout Shift score. Recommended <= 0.1. */
|
||||
cls?: number;
|
||||
/** Time to First Byte in milliseconds. */
|
||||
ttfbMs?: number;
|
||||
/** Longest main-thread task in milliseconds. Recommended <= 50. */
|
||||
longTaskMs?: number;
|
||||
/** Number of client hydration boundaries on the route. */
|
||||
hydratedComponents?: number;
|
||||
/** Total request count for the initial navigation. */
|
||||
requests?: number;
|
||||
}
|
||||
|
||||
export interface PerformanceMeasurement {
|
||||
@@ -14,6 +28,13 @@ export interface PerformanceMeasurement {
|
||||
imageBytes?: number;
|
||||
hydrationMs?: number;
|
||||
serverRenderMs?: number;
|
||||
lcpMs?: number;
|
||||
inpMs?: number;
|
||||
cls?: number;
|
||||
ttfbMs?: number;
|
||||
longTaskMs?: number;
|
||||
hydratedComponents?: number;
|
||||
requests?: number;
|
||||
}
|
||||
|
||||
export interface BudgetViolation {
|
||||
@@ -23,6 +44,15 @@ export interface BudgetViolation {
|
||||
overBy: number;
|
||||
}
|
||||
|
||||
export const recommendedWebBudgets: Readonly<PerformanceBudgets> = Object.freeze({
|
||||
lcpMs: 2500,
|
||||
inpMs: 200,
|
||||
cls: 0.1,
|
||||
longTaskMs: 50,
|
||||
routeJsBytes: 50 * 1024,
|
||||
routeCssBytes: 25 * 1024,
|
||||
});
|
||||
|
||||
export function checkPerformanceBudgets(
|
||||
budgets: PerformanceBudgets,
|
||||
measurement: PerformanceMeasurement,
|
||||
|
||||
@@ -100,7 +100,28 @@ export interface RoomAuthInfo {
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
export interface RealtimeSecurityOptions {
|
||||
/** Maximum inbound or outbound serialized message size. Defaults to 64 KiB. */
|
||||
maxMessageBytes?: number;
|
||||
/** Maximum messages accepted per connection per rolling second. Defaults to 30. */
|
||||
maxMessagesPerSecond?: number;
|
||||
/** Maximum live connections in one room. Defaults to 1,000. */
|
||||
maxConnectionsPerRoom?: number;
|
||||
/** Maximum connections for one authenticated user in a room. Defaults to 10. */
|
||||
maxConnectionsPerUser?: number;
|
||||
/** Reject anonymous connections before onConnect. */
|
||||
requireUser?: boolean;
|
||||
/** Maximum nested JSON depth. Defaults to 32. */
|
||||
maxJsonDepth?: number;
|
||||
/** Optional message schema/authorization predicate. */
|
||||
validateMessage?(message: unknown, client: RoomClient): boolean | Promise<boolean>;
|
||||
/** Called when a connection is rejected or closed for a policy violation. */
|
||||
onViolation?(reason: string, client?: RoomClient): void;
|
||||
}
|
||||
|
||||
export interface RoomHandlers<TData = Record<string, unknown>> {
|
||||
/** Per-room abuse and payload controls. */
|
||||
security?: RealtimeSecurityOptions;
|
||||
/**
|
||||
* Gate the connection BEFORE it is accepted. Return false to reject the
|
||||
* upgrade with 403 (e.g. `authorize: (info) => !!info.user` to require auth).
|
||||
@@ -138,6 +159,8 @@ export function isRoomDefinition(value: unknown): value is RoomDefinition {
|
||||
|
||||
interface Conn {
|
||||
id: string;
|
||||
messageWindowStartedAt: number;
|
||||
messageCount: number;
|
||||
user?: string;
|
||||
data: Record<string, unknown>;
|
||||
query: Record<string, string>;
|
||||
@@ -179,6 +202,10 @@ export interface RealtimeBridge {
|
||||
publish(envelope: RealtimeEnvelope): void;
|
||||
}
|
||||
|
||||
export interface RealtimeRegistryOptions extends RealtimeSecurityOptions {
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface RealtimeRegistry {
|
||||
open(socket: RawSocket, meta: RealtimeConnectMeta): void | Promise<void>;
|
||||
message(socket: RawSocket, raw: string | Uint8Array): void | Promise<void>;
|
||||
@@ -191,16 +218,59 @@ export interface RealtimeRegistry {
|
||||
size(): number;
|
||||
}
|
||||
|
||||
const DANGEROUS_REALTIME_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
function assertRealtimePayload(value: unknown, maxDepth: number, depth = 0): void {
|
||||
if (depth > maxDepth) throw new Error("Realtime payload nesting limit exceeded.");
|
||||
if (!value || typeof value !== "object") return;
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (DANGEROUS_REALTIME_KEYS.has(key)) throw new Error(`Dangerous realtime key '${key}'.`);
|
||||
assertRealtimePayload(child, maxDepth, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function byteLength(value: string | Uint8Array): number {
|
||||
return typeof value === "string" ? new TextEncoder().encode(value).byteLength : value.byteLength;
|
||||
}
|
||||
|
||||
function serialize(message: unknown): string {
|
||||
return typeof message === "string" ? message : JSON.stringify(message);
|
||||
}
|
||||
|
||||
function mergedRealtimeSecurity(
|
||||
globalOptions: RealtimeRegistryOptions,
|
||||
room: RoomDefinition,
|
||||
): Required<
|
||||
Pick<
|
||||
RealtimeSecurityOptions,
|
||||
| "maxMessageBytes"
|
||||
| "maxMessagesPerSecond"
|
||||
| "maxConnectionsPerRoom"
|
||||
| "maxConnectionsPerUser"
|
||||
| "requireUser"
|
||||
| "maxJsonDepth"
|
||||
>
|
||||
> &
|
||||
RealtimeSecurityOptions {
|
||||
return {
|
||||
maxMessageBytes: 64 * 1024,
|
||||
maxMessagesPerSecond: 30,
|
||||
maxConnectionsPerRoom: 1_000,
|
||||
maxConnectionsPerUser: 10,
|
||||
requireUser: false,
|
||||
maxJsonDepth: 32,
|
||||
...globalOptions,
|
||||
...room.handlers.security,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the registry that maps sockets ↔ rooms and drives room handlers. */
|
||||
export function createRealtimeRegistry(): RealtimeRegistry {
|
||||
export function createRealtimeRegistry(options: RealtimeRegistryOptions = {}): RealtimeRegistry {
|
||||
const rooms = new Map<string, RoomImpl>();
|
||||
const bySocket = new Map<RawSocket, Conn>();
|
||||
let bridge: RealtimeBridge | null = null;
|
||||
let applyingRemote = false; // true while delivering a peer envelope (no re-publish)
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
const publish = (envelope: RealtimeEnvelope): void => {
|
||||
if (bridge && !applyingRemote) bridge.publish(envelope);
|
||||
@@ -208,6 +278,17 @@ export function createRealtimeRegistry(): RealtimeRegistry {
|
||||
|
||||
const send = (conn: Conn | undefined, payload: string): void => {
|
||||
if (!conn) return;
|
||||
const room = rooms.get(conn.roomName);
|
||||
const policy = room
|
||||
? mergedRealtimeSecurity(options, room.def)
|
||||
: ({ maxMessageBytes: options.maxMessageBytes ?? 64 * 1024 } as ReturnType<
|
||||
typeof mergedRealtimeSecurity
|
||||
>);
|
||||
if (byteLength(payload) > policy.maxMessageBytes) {
|
||||
policy.onViolation?.("outbound-message-too-large", conn.client);
|
||||
conn.socket.close(1009, "Message too large");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
conn.socket.send(payload);
|
||||
} catch {
|
||||
@@ -295,13 +376,31 @@ export function createRealtimeRegistry(): RealtimeRegistry {
|
||||
|
||||
return {
|
||||
async open(socket, meta) {
|
||||
const policy = mergedRealtimeSecurity(options, meta.def);
|
||||
if (policy.requireUser && !meta.user) {
|
||||
policy.onViolation?.("authentication-required");
|
||||
socket.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
let room = rooms.get(meta.room);
|
||||
if (!room) {
|
||||
room = { name: meta.room, state: {}, def: meta.def, conns: new Map(), users: new Map() };
|
||||
rooms.set(meta.room, room);
|
||||
}
|
||||
if (room.conns.size >= policy.maxConnectionsPerRoom) {
|
||||
policy.onViolation?.("room-connection-limit");
|
||||
socket.close(1013, "Room is at capacity");
|
||||
return;
|
||||
}
|
||||
if (meta.user && (room.users.get(meta.user)?.size ?? 0) >= policy.maxConnectionsPerUser) {
|
||||
policy.onViolation?.("user-connection-limit");
|
||||
socket.close(1008, "Too many connections");
|
||||
return;
|
||||
}
|
||||
const conn: Conn = {
|
||||
id: randomId(),
|
||||
messageWindowStartedAt: now(),
|
||||
messageCount: 0,
|
||||
data: {},
|
||||
query: meta.query ?? {},
|
||||
socket,
|
||||
@@ -320,6 +419,23 @@ export function createRealtimeRegistry(): RealtimeRegistry {
|
||||
if (!conn) return;
|
||||
const room = rooms.get(conn.roomName);
|
||||
if (!room) return;
|
||||
const policy = mergedRealtimeSecurity(options, room.def);
|
||||
if (byteLength(raw) > policy.maxMessageBytes) {
|
||||
policy.onViolation?.("inbound-message-too-large", conn.client);
|
||||
socket.close(1009, "Message too large");
|
||||
return;
|
||||
}
|
||||
const timestamp = now();
|
||||
if (timestamp - conn.messageWindowStartedAt >= 1_000) {
|
||||
conn.messageWindowStartedAt = timestamp;
|
||||
conn.messageCount = 0;
|
||||
}
|
||||
conn.messageCount += 1;
|
||||
if (conn.messageCount > policy.maxMessagesPerSecond) {
|
||||
policy.onViolation?.("message-rate-limit", conn.client);
|
||||
socket.close(1008, "Message rate exceeded");
|
||||
return;
|
||||
}
|
||||
const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
||||
let message: unknown;
|
||||
try {
|
||||
@@ -327,6 +443,18 @@ export function createRealtimeRegistry(): RealtimeRegistry {
|
||||
} catch {
|
||||
message = text;
|
||||
}
|
||||
try {
|
||||
assertRealtimePayload(message, policy.maxJsonDepth);
|
||||
} catch {
|
||||
policy.onViolation?.("invalid-message-shape", conn.client);
|
||||
socket.close(1008, "Invalid message");
|
||||
return;
|
||||
}
|
||||
if (policy.validateMessage && !(await policy.validateMessage(message, conn.client))) {
|
||||
policy.onViolation?.("message-validation-failed", conn.client);
|
||||
socket.close(1008, "Message rejected");
|
||||
return;
|
||||
}
|
||||
await room.def.handlers.onMessage?.(conn.client, message);
|
||||
},
|
||||
|
||||
|
||||
+107
-21
@@ -37,15 +37,47 @@ export interface LocalStorageSnapshot {
|
||||
}
|
||||
|
||||
const SESSION_COOKIE = "wrnexus.sid";
|
||||
/** Idle timeout: a session expires this long after its last access. */
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
||||
/** Run a background sweep after this many new sessions (bounds memory). */
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24;
|
||||
const SESSION_ABSOLUTE_TTL_MS = 1000 * 60 * 60 * 24 * 7;
|
||||
const SESSION_GC_EVERY = 500;
|
||||
|
||||
export interface SessionPolicy {
|
||||
cookieName?: string;
|
||||
idleTimeoutMs?: number;
|
||||
absoluteTimeoutMs?: number;
|
||||
sameSite?: NonNullable<CookieOptions["sameSite"]>;
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
let sessionPolicy: Required<
|
||||
Pick<SessionPolicy, "cookieName" | "idleTimeoutMs" | "absoluteTimeoutMs" | "sameSite">
|
||||
> &
|
||||
Pick<SessionPolicy, "secure"> = {
|
||||
cookieName: SESSION_COOKIE,
|
||||
idleTimeoutMs: SESSION_TTL_MS,
|
||||
absoluteTimeoutMs: SESSION_ABSOLUTE_TTL_MS,
|
||||
sameSite: "Lax",
|
||||
};
|
||||
|
||||
export function setSessionPolicy(policy: SessionPolicy): void {
|
||||
sessionPolicy = {
|
||||
...sessionPolicy,
|
||||
...policy,
|
||||
idleTimeoutMs: Math.max(60_000, policy.idleTimeoutMs ?? sessionPolicy.idleTimeoutMs),
|
||||
absoluteTimeoutMs: Math.max(
|
||||
policy.idleTimeoutMs ?? sessionPolicy.idleTimeoutMs,
|
||||
policy.absoluteTimeoutMs ?? sessionPolicy.absoluteTimeoutMs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** A stored session: its data plus an absolute expiry timestamp (ms). */
|
||||
export interface SessionEntry {
|
||||
data: Record<string, unknown>;
|
||||
expiresAt: number;
|
||||
/** Creation time used for the absolute session lifetime. Optional for old backends. */
|
||||
createdAt?: number;
|
||||
lastAccessAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +102,13 @@ function createMemorySessionBackend(): SessionBackend {
|
||||
set: (id, entry) => void map.set(id, entry),
|
||||
delete: (id) => void map.delete(id),
|
||||
gc: (now) => {
|
||||
for (const [key, entry] of map) if (entry.expiresAt <= now) map.delete(key);
|
||||
for (const [key, entry] of map) {
|
||||
if (
|
||||
entry.expiresAt <= now ||
|
||||
(entry.createdAt ?? now) + sessionPolicy.absoluteTimeoutMs <= now
|
||||
)
|
||||
map.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -102,13 +140,26 @@ export interface AsyncSessionBackend {
|
||||
*/
|
||||
export function loadSession(
|
||||
backend: AsyncSessionBackend,
|
||||
options: { ttlMs?: number } = {},
|
||||
options: {
|
||||
ttlMs?: number;
|
||||
absoluteTtlMs?: number;
|
||||
cookieName?: string;
|
||||
sameSite?: NonNullable<CookieOptions["sameSite"]>;
|
||||
secure?: boolean;
|
||||
} = {},
|
||||
): Middleware {
|
||||
const ttlMs = options.ttlMs ?? SESSION_TTL_MS;
|
||||
const ttlMs = options.ttlMs ?? sessionPolicy.idleTimeoutMs;
|
||||
const absoluteTtlMs = options.absoluteTtlMs ?? sessionPolicy.absoluteTimeoutMs;
|
||||
const cookieName = options.cookieName ?? sessionPolicy.cookieName;
|
||||
return async (ctx: Context, next) => {
|
||||
let id = ctx.cookies.get(SESSION_COOKIE);
|
||||
let id = ctx.cookies.get(cookieName);
|
||||
let entry = id ? await backend.load(id) : undefined;
|
||||
if (id && entry && entry.expiresAt <= Date.now()) {
|
||||
if (
|
||||
id &&
|
||||
entry &&
|
||||
(entry.expiresAt <= Date.now() ||
|
||||
(entry.createdAt ?? Date.now()) + absoluteTtlMs <= Date.now())
|
||||
) {
|
||||
await backend.destroy(id);
|
||||
entry = undefined;
|
||||
id = undefined;
|
||||
@@ -120,9 +171,16 @@ export function loadSession(
|
||||
const ensure = (): Record<string, unknown> => {
|
||||
if (!id) {
|
||||
id = randomId();
|
||||
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
ctx.cookies.set(
|
||||
cookieName,
|
||||
id,
|
||||
sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
|
||||
);
|
||||
}
|
||||
if (!entry) {
|
||||
const now = Date.now();
|
||||
entry = { data: {}, expiresAt: now + ttlMs, createdAt: now, lastAccessAt: now };
|
||||
}
|
||||
if (!entry) entry = { data: {}, expiresAt: Date.now() + ttlMs };
|
||||
return entry.data;
|
||||
};
|
||||
|
||||
@@ -147,14 +205,22 @@ export function loadSession(
|
||||
const data = entry?.data ?? {};
|
||||
if (id) destroys.add(id);
|
||||
id = randomId();
|
||||
entry = { data, expiresAt: Date.now() + ttlMs };
|
||||
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
const now = Date.now();
|
||||
entry = { data, expiresAt: now + ttlMs, createdAt: now, lastAccessAt: now };
|
||||
ctx.cookies.set(
|
||||
cookieName,
|
||||
id,
|
||||
sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
|
||||
);
|
||||
},
|
||||
clear() {
|
||||
if (id) destroys.add(id);
|
||||
entry = undefined;
|
||||
id = undefined;
|
||||
ctx.cookies.delete(SESSION_COOKIE, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
ctx.cookies.delete(
|
||||
cookieName,
|
||||
sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -164,6 +230,7 @@ export function loadSession(
|
||||
for (const gone of destroys) if (gone !== id) await backend.destroy(gone);
|
||||
if (id && entry) {
|
||||
entry.expiresAt = Date.now() + ttlMs;
|
||||
entry.lastAccessAt = Date.now();
|
||||
await backend.save(id, entry);
|
||||
}
|
||||
}
|
||||
@@ -176,11 +243,14 @@ const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
function readSessionEntry(id: string): SessionEntry | undefined {
|
||||
const entry = sessionBackend.get(id);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
const now = Date.now();
|
||||
if (entry.expiresAt <= now || (entry.createdAt ?? now) + sessionPolicy.absoluteTimeoutMs <= now) {
|
||||
sessionBackend.delete(id);
|
||||
return undefined;
|
||||
}
|
||||
entry.expiresAt = Date.now() + SESSION_TTL_MS; // sliding idle expiry
|
||||
entry.lastAccessAt = now;
|
||||
entry.createdAt ??= now;
|
||||
entry.expiresAt = now + sessionPolicy.idleTimeoutMs; // sliding idle expiry
|
||||
sessionBackend.set(id, entry); // persist the slide (matters for external backends)
|
||||
return entry;
|
||||
}
|
||||
@@ -223,8 +293,8 @@ export function createCookieStore(req: Request): CookieStore {
|
||||
export function createSessionStore(
|
||||
cookies: CookieStore,
|
||||
req: Request,
|
||||
cookieName = SESSION_COOKIE,
|
||||
secure = new URL(req.url).protocol === "https:",
|
||||
cookieName = sessionPolicy.cookieName,
|
||||
secure = sessionPolicy.secure ?? new URL(req.url).protocol === "https:",
|
||||
): SessionStore {
|
||||
let id = cookies.get(cookieName);
|
||||
let entry = id ? readSessionEntry(id) : undefined;
|
||||
@@ -245,7 +315,13 @@ export function createSessionStore(
|
||||
sessionsSinceGc = 0;
|
||||
sessionBackend.gc?.(Date.now());
|
||||
}
|
||||
entry = { data: {}, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
const now = Date.now();
|
||||
entry = {
|
||||
data: {},
|
||||
expiresAt: now + sessionPolicy.idleTimeoutMs,
|
||||
createdAt: now,
|
||||
lastAccessAt: now,
|
||||
};
|
||||
sessionBackend.set(id, entry);
|
||||
}
|
||||
return entry.data;
|
||||
@@ -278,7 +354,13 @@ export function createSessionStore(
|
||||
const data = entry?.data ?? {};
|
||||
if (id) sessionBackend.delete(id);
|
||||
id = randomId();
|
||||
entry = { data, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
const now = Date.now();
|
||||
entry = {
|
||||
data,
|
||||
expiresAt: now + sessionPolicy.idleTimeoutMs,
|
||||
createdAt: now,
|
||||
lastAccessAt: now,
|
||||
};
|
||||
sessionBackend.set(id, entry);
|
||||
cookies.set(cookieName, id, sessionCookieOptions(secure));
|
||||
},
|
||||
@@ -341,11 +423,14 @@ function serializeCookie(name: string, value: string, options: CookieOptions): s
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function sessionCookieOptions(secure: boolean): CookieOptions {
|
||||
function sessionCookieOptions(
|
||||
secure: boolean,
|
||||
sameSite: NonNullable<CookieOptions["sameSite"]> = sessionPolicy.sameSite,
|
||||
): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "Lax",
|
||||
sameSite,
|
||||
secure,
|
||||
};
|
||||
}
|
||||
@@ -358,6 +443,7 @@ function parseLocalStorageHeader(header: string | null): Record<string, string>
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (key === "__proto__" || key === "prototype" || key === "constructor") continue;
|
||||
if (typeof value === "string") out[key] = value;
|
||||
}
|
||||
return out;
|
||||
|
||||
+160
-22
@@ -1,19 +1,41 @@
|
||||
/**
|
||||
* File upload helpers. Bun parses `multipart/form-data` natively via
|
||||
* `Request.formData()`, yielding web `File` objects; these helpers validate and
|
||||
* persist them safely (size/type limits, filename sanitisation to prevent path
|
||||
* traversal).
|
||||
* File upload helpers. The legacy `saveUpload` keeps the original sanitized
|
||||
* filename for compatibility. New applications should use `saveUploadSecure`,
|
||||
* which stores a random name and supports content inspection/scanning hooks.
|
||||
*/
|
||||
|
||||
export class UploadError extends Error {
|
||||
constructor(message: string) {
|
||||
readonly code: string;
|
||||
constructor(message: string, code = "WRN-UPLOAD-REJECTED") {
|
||||
super(message);
|
||||
this.name = "UploadError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UploadInspectionResult {
|
||||
allowed: boolean;
|
||||
detectedType?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type UploadInspector = (input: {
|
||||
file: File;
|
||||
bytes: Uint8Array;
|
||||
filename: string;
|
||||
}) => UploadInspectionResult | Promise<UploadInspectionResult>;
|
||||
|
||||
export type UploadScanner = (input: {
|
||||
file: File;
|
||||
bytes: Uint8Array;
|
||||
filename: string;
|
||||
}) =>
|
||||
| boolean
|
||||
| { clean: boolean; reason?: string }
|
||||
| Promise<boolean | { clean: boolean; reason?: string }>;
|
||||
|
||||
export interface SaveUploadOptions {
|
||||
/** Destination directory. */
|
||||
/** Destination directory. Keep this outside the public web root. */
|
||||
dir: string;
|
||||
/** Reject files larger than this many bytes. */
|
||||
maxBytes?: number;
|
||||
@@ -21,6 +43,21 @@ export interface SaveUploadOptions {
|
||||
allowedTypes?: string[];
|
||||
/** Choose the stored filename. Default: the sanitised original name. */
|
||||
filename?: (file: File) => string;
|
||||
/** Content/magic-byte inspection hook. */
|
||||
inspect?: UploadInspector;
|
||||
/** Malware scanning hook. */
|
||||
scan?: UploadScanner;
|
||||
/** Called after validation but before persistence. */
|
||||
beforeSave?: (input: { file: File; bytes: Uint8Array; filename: string }) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface SecureUploadOptions extends Omit<SaveUploadOptions, "filename"> {
|
||||
/** Preserve the original sanitized name instead of a random server name. */
|
||||
preserveOriginalName?: boolean;
|
||||
/** Optional custom secure filename generator. */
|
||||
filename?: (file: File) => string;
|
||||
/** Preserve a conservative extension on random filenames. Defaults to true. */
|
||||
preserveExtension?: boolean;
|
||||
}
|
||||
|
||||
export interface SavedUpload {
|
||||
@@ -28,51 +65,152 @@ export interface SavedUpload {
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
detectedType?: string;
|
||||
}
|
||||
|
||||
/** All `File` values in a parsed form, with their field names. */
|
||||
export function collectUploads(form: FormData): { field: string; file: File }[] {
|
||||
export function collectUploads(
|
||||
form: FormData,
|
||||
options: { maxFiles?: number; maxTotalBytes?: number } = {},
|
||||
): { field: string; file: File }[] {
|
||||
const out: { field: string; file: File }[] = [];
|
||||
let totalBytes = 0;
|
||||
for (const [field, value] of form) {
|
||||
if (value instanceof File && value.size > 0) out.push({ field, file: value });
|
||||
if (!(value instanceof File) || value.size <= 0) continue;
|
||||
out.push({ field, file: value });
|
||||
totalBytes += value.size;
|
||||
if (options.maxFiles !== undefined && out.length > options.maxFiles) {
|
||||
throw new UploadError(
|
||||
`Upload contains more than ${options.maxFiles} files.`,
|
||||
"WRN-UPLOAD-FILE-COUNT",
|
||||
);
|
||||
}
|
||||
if (options.maxTotalBytes !== undefined && totalBytes > options.maxTotalBytes) {
|
||||
throw new UploadError(
|
||||
`Upload exceeds the ${options.maxTotalBytes}-byte aggregate limit.`,
|
||||
"WRN-UPLOAD-TOTAL-SIZE",
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Validate and write one uploaded file to disk. Throws `UploadError` on reject. */
|
||||
/** Validate and write one uploaded file using a compatibility filename policy. */
|
||||
export async function saveUpload(file: File, options: SaveUploadOptions): Promise<SavedUpload> {
|
||||
return persistUpload(
|
||||
file,
|
||||
options,
|
||||
sanitizeFilename(options.filename ? options.filename(file) : file.name || "upload"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Store an upload under a random server-generated name by default. */
|
||||
export async function saveUploadSecure(
|
||||
file: File,
|
||||
options: SecureUploadOptions,
|
||||
): Promise<SavedUpload> {
|
||||
const requested = options.filename?.(file);
|
||||
const filename = requested
|
||||
? sanitizeFilename(requested)
|
||||
: options.preserveOriginalName
|
||||
? sanitizeFilename(file.name || "upload")
|
||||
: randomUploadFilename(file.name, options.preserveExtension !== false);
|
||||
return persistUpload(file, options, filename);
|
||||
}
|
||||
|
||||
async function persistUpload(
|
||||
file: File,
|
||||
options: SaveUploadOptions,
|
||||
filename: string,
|
||||
): Promise<SavedUpload> {
|
||||
if (options.maxBytes !== undefined && file.size > options.maxBytes) {
|
||||
throw new UploadError(`File "${file.name}" exceeds the ${options.maxBytes}-byte limit`);
|
||||
throw new UploadError(
|
||||
`File "${file.name}" exceeds the ${options.maxBytes}-byte limit`,
|
||||
"WRN-UPLOAD-SIZE",
|
||||
);
|
||||
}
|
||||
if (options.allowedTypes && !isAllowed(file, options.allowedTypes)) {
|
||||
throw new UploadError(`File type not allowed: ${file.type || file.name || "unknown"}`);
|
||||
throw new UploadError(
|
||||
`File type not allowed: ${file.type || file.name || "unknown"}`,
|
||||
"WRN-UPLOAD-TYPE",
|
||||
);
|
||||
}
|
||||
|
||||
const filename = sanitizeFilename(
|
||||
options.filename ? options.filename(file) : file.name || "upload",
|
||||
);
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let detectedType: string | undefined;
|
||||
if (options.inspect) {
|
||||
const result = await options.inspect({ file, bytes, filename });
|
||||
if (!result.allowed) {
|
||||
throw new UploadError(result.reason ?? "File content is not allowed.", "WRN-UPLOAD-CONTENT");
|
||||
}
|
||||
detectedType = result.detectedType;
|
||||
}
|
||||
if (options.scan) {
|
||||
const result = await options.scan({ file, bytes, filename });
|
||||
const clean = typeof result === "boolean" ? result : result.clean;
|
||||
if (!clean) {
|
||||
throw new UploadError(
|
||||
typeof result === "boolean"
|
||||
? "File failed malware scanning."
|
||||
: (result.reason ?? "File failed malware scanning."),
|
||||
"WRN-UPLOAD-MALWARE",
|
||||
);
|
||||
}
|
||||
}
|
||||
await options.beforeSave?.({ file, bytes, filename });
|
||||
|
||||
const path = `${options.dir.replace(/[/\\]+$/, "")}/${filename}`;
|
||||
await Bun.write(path, file);
|
||||
return { path, filename, size: file.size, type: file.type };
|
||||
await Bun.write(path, bytes);
|
||||
return {
|
||||
path,
|
||||
filename,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
...(detectedType ? { detectedType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowed(file: File, allowed: string[]): boolean {
|
||||
const type = (file.type || "").toLowerCase();
|
||||
const name = (file.name || "").toLowerCase();
|
||||
return allowed.some((entry) => {
|
||||
const e = entry.toLowerCase();
|
||||
return e.startsWith(".") ? name.endsWith(e) : type === e;
|
||||
const candidate = entry.toLowerCase();
|
||||
if (candidate.startsWith(".")) return name.endsWith(candidate);
|
||||
if (candidate.endsWith("/*")) return type.startsWith(candidate.slice(0, -1));
|
||||
return type === candidate;
|
||||
});
|
||||
}
|
||||
|
||||
/** Strip directory separators, traversal, and control chars from a filename. */
|
||||
export function sanitizeFilename(name: string): string {
|
||||
const base = name
|
||||
.replace(/[/\\]+/g, "_") // path separators
|
||||
.replace(/\.\.+/g, ".") // collapse traversal dots
|
||||
.replace(/[/\\]+/g, "_")
|
||||
.replace(/\.\.+/g, ".")
|
||||
// eslint-disable-next-line no-control-regex -- intentionally stripping control chars
|
||||
.replace(/[\x00-\x1f<>:"|?*]/g, "") // control + illegal chars
|
||||
.replace(/^\.+/, "") // no leading dots
|
||||
.replace(/[\x00-\x1f<>:"|?*]/g, "")
|
||||
.replace(/^\.+/, "")
|
||||
.trim();
|
||||
return base.length > 0 ? base.slice(0, 255) : "upload";
|
||||
}
|
||||
|
||||
export function randomUploadFilename(originalName = "", preserveExtension = true): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const id = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
if (!preserveExtension) return id;
|
||||
const match = /(?:^|\.)([A-Za-z0-9]{1,10})$/.exec(originalName);
|
||||
return match ? `${id}.${match[1]!.toLowerCase()}` : id;
|
||||
}
|
||||
|
||||
export function secureDownloadHeaders(
|
||||
filename: string,
|
||||
type = "application/octet-stream",
|
||||
): Headers {
|
||||
const safe = sanitizeFilename(filename).replace(/["\\]/g, "_");
|
||||
return new Headers({
|
||||
"content-type": type,
|
||||
"content-disposition": `attachment; filename="${safe}"`,
|
||||
"x-content-type-options": "nosniff",
|
||||
"cache-control": "private, no-store",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,3 +159,46 @@ test("room.state and count() track the live room", async () => {
|
||||
expect(a.sent[0]).toMatchObject({ online: 1, hits: 1 });
|
||||
expect(b.sent[0]).toMatchObject({ online: 2, hits: 2 });
|
||||
});
|
||||
|
||||
test("realtime security enforces authentication, message size, rate, and safe shapes", async () => {
|
||||
const violations: string[] = [];
|
||||
const def = defineRoom({
|
||||
security: {
|
||||
requireUser: true,
|
||||
maxMessageBytes: 64,
|
||||
maxMessagesPerSecond: 1,
|
||||
onViolation(reason) {
|
||||
violations.push(reason);
|
||||
},
|
||||
},
|
||||
});
|
||||
const now = { value: 0 };
|
||||
const reg = createRealtimeRegistry({ now: () => now.value });
|
||||
const anonymousClosures: Array<[number | undefined, string | undefined]> = [];
|
||||
const anonymous: RawSocket = {
|
||||
send() {},
|
||||
close(code, reason) {
|
||||
anonymousClosures.push([code, reason]);
|
||||
},
|
||||
};
|
||||
await reg.open(anonymous, { room: "/secure", def });
|
||||
expect(anonymousClosures[0]).toEqual([1008, "Authentication required"]);
|
||||
|
||||
const closures: Array<[number | undefined, string | undefined]> = [];
|
||||
const socket: RawSocket = {
|
||||
send() {},
|
||||
close(code, reason) {
|
||||
closures.push([code, reason]);
|
||||
},
|
||||
};
|
||||
await reg.open(socket, { room: "/secure", def, user: "u1" });
|
||||
await reg.message(socket, JSON.stringify({ ok: true }));
|
||||
await reg.message(socket, JSON.stringify({ ok: true }));
|
||||
expect(closures.at(-1)).toEqual([1008, "Message rate exceeded"]);
|
||||
expect(violations).toContain("message-rate-limit");
|
||||
|
||||
now.value = 2_000;
|
||||
const unsafe = '{"constructor":{"prototype":{"admin":true}}}';
|
||||
await reg.message(socket, unsafe);
|
||||
expect(violations).toContain("invalid-message-shape");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -352,8 +352,22 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
},
|
||||
};
|
||||
}
|
||||
function sanitizeReactiveUrl(value) {
|
||||
var raw = String(value == null ? "" : value);
|
||||
var compact = raw.trim().replace(/[\u0000-\u0020]+/g, "").toLowerCase();
|
||||
if (/^(?:javascript|vbscript|file):/.test(compact)) return "about:blank";
|
||||
if (/^data:(?!image\/(?:png|gif|jpeg|webp|avif);)/.test(compact)) return "about:blank";
|
||||
return raw;
|
||||
}
|
||||
|
||||
function applyReactiveAttribute(node, name, value) {
|
||||
var lowerName = String(name || "").toLowerCase();
|
||||
if (
|
||||
value != null &&
|
||||
["href", "src", "action", "formaction", "poster", "cite", "background", "xlink:href"].indexOf(lowerName) !== -1
|
||||
) {
|
||||
value = sanitizeReactiveUrl(value);
|
||||
}
|
||||
|
||||
if (
|
||||
lowerName === "value" &&
|
||||
@@ -2030,16 +2044,29 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
|
||||
function hydrate() {
|
||||
if (element.__wrnexusScope || !element.isConnected) return;
|
||||
var started = performance.now ? performance.now() : Date.now();
|
||||
function complete(module) {
|
||||
if (element.__wrnexusScope || !element.isConnected) return;
|
||||
if (module) element.__wrnexusClientModule = module;
|
||||
setupScope(element);
|
||||
var ended = performance.now ? performance.now() : Date.now();
|
||||
try {
|
||||
element.dispatchEvent(new CustomEvent("wrnexus:hydrated", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
id: element.getAttribute("data-wrn-hydration") || null,
|
||||
strategy: strategy,
|
||||
durationMs: Math.max(0, ended - started),
|
||||
},
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
var moduleUrl = element.getAttribute("data-wrn-client-module");
|
||||
if (!moduleUrl || moduleUrl === "__WRNEXUS_CLIENT_MODULE__") {
|
||||
setupScope(element);
|
||||
complete(null);
|
||||
return;
|
||||
}
|
||||
loadClientModule(element).then(function (module) {
|
||||
if (element.__wrnexusScope || !element.isConnected) return;
|
||||
element.__wrnexusClientModule = module;
|
||||
setupScope(element);
|
||||
});
|
||||
loadClientModule(element).then(complete);
|
||||
}
|
||||
|
||||
if (strategy === "load") {
|
||||
@@ -2744,7 +2771,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-wrnexus-csrf": csrf ? csrf.getAttribute("content") || "" : "",
|
||||
"x-csrf-token": csrf ? csrf.getAttribute("content") || "" : "",
|
||||
},
|
||||
body: JSON.stringify({ component: component, function: functionName, args: args || [] }),
|
||||
}).then(function (response) {
|
||||
|
||||
@@ -17,7 +17,7 @@ export class WrnServerCallError extends Error {
|
||||
|
||||
function csrfFromCookie(): string | undefined {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
const raw = /(?:^|;\s*)wrnexus_csrf=([^;]+)/.exec(document.cookie)?.[1];
|
||||
const raw = /(?:^|;\s*)wire-csrf=([^;]+)/.exec(document.cookie)?.[1];
|
||||
return raw ? decodeURIComponent(raw) : undefined;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function callServerFunction<TInput extends unknown[], TOutput>(
|
||||
signal: options.signal,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(csrfToken ? { "x-wrnexus-csrf": csrfToken } : {}),
|
||||
...(csrfToken ? { "x-csrf-token": csrfToken } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify({ component, function: functionName, args }),
|
||||
|
||||
@@ -73,11 +73,15 @@ test("ignores cross-origin links (full navigation)", async () => {
|
||||
|
||||
test("ignores modified clicks so new-tab still works", async () => {
|
||||
install(`<div id="app"><a href="/about" id="lnk">x</a></div>`);
|
||||
win.document
|
||||
.getElementById("lnk")
|
||||
.dispatchEvent(
|
||||
new win.MouseEvent("click", { bubbles: true, cancelable: true, button: 0, metaKey: true }),
|
||||
);
|
||||
const MouseEventConstructor = (win as unknown as { MouseEvent: typeof MouseEvent }).MouseEvent;
|
||||
win.document.getElementById("lnk").dispatchEvent(
|
||||
new MouseEventConstructor("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
metaKey: true,
|
||||
}),
|
||||
);
|
||||
await flush();
|
||||
expect(fetchCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
@@ -73,7 +73,10 @@ test("component functions support formatted multiline assignments and ternaries"
|
||||
increment.click();
|
||||
expect(increment.textContent).toBe("1");
|
||||
|
||||
increment.dispatchEvent(new win.MouseEvent("click", { shiftKey: true }) as unknown as Event);
|
||||
const MouseEventConstructor = (win as unknown as { MouseEvent: typeof MouseEvent }).MouseEvent;
|
||||
increment.dispatchEvent(
|
||||
new MouseEventConstructor("click", { shiftKey: true }) as unknown as Event,
|
||||
);
|
||||
expect(increment.textContent).toBe("11");
|
||||
|
||||
(win.document.getElementById("normalize") as unknown as HTMLElement).click();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -38,3 +38,6 @@ export { paginate, loadRelated } from "./query.ts";
|
||||
export type { Paginated, PageOptions, RelationOptions } from "./query.ts";
|
||||
export { cursorPaginate, optimisticUpdate, tenantScope, softDeleteClause } from "./advanced.ts";
|
||||
export type { CursorPage, CursorPageOptions } from "./advanced.ts";
|
||||
|
||||
export { instrumentDb, queryOperation } from "./performance.ts";
|
||||
export type { QueryIssue, QueryPolicy, QueryRecord } from "./performance.ts";
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { Db, ExecResult, Row } from "./driver.ts";
|
||||
import type { Model } from "./schema.ts";
|
||||
|
||||
export interface QueryRecord {
|
||||
sql: string;
|
||||
paramsCount: number;
|
||||
durationMs: number;
|
||||
rowCount?: number;
|
||||
operation: "all" | "one" | "exec";
|
||||
duplicateCount: number;
|
||||
}
|
||||
|
||||
export interface QueryIssue {
|
||||
code: string;
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export interface QueryPolicy {
|
||||
slowQueryMs?: number;
|
||||
timeoutMs?: number;
|
||||
maxRows?: number;
|
||||
duplicateWarningCount?: number;
|
||||
warnSelectStar?: boolean;
|
||||
warnUnboundedSelect?: boolean;
|
||||
onQuery?: (record: QueryRecord) => void | Promise<void>;
|
||||
onIssue?: (issue: QueryIssue) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function normalizedSql(sql: string): string {
|
||||
return sql
|
||||
.replace(/--.*$/gm, " ")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function operationName(sql: string): string {
|
||||
return normalizedSql(sql).split(" ")[0]?.toUpperCase() ?? "UNKNOWN";
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, sql: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(`WRN-DB-TIMEOUT: query exceeded ${timeoutMs} ms: ${sql.slice(0, 120)}`),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export function instrumentDb(db: Db, policy: QueryPolicy = {}): Db {
|
||||
const counts = new Map<string, number>();
|
||||
const slowQueryMs = policy.slowQueryMs ?? 100;
|
||||
const timeoutMs = policy.timeoutMs ?? 30_000;
|
||||
const maxRows = policy.maxRows ?? 10_000;
|
||||
const duplicateWarningCount = policy.duplicateWarningCount ?? 5;
|
||||
|
||||
const inspect = async (sql: string): Promise<void> => {
|
||||
const normalized = normalizedSql(sql);
|
||||
if (policy.warnSelectStar !== false && /^SELECT\s+\*/i.test(normalized)) {
|
||||
await policy.onIssue?.({
|
||||
code: "WRN-DB-SELECT-STAR",
|
||||
severity: "warning",
|
||||
message: "Avoid SELECT * in production queries.",
|
||||
sql,
|
||||
});
|
||||
}
|
||||
if (
|
||||
policy.warnUnboundedSelect !== false &&
|
||||
/^SELECT\b/i.test(normalized) &&
|
||||
!/\bLIMIT\b/i.test(normalized) &&
|
||||
!/\bCOUNT\s*\(/i.test(normalized)
|
||||
) {
|
||||
await policy.onIssue?.({
|
||||
code: "WRN-DB-UNBOUNDED-SELECT",
|
||||
severity: "warning",
|
||||
message: "SELECT query has no LIMIT. Prefer cursor pagination for large datasets.",
|
||||
sql,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const record = async (
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
operation: QueryRecord["operation"],
|
||||
durationMs: number,
|
||||
rowCount?: number,
|
||||
): Promise<void> => {
|
||||
const key = `${operation}:${normalizedSql(sql)}:${JSON.stringify(params)}`;
|
||||
const duplicateCount = (counts.get(key) ?? 0) + 1;
|
||||
counts.set(key, duplicateCount);
|
||||
const queryRecord: QueryRecord = {
|
||||
sql,
|
||||
paramsCount: params.length,
|
||||
durationMs,
|
||||
...(rowCount === undefined ? {} : { rowCount }),
|
||||
operation,
|
||||
duplicateCount,
|
||||
};
|
||||
await policy.onQuery?.(queryRecord);
|
||||
if (durationMs >= slowQueryMs) {
|
||||
await policy.onIssue?.({
|
||||
code: "WRN-DB-SLOW-QUERY",
|
||||
severity: durationMs >= slowQueryMs * 5 ? "error" : "warning",
|
||||
message: `Query took ${durationMs.toFixed(2)} ms.`,
|
||||
sql,
|
||||
});
|
||||
}
|
||||
if (duplicateCount === duplicateWarningCount) {
|
||||
await policy.onIssue?.({
|
||||
code: "WRN-DB-DUPLICATE-QUERY",
|
||||
severity: "warning",
|
||||
message: `The same query ran ${duplicateCount} times in one request scope (possible N+1).`,
|
||||
sql,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const wrapped: Db = {
|
||||
driver: db.driver,
|
||||
async all<T = Row>(sql: string, params: unknown[] = [], model?: Model<T>): Promise<T[]> {
|
||||
await inspect(sql);
|
||||
const started = performance.now();
|
||||
const rows = await withTimeout(db.all(sql, params, model), timeoutMs, sql);
|
||||
const duration = performance.now() - started;
|
||||
if (rows.length > maxRows) {
|
||||
await policy.onIssue?.({
|
||||
code: "WRN-DB-MAX-ROWS",
|
||||
severity: "error",
|
||||
message: `Query returned ${rows.length} rows; maximum is ${maxRows}.`,
|
||||
sql,
|
||||
});
|
||||
throw new Error(`WRN-DB-MAX-ROWS: query returned ${rows.length} rows.`);
|
||||
}
|
||||
await record(sql, params, "all", duration, rows.length);
|
||||
return rows;
|
||||
},
|
||||
async one<T = Row>(sql: string, params: unknown[] = [], model?: Model<T>): Promise<T | null> {
|
||||
await inspect(sql);
|
||||
const started = performance.now();
|
||||
const row = await withTimeout(db.one(sql, params, model), timeoutMs, sql);
|
||||
await record(sql, params, "one", performance.now() - started, row ? 1 : 0);
|
||||
return row;
|
||||
},
|
||||
async exec(sql: string, params: unknown[] = []): Promise<ExecResult> {
|
||||
const started = performance.now();
|
||||
const result = await withTimeout(db.exec(sql, params), timeoutMs, sql);
|
||||
await record(sql, params, "exec", performance.now() - started, result.changes);
|
||||
return result;
|
||||
},
|
||||
tx<T>(fn: (transaction: Db) => Promise<T>): Promise<T> {
|
||||
return db.tx((transaction) => fn(instrumentDb(transaction, policy)));
|
||||
},
|
||||
createTable(model) {
|
||||
return db.createTable(model);
|
||||
},
|
||||
close() {
|
||||
return db.close();
|
||||
},
|
||||
};
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export function queryOperation(sql: string): string {
|
||||
return operationName(sql);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
@@ -22,6 +22,8 @@
|
||||
"@wrnexus/pubsub": "workspace:*",
|
||||
"@wrnexus/uploader": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/store": "workspace:*"
|
||||
"@wrnexus/store": "workspace:*",
|
||||
"@wrnexus/security": "workspace:*",
|
||||
"@wrnexus/observability": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,3 +73,20 @@ test("collectScripts omits client navigation in document mode", () => {
|
||||
|
||||
expect(scripts).not.toContain("/__wrnexus/nav.js");
|
||||
});
|
||||
|
||||
test("collectScripts ships zero framework JavaScript for static markup by default", () => {
|
||||
expect(collectScripts('<main><a href="/about">About</a></main>')).toEqual([]);
|
||||
});
|
||||
|
||||
test("collectScripts adds navigation in explicit client mode", () => {
|
||||
expect(collectScripts("<main>Server rendered</main>", [], { mode: "client" })).toEqual([
|
||||
"/__wrnexus/nav.js",
|
||||
]);
|
||||
});
|
||||
|
||||
test("collectScripts auto-adds navigation when a page already needs a browser runtime", () => {
|
||||
expect(collectScripts('<button data-scope="{}">Count</button>')).toEqual([
|
||||
"/__wrnexus/nav.js",
|
||||
"/__wrnexus/reactive.js",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
|
||||
if (window.__wrnexusDevToolbar) return;
|
||||
const KEY = "__wrnexus_dev_toolbar_settings__";
|
||||
const state = { open:false, issues:[], report:null, search:"", severity:"all", category:"all", platform:null, panels:[], config:{ position:"bottom-center", scanOnNavigation:true, scanOnHmr:true } };
|
||||
const state = { open:false, issues:[], report:null, search:"", severity:"all", category:"all", platform:null, panels:[], runtimeMetrics:{ hydrationCount:0, hydrationMs:0, longTasks:[] }, config:{ position:"bottom-center", scanOnNavigation:true, scanOnHmr:true } };
|
||||
try { state.config = Object.assign(state.config, JSON.parse(localStorage.getItem(KEY) || "{}")); } catch {}
|
||||
const host = document.createElement("wrnexus-dev-toolbar");
|
||||
host.setAttribute("data-wrnexus-dev-toolbar", "");
|
||||
@@ -36,16 +36,21 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
|
||||
const count=document.querySelectorAll("*").length;if(count>1500)found.push(issue("html/dom-size","html",count>3000?"error":"warning","DOM is large","The page contains "+count+" elements.",null,"Reduce wrappers and render hidden content on demand."));
|
||||
q("button button,button a,a a,a button").forEach(el=>found.push(issue("html/nested-interactive","html","error","Interactive elements are nested","Nested links or buttons create invalid interaction behavior.",el,"Use one interactive element.")));
|
||||
q("a").forEach(el=>{const href=el.getAttribute("href");if(!href||href==="#")found.push(issue("links/empty","links","warning","Link has no useful destination","The href is missing or only #.",el,"Use a real URL or a button."));if(el.target==="_blank"&&!/\b(noopener|noreferrer)\b/.test(el.rel))found.push(issue("links/blank-rel","security","warning","New-tab link lacks rel protection","The opened page may access window.opener.",el,"Add rel=\"noopener noreferrer\"."));});
|
||||
q("form").forEach(el=>{const method=(el.getAttribute("method")||"get").toLowerCase();const action=el.getAttribute("action")||"";if(location.protocol==="https:"&&action.startsWith("http://"))found.push(issue("security/insecure-form-action","security","error","Form submits over HTTP",action,el,"Use an HTTPS or same-origin form action."));if(method!=="get"&&!el.hasAttribute("data-csrf")&&!el.querySelector('input[name="wire-csrf"],input[name="_csrf"]'))found.push(issue("security/missing-csrf","security","error","State-changing form has no CSRF marker","Cookie-authenticated form submissions require CSRF protection.",el,"Add data-csrf or a framework-generated CSRF field."));});
|
||||
q("script:not([src]):not([type='application/json']):not([nonce])").forEach(el=>found.push(issue("security/inline-script","security","warning","Inline script has no CSP nonce","A strict Content Security Policy will block this script.",el,"Move code to a client module or attach the request nonce.")));
|
||||
q("iframe:not([sandbox])").forEach(el=>found.push(issue("security/iframe-sandbox","security","warning","Iframe is not sandboxed","Untrusted iframe content has broad browser capabilities.",el,"Add the narrowest sandbox and permissions policy.")));
|
||||
q("[src],[href],[action]").forEach(el=>{const raw=el.getAttribute("src")||el.getAttribute("href")||el.getAttribute("action")||"";if(location.protocol==="https:"&&raw.startsWith("http://"))found.push(issue("security/mixed-content","security","error","Mixed-content resource",raw,el,"Use HTTPS or a same-origin resource."));});
|
||||
try{for(let i=0;i<localStorage.length;i++){const key=localStorage.key(i)||"";if(/(?:token|secret|password|session|credential|authorization)/i.test(key))found.push(issue("security/sensitive-local-storage","security","error","Sensitive value may be stored in localStorage","Storage key “"+key+"” looks authentication- or secret-related.",null,"Keep sessions and credentials in Secure, HttpOnly cookies."));}}catch{}
|
||||
if(document.documentElement.scrollWidth>innerWidth+2)found.push(issue("responsive/document-overflow","responsive","error","Page has horizontal overflow","Document width exceeds the viewport.",null,"Inspect fixed widths, long text and overflowing media."));
|
||||
q("body *").filter(visible).slice(0,2500).forEach(el=>{const r=el.getBoundingClientRect();if((r.right>innerWidth+8||r.left<-8)&&found.filter(x=>x.ruleId==="responsive/element-overflow").length<20)found.push(issue("responsive/element-overflow","responsive","warning","Element extends outside the viewport","Element bounds exceed the current viewport.",el,"Use fluid sizing, wrapping, max-width or an intentional scroll container."));});
|
||||
const resources=performance.getEntriesByType("resource");const total=resources.reduce((s,e)=>s+(e.transferSize||0),0);if(resources.length>150)found.push(issue("performance/resource-count","performance","warning","Page loads many resources","Found "+resources.length+" resource requests.",null,"Remove duplicates and defer non-critical resources."));if(total>5000000)found.push(issue("performance/transfer-size","performance",total>10000000?"error":"warning","Page transfer size is large","Observed transfer size is about "+(total/1000000).toFixed(2)+" MB.",null,"Compress and optimize page resources."));
|
||||
const resources=performance.getEntriesByType("resource");const total=resources.reduce((s,e)=>s+(e.transferSize||0),0);const jsBytes=resources.filter(e=>/(?:\.m?js)(?:\?|$)/i.test(e.name)).reduce((sum,e)=>sum+(e.transferSize||0),0);if(resources.length>150)found.push(issue("performance/resource-count","performance","warning","Page loads many resources","Found "+resources.length+" resource requests.",null,"Remove duplicates and defer non-critical resources."));if(total>5000000)found.push(issue("performance/transfer-size","performance",total>10000000?"error":"warning","Page transfer size is large","Observed transfer size is about "+(total/1000000).toFixed(2)+" MB.",null,"Compress and optimize page resources."));if(jsBytes>150000)found.push(issue("performance/javascript-budget","javascript",jsBytes>300000?"error":"warning","JavaScript budget exceeded","JavaScript transfer is about "+(jsBytes/1000).toFixed(1)+" KB.",null,"Split routes and defer optional hydration."));const hydrationRoots=q("[data-wrn-client-module],[data-wrn-hydrate]");if(hydrationRoots.length>50)found.push(issue("performance/hydration-count","runtime","warning","Many components hydrate",hydrationRoots.length+" hydration boundaries were found.",null,"Use visible, idle or interaction hydration."));if(state.runtimeMetrics.longTasks.length)found.push(issue("performance/long-tasks","javascript","warning","Long main-thread tasks detected",state.runtimeMetrics.longTasks.length+" task(s) exceeded 50 ms.",null,"Split expensive work and reduce hydration.","high",{longestMs:Math.max(...state.runtimeMetrics.longTasks)}));
|
||||
q("[data-wrn-client-module]").forEach(el=>found.push(issue("runtime/client-module","runtime","info","Client function module",el.getAttribute("data-wrn-client-module")||"Unknown module",el,"Loaded according to the component hydration strategy.","high",{hydration:el.getAttribute("data-wrn-hydrate"),runtime:el.getAttribute("data-wrn-runtime")})));
|
||||
const storeContainer=window.__wrnexusStoreContainer;
|
||||
if(storeContainer&&typeof storeContainer.inspect==="function"){
|
||||
for(const store of storeContainer.inspect())found.push(issue("stores/instance","stores","info",store.kind+" store: "+store.name,JSON.stringify({state:store.state,computed:store.computed}),null,"Use store actions for mutations. Sensitive server state is never hydrated.","high",store));
|
||||
}
|
||||
const unique=new Map();[...state.issues.filter(x=>["runtime","stores","network","server","compiler","routing"].includes(x.category)),...found].forEach(x=>unique.set(x.fingerprint,x));state.issues=[...unique.values()];
|
||||
state.report={url:location.href,pathname:location.pathname,title:document.title,status:200,generatedAt:Date.now(),issues:state.issues,metrics:{domNodes:count,cssResources:q('link[rel="stylesheet"]').length,scriptResources:q("script[src]").length,imageResources:q("img").length,totalTransferBytes:total,pageLoadMs:performance.getEntriesByType("navigation")[0]?.duration}}; render(); return state.report;
|
||||
state.report={url:location.href,pathname:location.pathname,title:document.title,status:200,generatedAt:Date.now(),issues:state.issues,metrics:{domNodes:count,cssResources:q('link[rel="stylesheet"]').length,scriptResources:q("script[src]").length,imageResources:q("img").length,totalTransferBytes:total,javascriptBytes:jsBytes,hydratedComponents:state.runtimeMetrics.hydrationCount,hydrationMs:state.runtimeMetrics.hydrationMs,longTasks:state.runtimeMetrics.longTasks.length,pageLoadMs:performance.getEntriesByType("navigation")[0]?.duration}}; render(); return state.report;
|
||||
};
|
||||
let highlightEl=null; const clearHighlight=()=>{highlightEl?.remove();highlightEl=null}; const highlight=sel=>{clearHighlight();let target;try{target=document.querySelector(sel)}catch{}if(!target)return;const r=target.getBoundingClientRect();highlightEl=document.createElement("div");highlightEl.className="wrn-highlight";Object.assign(highlightEl.style,{left:r.left+"px",top:r.top+"px",width:r.width+"px",height:r.height+"px"});root.appendChild(highlightEl);target.scrollIntoView({block:"center",behavior:"smooth"});setTimeout(clearHighlight,3000)};
|
||||
const render=()=>{const filtered=state.issues.filter(x=>{const severityMatches=state.severity==="all"||x.severity===state.severity;const categoryMatches=state.category==="all"||x.category===state.category;const textMatches=!state.search||[x.title,x.message,x.ruleId,x.category,x.severity,x.source?.file].join(" ").toLowerCase().includes(state.search);return severityMatches&&categoryMatches&&textMatches;});root.querySelector("[data-errors]").textContent=state.issues.filter(x=>x.severity==="error").length;root.querySelector("[data-warnings]").textContent=state.issues.filter(x=>x.severity==="warning").length;root.querySelector("[data-suggestions]").textContent=state.issues.filter(x=>x.severity==="suggestion").length;root.querySelectorAll("[data-filter]").forEach(button=>{const active=button.dataset.filter===state.severity;button.classList.toggle("active",active);button.setAttribute("aria-pressed",String(active));});root.querySelectorAll("[data-category]").forEach(button=>{const active=button.dataset.category===state.category;button.classList.toggle("active",active);button.setAttribute("aria-pressed",String(active));});meta.textContent=location.pathname+" · "+state.issues.length+" findings"+(state.category!=="all"?" · "+state.category:"")+(state.severity!=="all"?" · "+state.severity:"");if(!filtered.length){list.innerHTML='<div class="wrn-empty">No issues match the current filters.</div>';return;}list.innerHTML=filtered.map(x=>'<article class="wrn-issue"><span class="wrn-severity '+esc(x.severity)+'"></span><div><div class="wrn-issue-title">'+esc(x.title)+'</div><div class="wrn-issue-message">'+esc(x.message)+'</div><div class="wrn-issue-meta">'+esc(x.severity)+' · '+esc(x.category)+' · '+esc(x.ruleId)+(x.source?.file?' · '+esc(x.source.file):'')+'</div></div><div class="wrn-actions">'+(x.target?.selector?'<button class="wrn-small" data-highlight="'+esc(x.target.selector)+'">Show</button>':'')+'</div></article>').join("");};
|
||||
@@ -57,6 +62,8 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
|
||||
addEventListener("wrnexus:navigated",()=>{if(state.config.scanOnNavigation)setTimeout(scan,50)});addEventListener("wrnexus:hmr",()=>{if(state.config.scanOnHmr)setTimeout(scan,100)});addEventListener("wrnexus:runtime-error",event=>addRuntime("error","WRNexus runtime error",event.detail?.message||"Runtime failure",event.detail||{}));
|
||||
addEventListener("wrnexus:diagnostic",event=>{const detail=event.detail||{};const code=detail.code||"WRN-RUNTIME";const category=String(code).includes("HYDRATE")?"runtime":String(code).includes("ROUTE")?"routing":"compiler";const title=String(code).includes("HYDRATE")?"Hydration diagnostic":"WRNexus diagnostic";const x=issue(String(code),category,"error",title,detail.message||"Framework diagnostic",null,"Open the source location and resolve the reported framework contract.","high",detail);state.issues=[x,...state.issues.filter(i=>i.fingerprint!==x.fingerprint)];render();});
|
||||
addEventListener("wrnexus:store-mutation",event=>{const mutation=event.detail||{};const x=issue("stores/mutation","stores","info","Store action: "+(mutation.store||"unknown")+"."+(mutation.action||"direct"),"Changed fields: "+((mutation.changed||[]).join(", ")||"none"),null,"Use the Stores panel to inspect safe client state.","high",mutation);state.issues=[x,...state.issues.filter(i=>i.fingerprint!==x.fingerprint)];render();});
|
||||
addEventListener("wrnexus:hydrated",event=>{state.runtimeMetrics.hydrationCount+=1;state.runtimeMetrics.hydrationMs+=Number(event.detail?.durationMs||0);});
|
||||
try{new PerformanceObserver(list=>{for(const entry of list.getEntries()){if(entry.duration>50)state.runtimeMetrics.longTasks.push(entry.duration);}state.runtimeMetrics.longTasks=state.runtimeMetrics.longTasks.slice(-100);}).observe({type:"longtask",buffered:true});}catch{}
|
||||
fetch("/__wrnexus/dev-toolbar/platform").then(r=>r.ok?r.json():null).then(data=>{if(!data)return;state.platform=data.platform;state.panels=data.panels||[];render();}).catch(()=>{});
|
||||
window.__wrnexusDevToolbar={open(){state.open=true;panel.classList.add("open")},close(){state.open=false;panel.classList.remove("open")},toggle(){state.open=!state.open;panel.classList.toggle("open",state.open)},scan,clear(){state.issues=[];render()},report(){return state.report},highlight,configure(config){state.config=Object.assign(state.config,config||{});shell.dataset.position=state.config.position;try{localStorage.setItem(KEY,JSON.stringify(state.config))}catch{}}};
|
||||
const observer=new MutationObserver(()=>{clearTimeout(observer.timer);observer.timer=setTimeout(()=>{if(!state.open)return;scan()},400)});observer.observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style","src","href","alt","aria-label"]});
|
||||
|
||||
@@ -6,13 +6,13 @@ export const performanceRules: DevToolbarRule[] = [
|
||||
id: "performance/resources",
|
||||
category: "performance",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks resource count and transfer sizes.",
|
||||
run: ({ performanceEntries }) => {
|
||||
description: "Checks resource, DOM, hydration, and main-thread budgets.",
|
||||
run: ({ document, root, performanceEntries }) => {
|
||||
const resources = performanceEntries.filter(
|
||||
(entry): entry is PerformanceResourceTiming => entry.entryType === "resource",
|
||||
);
|
||||
const issues = [];
|
||||
if (resources.length > 150)
|
||||
if (resources.length > 150) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/resource-count",
|
||||
@@ -24,8 +24,9 @@ export const performanceRules: DevToolbarRule[] = [
|
||||
"Remove duplicates, combine tiny assets where useful, and load non-critical resources later.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const total = resources.reduce((sum, entry) => sum + (entry.transferSize || 0), 0);
|
||||
if (total > 5_000_000)
|
||||
if (total > 5_000_000) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/transfer-size",
|
||||
@@ -37,7 +38,26 @@ export const performanceRules: DevToolbarRule[] = [
|
||||
"Compress images, scripts, styles and fonts; review third-party resources.",
|
||||
}),
|
||||
);
|
||||
for (const entry of resources.filter((item) => item.duration > 2000).slice(0, 20))
|
||||
}
|
||||
|
||||
const javascriptBytes = resources
|
||||
.filter((entry) => /(?:\.m?js)(?:\?|$)/i.test(entry.name))
|
||||
.reduce((sum, entry) => sum + (entry.transferSize || 0), 0);
|
||||
if (javascriptBytes > 150_000) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/javascript-budget",
|
||||
category: "javascript",
|
||||
severity: javascriptBytes > 300_000 ? "error" : "warning",
|
||||
title: "JavaScript budget exceeded",
|
||||
message: `JavaScript transfer is approximately ${(javascriptBytes / 1_000).toFixed(1)} KB.`,
|
||||
recommendation:
|
||||
"Split routes, remove unused client code, and defer optional hydration.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const entry of resources.filter((item) => item.duration > 2_000).slice(0, 20)) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/slow-resource",
|
||||
@@ -49,6 +69,90 @@ export const performanceRules: DevToolbarRule[] = [
|
||||
metadata: { url: entry.name, duration: entry.duration },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const domNodes = root.querySelectorAll("*").length;
|
||||
if (domNodes > 1_500) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/dom-size",
|
||||
category: "performance",
|
||||
severity: domNodes > 3_000 ? "error" : "warning",
|
||||
title: "DOM is large",
|
||||
message: `The page contains ${domNodes} elements.`,
|
||||
recommendation:
|
||||
"Virtualize long lists and avoid rendering hidden or duplicate structures.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const hydrationRoots = root.querySelectorAll("[data-wrn-client-module], [data-wrn-hydrate]");
|
||||
if (hydrationRoots.length > 50) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/hydration-count",
|
||||
category: "runtime",
|
||||
severity: "warning",
|
||||
title: "Many components hydrate",
|
||||
message: `${hydrationRoots.length} hydration boundaries were found.`,
|
||||
recommendation:
|
||||
"Use visible, idle, or interaction hydration and keep static components server-only.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const image of root.querySelectorAll<HTMLImageElement>("img")) {
|
||||
if (!image.complete || !image.naturalWidth) continue;
|
||||
const renderedWidth = Math.max(1, image.getBoundingClientRect().width);
|
||||
if (image.naturalWidth > renderedWidth * 2.5) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/oversized-image",
|
||||
category: "images",
|
||||
severity: "warning",
|
||||
title: "Image is larger than rendered size",
|
||||
message: `${image.naturalWidth}px image is rendered at about ${Math.round(renderedWidth)}px.`,
|
||||
element: image,
|
||||
recommendation: "Generate responsive srcset candidates and accurate sizes.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const blocking = document.querySelectorAll(
|
||||
'head script:not([async]):not([defer]):not([type="module"]), head link[rel="stylesheet"]:not([media])',
|
||||
);
|
||||
if (blocking.length > 4) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/render-blocking",
|
||||
category: "performance",
|
||||
severity: "warning",
|
||||
title: "Multiple render-blocking resources",
|
||||
message: `${blocking.length} potentially render-blocking resources were found.`,
|
||||
recommendation: "Inline only critical CSS and defer non-critical scripts and styles.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const longTasks = performanceEntries.filter(
|
||||
(entry) => entry.entryType === "longtask" && entry.duration > 50,
|
||||
);
|
||||
if (longTasks.length) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/long-tasks",
|
||||
category: "javascript",
|
||||
severity: "warning",
|
||||
title: "Long main-thread tasks detected",
|
||||
message: `${longTasks.length} task(s) exceeded 50 ms.`,
|
||||
recommendation:
|
||||
"Split expensive work, reduce hydration, and move non-UI work off the main thread.",
|
||||
metadata: { longestMs: Math.max(...longTasks.map((entry) => entry.duration)) },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { createIssue } from "./helpers.ts";
|
||||
|
||||
const SECRET_KEY = /pass(word)?|token|secret|api[-_]?key|authorization|session/i;
|
||||
|
||||
export const securityRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "security/page",
|
||||
category: "security",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks development-visible security mistakes.",
|
||||
run: ({ url, root }) => {
|
||||
run: ({ document, window, url, root }) => {
|
||||
const issues = [];
|
||||
for (const [key] of url.searchParams)
|
||||
if (/pass(word)?|token|secret|api[-_]?key/i.test(key))
|
||||
for (const [key] of url.searchParams) {
|
||||
if (SECRET_KEY.test(key)) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/secret-query",
|
||||
@@ -22,7 +24,10 @@ export const securityRules: DevToolbarRule[] = [
|
||||
"Send secrets in a secure request body or authorization header, not a URL.",
|
||||
}),
|
||||
);
|
||||
for (const form of root.querySelectorAll<HTMLFormElement>('form[action^="http://"]'))
|
||||
}
|
||||
}
|
||||
|
||||
for (const form of root.querySelectorAll<HTMLFormElement>('form[action^="http://"]')) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/insecure-form",
|
||||
@@ -34,6 +39,133 @@ export const securityRules: DevToolbarRule[] = [
|
||||
recommendation: "Submit to an HTTPS endpoint.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const form of root.querySelectorAll<HTMLFormElement>("form")) {
|
||||
const method = (form.method || "get").toUpperCase();
|
||||
if (
|
||||
!["GET", "HEAD"].includes(method) &&
|
||||
!form.querySelector('input[name="wire-csrf"], input[name="_csrf"]')
|
||||
) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/missing-csrf",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "State-changing form has no CSRF token",
|
||||
message: `${method} form does not contain a recognized CSRF field.`,
|
||||
element: form,
|
||||
recommendation: "Enable WRNexus CSRF middleware and use the generated form token.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const script of root.querySelectorAll<HTMLScriptElement>("script:not([src])")) {
|
||||
if (script.type === "application/json" || script.hasAttribute("nonce")) continue;
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/inline-script",
|
||||
category: "security",
|
||||
severity: "warning",
|
||||
title: "Inline script has no CSP nonce",
|
||||
message: "A strict Content Security Policy will block this inline script.",
|
||||
element: script,
|
||||
recommendation: "Move code into a client module or attach the request CSP nonce.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const element of root.querySelectorAll<HTMLElement>("[style]")) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/inline-style",
|
||||
category: "security",
|
||||
severity: "suggestion",
|
||||
title: "Inline style weakens strict CSP",
|
||||
message: "The element uses a style attribute.",
|
||||
element,
|
||||
recommendation:
|
||||
"Prefer extracted CSS classes or a nonce/hash-compatible style strategy.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const element of root.querySelectorAll<HTMLElement>("[src], [href], [action]")) {
|
||||
const raw =
|
||||
element.getAttribute("src") ??
|
||||
element.getAttribute("href") ??
|
||||
element.getAttribute("action");
|
||||
if (window.location.protocol === "https:" && raw?.startsWith("http://")) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/mixed-content",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "Mixed-content resource",
|
||||
message: `${raw} is loaded over insecure HTTP.`,
|
||||
element,
|
||||
recommendation: "Use HTTPS or serve the resource from the same secure origin.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const frame of root.querySelectorAll<HTMLIFrameElement>("iframe:not([sandbox])")) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/iframe-sandbox",
|
||||
category: "security",
|
||||
severity: "warning",
|
||||
title: "Iframe is not sandboxed",
|
||||
message: "Third-party or untrusted iframe content has broad browser capabilities.",
|
||||
element: frame,
|
||||
recommendation: "Add the narrowest possible sandbox and permissions policy.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index) ?? "";
|
||||
if (SECRET_KEY.test(key)) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/sensitive-local-storage",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "Sensitive value may be stored in localStorage",
|
||||
message: `Storage key “${key}” looks authentication- or secret-related.`,
|
||||
recommendation: "Keep sessions and credentials in Secure, HttpOnly cookies.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Storage may be unavailable in restricted browser contexts.
|
||||
}
|
||||
|
||||
const hydration = document.querySelectorAll<HTMLScriptElement>(
|
||||
'script[type="application/json"][data-wrnexus-state], script[data-wrnexus-store-state]',
|
||||
);
|
||||
for (const script of hydration) {
|
||||
if (SECRET_KEY.test(script.textContent ?? "")) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/hydration-secret",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "Hydration payload may contain a secret",
|
||||
message: "Sensitive-looking field names were found in serialized client state.",
|
||||
element: script,
|
||||
recommendation: "Move sensitive state to server-only state and regenerate the page.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -79,6 +79,13 @@ export interface DevToolbarMetrics {
|
||||
longTasks?: number;
|
||||
layoutShifts?: number;
|
||||
pageLoadMs?: number;
|
||||
hydrationMs?: number;
|
||||
hydratedComponents?: number;
|
||||
javascriptBytes?: number;
|
||||
cssBytes?: number;
|
||||
lcpMs?: number;
|
||||
cls?: number;
|
||||
inpMs?: number;
|
||||
}
|
||||
|
||||
export interface DevToolbarPageReport {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# @wrnexus/image
|
||||
|
||||
Responsive image attribute generation with secure remote-host policies and audits for dimensions, LCP loading, source oversizing, transfer size, and modern formats.
|
||||
|
||||
```ts
|
||||
import { createResponsiveImage } from "@wrnexus/image";
|
||||
const attrs = createResponsiveImage({
|
||||
src: "/hero.jpg",
|
||||
alt: "Hero",
|
||||
width: 1600,
|
||||
height: 900,
|
||||
widths: [480, 960, 1600],
|
||||
sizes: "100vw",
|
||||
format: "avif",
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@wrnexus/image",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/security": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { validateUrl } from "@wrnexus/security";
|
||||
|
||||
export type ImageFormat = "avif" | "webp" | "jpeg" | "png" | "original";
|
||||
|
||||
export interface ImageLoaderInput {
|
||||
src: string;
|
||||
width: number;
|
||||
quality?: number;
|
||||
format?: ImageFormat;
|
||||
}
|
||||
|
||||
export type ImageLoader = (input: ImageLoaderInput) => string;
|
||||
|
||||
export interface ImagePolicy {
|
||||
remoteHosts?: string[];
|
||||
allowedProtocols?: string[];
|
||||
maxWidth?: number;
|
||||
maxQuality?: number;
|
||||
}
|
||||
|
||||
export interface ResponsiveImageOptions extends ImagePolicy {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
widths?: number[];
|
||||
sizes?: string;
|
||||
quality?: number;
|
||||
format?: ImageFormat;
|
||||
loading?: "eager" | "lazy";
|
||||
fetchPriority?: "high" | "low" | "auto";
|
||||
decoding?: "async" | "sync" | "auto";
|
||||
loader?: ImageLoader;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export interface ResponsiveImageAttributes {
|
||||
src: string;
|
||||
srcset?: string;
|
||||
sizes?: string;
|
||||
alt: string;
|
||||
width: string;
|
||||
height: string;
|
||||
loading: "eager" | "lazy";
|
||||
decoding: "async" | "sync" | "auto";
|
||||
fetchpriority?: "high" | "low" | "auto";
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export interface ImageAuditInput {
|
||||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
renderedWidth?: number;
|
||||
bytes?: number;
|
||||
loading?: string;
|
||||
fetchPriority?: string;
|
||||
isLcp?: boolean;
|
||||
}
|
||||
|
||||
export interface ImageAuditIssue {
|
||||
code: string;
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const defaultImageLoader: ImageLoader = ({ src, width, quality, format }) => {
|
||||
const separator = src.includes("?") ? "&" : "?";
|
||||
const params = new URLSearchParams({ w: String(width) });
|
||||
if (quality !== undefined) params.set("q", String(quality));
|
||||
if (format && format !== "original") params.set("format", format);
|
||||
return `${src}${separator}${params.toString()}`;
|
||||
};
|
||||
|
||||
function validateSource(src: string, policy: ImagePolicy): void {
|
||||
if (/^(?:https?:)?\/\//i.test(src)) {
|
||||
validateUrl(src.startsWith("//") ? `https:${src}` : src, {
|
||||
allowRelative: false,
|
||||
allowedProtocols: policy.allowedProtocols ?? ["https:"],
|
||||
allowedHosts: policy.remoteHosts,
|
||||
allowCredentials: false,
|
||||
});
|
||||
} else {
|
||||
validateUrl(src, {
|
||||
base: "https://wrnexus.invalid",
|
||||
allowRelative: true,
|
||||
allowedProtocols: ["https:"],
|
||||
allowCredentials: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createResponsiveImage(options: ResponsiveImageOptions): ResponsiveImageAttributes {
|
||||
validateSource(options.src, options);
|
||||
if (!Number.isFinite(options.width) || options.width <= 0) {
|
||||
throw new RangeError("Image width must be a positive number.");
|
||||
}
|
||||
if (!Number.isFinite(options.height) || options.height <= 0) {
|
||||
throw new RangeError("Image height must be a positive number.");
|
||||
}
|
||||
const maxWidth = options.maxWidth ?? 8_192;
|
||||
const maxQuality = options.maxQuality ?? 100;
|
||||
const quality = Math.min(maxQuality, Math.max(1, Math.round(options.quality ?? 80)));
|
||||
const widths = [...new Set(options.widths ?? [options.width])]
|
||||
.map((width) => Math.round(width))
|
||||
.filter((width) => width > 0 && width <= maxWidth)
|
||||
.sort((a, b) => a - b);
|
||||
if (!widths.includes(Math.round(options.width)) && options.width <= maxWidth) {
|
||||
widths.push(Math.round(options.width));
|
||||
widths.sort((a, b) => a - b);
|
||||
}
|
||||
const loader = options.loader ?? defaultImageLoader;
|
||||
const src = loader({
|
||||
src: options.src,
|
||||
width: Math.min(Math.round(options.width), maxWidth),
|
||||
quality,
|
||||
format: options.format,
|
||||
});
|
||||
const srcset =
|
||||
widths.length > 1
|
||||
? widths
|
||||
.map(
|
||||
(width) =>
|
||||
`${loader({ src: options.src, width, quality, format: options.format })} ${width}w`,
|
||||
)
|
||||
.join(", ")
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
src,
|
||||
...(srcset ? { srcset } : {}),
|
||||
...(options.sizes ? { sizes: options.sizes } : {}),
|
||||
alt: options.alt,
|
||||
width: String(Math.round(options.width)),
|
||||
height: String(Math.round(options.height)),
|
||||
loading: options.loading ?? (options.fetchPriority === "high" ? "eager" : "lazy"),
|
||||
decoding: options.decoding ?? "async",
|
||||
...(options.fetchPriority ? { fetchpriority: options.fetchPriority } : {}),
|
||||
...(options.class ? { class: options.class } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function auditImage(input: ImageAuditInput): ImageAuditIssue[] {
|
||||
const issues: ImageAuditIssue[] = [];
|
||||
if (!input.width || !input.height) {
|
||||
issues.push({
|
||||
code: "WRN-IMAGE-DIMENSIONS",
|
||||
severity: "error",
|
||||
message: "Images must declare width and height to prevent layout shifts.",
|
||||
});
|
||||
}
|
||||
if (input.isLcp && input.loading === "lazy") {
|
||||
issues.push({
|
||||
code: "WRN-IMAGE-LCP-LAZY",
|
||||
severity: "error",
|
||||
message: "The LCP image must not be lazy-loaded.",
|
||||
});
|
||||
}
|
||||
if (input.isLcp && input.fetchPriority !== "high") {
|
||||
issues.push({
|
||||
code: "WRN-IMAGE-LCP-PRIORITY",
|
||||
severity: "warning",
|
||||
message: "Consider fetchpriority=high for the LCP image.",
|
||||
});
|
||||
}
|
||||
if (input.width && input.renderedWidth && input.width > input.renderedWidth * 2.5) {
|
||||
issues.push({
|
||||
code: "WRN-IMAGE-OVERSIZED",
|
||||
severity: "warning",
|
||||
message: "The source image is substantially wider than its rendered size.",
|
||||
});
|
||||
}
|
||||
if ((input.bytes ?? 0) > 1_000_000) {
|
||||
issues.push({
|
||||
code: "WRN-IMAGE-BYTES",
|
||||
severity: (input.bytes ?? 0) > 3_000_000 ? "error" : "warning",
|
||||
message: `Image transfer size is ${Math.round((input.bytes ?? 0) / 1024)} KiB.`,
|
||||
});
|
||||
}
|
||||
if (/\.(?:png|jpe?g)(?:\?|$)/i.test(input.src)) {
|
||||
issues.push({
|
||||
code: "WRN-IMAGE-MODERN-FORMAT",
|
||||
severity: "info",
|
||||
message: "Consider serving AVIF or WebP with a compatible fallback.",
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { auditImage, createResponsiveImage } from "../src/index.ts";
|
||||
|
||||
describe("@wrnexus/image", () => {
|
||||
test("builds deterministic responsive image attributes", () => {
|
||||
const image = createResponsiveImage({
|
||||
src: "/hero.jpg",
|
||||
alt: "Hero",
|
||||
width: 1200,
|
||||
height: 600,
|
||||
widths: [400, 800, 1200, 800],
|
||||
sizes: "100vw",
|
||||
format: "webp",
|
||||
fetchPriority: "high",
|
||||
});
|
||||
expect(image.srcset).toContain("400w");
|
||||
expect(image.srcset).toContain("1200w");
|
||||
expect(image.loading).toBe("eager");
|
||||
expect(image.width).toBe("1200");
|
||||
});
|
||||
|
||||
test("reports layout shift and LCP mistakes", () => {
|
||||
const issues = auditImage({ src: "hero.jpg", isLcp: true, loading: "lazy" });
|
||||
expect(issues.map((issue) => issue.code)).toContain("WRN-IMAGE-DIMENSIONS");
|
||||
expect(issues.map((issue) => issue.code)).toContain("WRN-IMAGE-LCP-LAZY");
|
||||
});
|
||||
test("rejects insecure remote images unless explicitly allowed", () => {
|
||||
expect(() =>
|
||||
createResponsiveImage({
|
||||
src: "http://images.example.com/hero.jpg",
|
||||
alt: "Hero",
|
||||
width: 800,
|
||||
height: 400,
|
||||
remoteHosts: ["images.example.com"],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# @wrnexus/observability
|
||||
|
||||
Privacy-conscious counters, gauges, histograms, HTTP middleware, Web Vitals ingestion, browser collection, and exporter adapters. Request bodies and user identifiers are not collected by default.
|
||||
|
||||
```ts
|
||||
export default {
|
||||
observability: { enabled: true, serverTiming: true, sampleRate: 0.1, webVitals: true },
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@wrnexus/observability",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"description": "Metrics, Web Vitals collection, request instrumentation, and exporter adapters for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface WebVitalsClientOptions {
|
||||
endpoint?: string;
|
||||
sampleRate?: number;
|
||||
}
|
||||
|
||||
export function webVitalsClient(options: WebVitalsClientOptions = {}): string {
|
||||
const endpoint = JSON.stringify(options.endpoint ?? "/__wrnexus/metrics/vitals");
|
||||
const sampleRate = Math.max(0, Math.min(1, options.sampleRate ?? 0.1));
|
||||
return `(function(){
|
||||
if (!window.PerformanceObserver || Math.random() > ${sampleRate}) return;
|
||||
var endpoint = ${endpoint};
|
||||
var cls = 0;
|
||||
function rating(name, value) {
|
||||
if (name === "LCP") return value <= 2500 ? "good" : value <= 4000 ? "needs-improvement" : "poor";
|
||||
if (name === "INP") return value <= 200 ? "good" : value <= 500 ? "needs-improvement" : "poor";
|
||||
if (name === "CLS") return value <= 0.1 ? "good" : value <= 0.25 ? "needs-improvement" : "poor";
|
||||
return "unknown";
|
||||
}
|
||||
function send(name, value) {
|
||||
var body = JSON.stringify({ name: name, value: value, rating: rating(name, value), route: location.pathname, navigationType: performance.getEntriesByType("navigation")[0]?.type });
|
||||
if (navigator.sendBeacon) navigator.sendBeacon(endpoint, new Blob([body], { type: "application/json" }));
|
||||
else fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: body, keepalive: true }).catch(function(){});
|
||||
}
|
||||
try { new PerformanceObserver(function(list){ var entries=list.getEntries(); var last=entries[entries.length-1]; if(last) send("LCP", last.startTime); }).observe({type:"largest-contentful-paint",buffered:true}); } catch(_) {}
|
||||
try { new PerformanceObserver(function(list){ list.getEntries().forEach(function(e){ if(!e.hadRecentInput) cls += e.value; }); }).observe({type:"layout-shift",buffered:true}); } catch(_) {}
|
||||
try { new PerformanceObserver(function(list){ var max=0; list.getEntries().forEach(function(e){ max=Math.max(max,e.duration||0); }); if(max) send("INP",max); }).observe({type:"event",durationThreshold:40,buffered:true}); } catch(_) {}
|
||||
addEventListener("visibilitychange", function(){ if(document.visibilityState === "hidden" && cls) send("CLS", cls); }, { once: true });
|
||||
})();`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { MetricsRegistry } from "./metrics.ts";
|
||||
export type { MetricLabels, MetricPoint } from "./metrics.ts";
|
||||
export {
|
||||
createHttpMetricExporter,
|
||||
createWebVitalsHandler,
|
||||
defaultMetrics,
|
||||
metricsMiddleware,
|
||||
} from "./server.ts";
|
||||
export type {
|
||||
MetricExporter,
|
||||
MetricsMiddlewareOptions,
|
||||
WebVitalRecord,
|
||||
WebVitalsHandlerOptions,
|
||||
} from "./server.ts";
|
||||
export { webVitalsClient } from "./client.ts";
|
||||
export type { WebVitalsClientOptions } from "./client.ts";
|
||||
@@ -0,0 +1,81 @@
|
||||
export type MetricLabels = Record<string, string | number | boolean>;
|
||||
|
||||
export interface MetricPoint {
|
||||
name: string;
|
||||
type: "counter" | "gauge" | "histogram";
|
||||
value: number;
|
||||
count?: number;
|
||||
sum?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
labels: MetricLabels;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function labelKey(labels: MetricLabels): string {
|
||||
return Object.entries(labels)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([key, value]) => `${key}=${String(value)}`)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
interface StoredMetric extends MetricPoint {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class MetricsRegistry {
|
||||
private points = new Map<string, StoredMetric>();
|
||||
constructor(private readonly clock: () => number = Date.now) {}
|
||||
|
||||
increment(name: string, value = 1, labels: MetricLabels = {}): void {
|
||||
const key = `counter:${name}:${labelKey(labels)}`;
|
||||
const current = this.points.get(key);
|
||||
this.points.set(key, {
|
||||
key,
|
||||
name,
|
||||
type: "counter",
|
||||
value: (current?.value ?? 0) + value,
|
||||
labels: { ...labels },
|
||||
timestamp: this.clock(),
|
||||
});
|
||||
}
|
||||
|
||||
gauge(name: string, value: number, labels: MetricLabels = {}): void {
|
||||
const key = `gauge:${name}:${labelKey(labels)}`;
|
||||
this.points.set(key, {
|
||||
key,
|
||||
name,
|
||||
type: "gauge",
|
||||
value,
|
||||
labels: { ...labels },
|
||||
timestamp: this.clock(),
|
||||
});
|
||||
}
|
||||
|
||||
observe(name: string, value: number, labels: MetricLabels = {}): void {
|
||||
const key = `histogram:${name}:${labelKey(labels)}`;
|
||||
const current = this.points.get(key);
|
||||
const count = (current?.count ?? 0) + 1;
|
||||
const sum = (current?.sum ?? 0) + value;
|
||||
this.points.set(key, {
|
||||
key,
|
||||
name,
|
||||
type: "histogram",
|
||||
value: sum / count,
|
||||
count,
|
||||
sum,
|
||||
min: current?.min === undefined ? value : Math.min(current.min, value),
|
||||
max: current?.max === undefined ? value : Math.max(current.max, value),
|
||||
labels: { ...labels },
|
||||
timestamp: this.clock(),
|
||||
});
|
||||
}
|
||||
|
||||
snapshot(): MetricPoint[] {
|
||||
return [...this.points.values()].map(({ key: _key, ...point }) => ({ ...point }));
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.points.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
import { MetricsRegistry, type MetricPoint } from "./metrics.ts";
|
||||
|
||||
export interface MetricsMiddlewareOptions {
|
||||
registry?: MetricsRegistry;
|
||||
routeLabel?: (ctx: Context) => string;
|
||||
includePath?: boolean;
|
||||
}
|
||||
|
||||
export function metricsMiddleware(options: MetricsMiddlewareOptions = {}): Middleware {
|
||||
const registry = options.registry ?? defaultMetrics;
|
||||
let active = 0;
|
||||
return async (ctx, next) => {
|
||||
const started = performance.now();
|
||||
active++;
|
||||
registry.gauge("http.server.active_requests", active);
|
||||
try {
|
||||
const response = await next();
|
||||
const route =
|
||||
options.routeLabel?.(ctx) ?? (options.includePath ? ctx.url.pathname : "unknown");
|
||||
const labels = { method: ctx.req.method, status: response.status, route };
|
||||
registry.increment("http.server.requests", 1, labels);
|
||||
registry.observe("http.server.duration_ms", performance.now() - started, labels);
|
||||
return response;
|
||||
} catch (error) {
|
||||
registry.increment("http.server.errors", 1, { method: ctx.req.method });
|
||||
throw error;
|
||||
} finally {
|
||||
active--;
|
||||
registry.gauge("http.server.active_requests", active);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface WebVitalRecord {
|
||||
name: "LCP" | "INP" | "CLS" | "FCP" | "TTFB";
|
||||
value: number;
|
||||
rating?: "good" | "needs-improvement" | "poor";
|
||||
route?: string;
|
||||
navigationType?: string;
|
||||
}
|
||||
|
||||
export interface WebVitalsHandlerOptions {
|
||||
registry?: MetricsRegistry;
|
||||
onRecord?: (record: WebVitalRecord, request: Request) => void | Promise<void>;
|
||||
maxBodyBytes?: number;
|
||||
}
|
||||
|
||||
const VITAL_NAMES = new Set(["LCP", "INP", "CLS", "FCP", "TTFB"]);
|
||||
|
||||
export function createWebVitalsHandler(options: WebVitalsHandlerOptions = {}) {
|
||||
const registry = options.registry ?? defaultMetrics;
|
||||
const maxBodyBytes = options.maxBodyBytes ?? 8 * 1024;
|
||||
return async (request: Request): Promise<Response> => {
|
||||
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
|
||||
if (request.headers.get("sec-fetch-site") === "cross-site") {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin) {
|
||||
try {
|
||||
if (new URL(origin).origin !== new URL(request.url).origin) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
} catch {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
const length = Number(request.headers.get("content-length") ?? "0");
|
||||
if (length > maxBodyBytes) return new Response("Payload Too Large", { status: 413 });
|
||||
let body: unknown;
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (new TextEncoder().encode(text).byteLength > maxBodyBytes) {
|
||||
return new Response("Payload Too Large", { status: 413 });
|
||||
}
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
return new Response("Invalid JSON", { status: 400 });
|
||||
}
|
||||
if (!body || typeof body !== "object") return new Response("Invalid metric", { status: 400 });
|
||||
const raw = body as Record<string, unknown>;
|
||||
if (
|
||||
!VITAL_NAMES.has(String(raw.name)) ||
|
||||
typeof raw.value !== "number" ||
|
||||
!Number.isFinite(raw.value)
|
||||
) {
|
||||
return new Response("Invalid metric", { status: 400 });
|
||||
}
|
||||
const record: WebVitalRecord = {
|
||||
name: raw.name as WebVitalRecord["name"],
|
||||
value: raw.value,
|
||||
...(typeof raw.rating === "string" ? { rating: raw.rating as WebVitalRecord["rating"] } : {}),
|
||||
...(typeof raw.route === "string" ? { route: raw.route.slice(0, 256) } : {}),
|
||||
...(typeof raw.navigationType === "string"
|
||||
? { navigationType: raw.navigationType.slice(0, 64) }
|
||||
: {}),
|
||||
};
|
||||
registry.observe(`web.vitals.${record.name.toLowerCase()}`, record.value, {
|
||||
rating: record.rating ?? "unknown",
|
||||
route: record.route ?? "unknown",
|
||||
});
|
||||
await options.onRecord?.(record, request);
|
||||
return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
|
||||
};
|
||||
}
|
||||
|
||||
export interface MetricExporter {
|
||||
export(points: readonly MetricPoint[]): Promise<void> | void;
|
||||
}
|
||||
|
||||
export function createHttpMetricExporter(
|
||||
endpoint: string,
|
||||
options: { headers?: HeadersInit; fetch?: typeof fetch } = {},
|
||||
): MetricExporter {
|
||||
const send = options.fetch ?? fetch;
|
||||
return {
|
||||
async export(points) {
|
||||
const response = await send(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...Object.fromEntries(new Headers(options.headers)),
|
||||
},
|
||||
body: JSON.stringify({ resourceMetrics: points }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Metric export failed with ${response.status}.`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultMetrics = new MetricsRegistry();
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createWebVitalsHandler, MetricsRegistry } from "../src/index.ts";
|
||||
|
||||
describe("@wrnexus/observability", () => {
|
||||
test("records deterministic counters and histograms", () => {
|
||||
const registry = new MetricsRegistry(() => 123);
|
||||
registry.increment("requests", 1, { route: "/" });
|
||||
registry.increment("requests", 2, { route: "/" });
|
||||
registry.observe("duration", 10);
|
||||
registry.observe("duration", 20);
|
||||
expect(registry.snapshot()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "requests", value: 3 }),
|
||||
expect.objectContaining({ name: "duration", value: 15, count: 2, min: 10, max: 20 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts same-origin Web Vitals and rejects cross-site beacons", async () => {
|
||||
const handler = createWebVitalsHandler();
|
||||
const good = await handler(
|
||||
new Request("https://example.com/__wrnexus/metrics/vitals", {
|
||||
method: "POST",
|
||||
headers: { origin: "https://example.com", "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "LCP", value: 1200, route: "/" }),
|
||||
}),
|
||||
);
|
||||
expect(good.status).toBe(204);
|
||||
const denied = await handler(
|
||||
new Request("https://example.com/__wrnexus/metrics/vitals", {
|
||||
method: "POST",
|
||||
headers: { origin: "https://evil.example", "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "LCP", value: 1200 }),
|
||||
}),
|
||||
);
|
||||
expect(denied.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/plugin",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { redisDriver } from "../src/redis.ts";
|
||||
|
||||
const bun = globalThis.Bun as typeof Bun & { connect: (options: unknown) => Promise<unknown> };
|
||||
const bun = Bun as typeof Bun & { connect: (options: unknown) => Promise<unknown> };
|
||||
const originalConnect = bun.connect;
|
||||
afterEach(() => {
|
||||
bun.connect = originalConnect;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# @wrnexus/security
|
||||
|
||||
Secure-by-default utilities for WRNexusJS: bounded HTML-safe serialization, prototype-pollution rejection, URL policy, secure cookies, request hardening, security presets, and SSRF-safe remote fetches.
|
||||
|
||||
```ts
|
||||
import { safeFetch, securityPreset, setSecureCookie } from "@wrnexus/security";
|
||||
|
||||
export default { security: securityPreset("strict") };
|
||||
const response = await safeFetch(remoteUrl, { allowedHosts: ["api.example.com"] });
|
||||
setSecureCookie(ctx, "__Host-session", sessionId);
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@wrnexus/security",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"description": "Security policies, safe serialization, SSRF protection, request limits, and secure cookie helpers for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./fetch": "./src/fetch.ts",
|
||||
"./serialization": "./src/serialization.ts",
|
||||
"./trusted-html": "./src/trusted-html.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Context, CookieOptions } from "@wrnexus/core";
|
||||
import { SecurityError } from "./errors.ts";
|
||||
|
||||
export interface SecureCookieOptions extends CookieOptions {
|
||||
hostOnly?: boolean;
|
||||
}
|
||||
|
||||
export function secureCookieOptions(
|
||||
ctx: Pick<Context, "url">,
|
||||
options: SecureCookieOptions = {},
|
||||
): CookieOptions {
|
||||
const secure = options.secure ?? ctx.url.protocol === "https:";
|
||||
const sameSite = options.sameSite ?? "Lax";
|
||||
if (sameSite.toString().toLowerCase() === "none" && !secure) {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-COOKIE-SAMESITE",
|
||||
"SameSite=None cookies must also use Secure.",
|
||||
);
|
||||
}
|
||||
if (options.hostOnly && options.domain) {
|
||||
throw new SecurityError("WRN-SEC-COOKIE-HOST", "Host-only cookies cannot set Domain.");
|
||||
}
|
||||
return {
|
||||
path: options.path ?? "/",
|
||||
...options,
|
||||
domain: options.hostOnly ? undefined : options.domain,
|
||||
httpOnly: options.httpOnly ?? true,
|
||||
secure,
|
||||
sameSite,
|
||||
};
|
||||
}
|
||||
|
||||
export function setSecureCookie(
|
||||
ctx: Pick<Context, "url" | "cookies">,
|
||||
name: string,
|
||||
value: string,
|
||||
options: SecureCookieOptions = {},
|
||||
): void {
|
||||
if (name.startsWith("__Host-") && (options.domain || (options.path && options.path !== "/"))) {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-COOKIE-HOST-PREFIX",
|
||||
"__Host- cookies require Path=/ and must not set Domain.",
|
||||
);
|
||||
}
|
||||
ctx.cookies.set(
|
||||
name,
|
||||
value,
|
||||
secureCookieOptions(ctx, {
|
||||
...options,
|
||||
hostOnly: name.startsWith("__Host-") || options.hostOnly,
|
||||
path: name.startsWith("__Host-") ? "/" : options.path,
|
||||
secure: name.startsWith("__Host-") ? true : options.secure,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class SecurityError extends Error {
|
||||
readonly code: string;
|
||||
readonly status: number;
|
||||
|
||||
constructor(code: string, message: string, status = 400, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "SecurityError";
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import { SecurityError } from "./errors.ts";
|
||||
import { validateUrl, type SafeUrlPolicy } from "./url.ts";
|
||||
|
||||
export interface SafeFetchOptions extends RequestInit, SafeUrlPolicy {
|
||||
timeoutMs?: number;
|
||||
maxRedirects?: number;
|
||||
maxResponseBytes?: number;
|
||||
blockPrivateNetworks?: boolean;
|
||||
/** Forward Authorization, Cookie, and Proxy-Authorization across origin-changing redirects. Defaults to false. */
|
||||
forwardSensitiveHeaders?: boolean;
|
||||
resolver?: (hostname: string) => Promise<string[]>;
|
||||
}
|
||||
|
||||
function isPrivateIpv4(address: string): boolean {
|
||||
const parts = address.split(".").map(Number);
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = parts;
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b! >= 16 && b! <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 100 && b! >= 64 && b! <= 127) ||
|
||||
a! >= 224
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIpv6(address: string): boolean {
|
||||
const normalized = address.toLowerCase().split("%")[0]!;
|
||||
const mappedIpv4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(normalized)?.[1];
|
||||
if (mappedIpv4) return isPrivateIpv4(mappedIpv4);
|
||||
return (
|
||||
normalized === "::" ||
|
||||
normalized === "::1" ||
|
||||
normalized.startsWith("fc") ||
|
||||
normalized.startsWith("fd") ||
|
||||
normalized.startsWith("fe8") ||
|
||||
normalized.startsWith("fe9") ||
|
||||
normalized.startsWith("fea") ||
|
||||
normalized.startsWith("feb") ||
|
||||
normalized.startsWith("ff")
|
||||
);
|
||||
}
|
||||
|
||||
export function isPrivateAddress(address: string): boolean {
|
||||
const version = isIP(address);
|
||||
return version === 4 ? isPrivateIpv4(address) : version === 6 ? isPrivateIpv6(address) : false;
|
||||
}
|
||||
|
||||
async function defaultResolver(hostname: string): Promise<string[]> {
|
||||
if (isIP(hostname)) return [hostname];
|
||||
return (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address);
|
||||
}
|
||||
|
||||
async function assertPublicHost(url: URL, options: SafeFetchOptions): Promise<void> {
|
||||
if (options.blockPrivateNetworks === false) return;
|
||||
const addresses = await (options.resolver ?? defaultResolver)(url.hostname);
|
||||
if (!addresses.length) {
|
||||
throw new SecurityError("WRN-SEC-SSRF-DNS", `Host '${url.hostname}' did not resolve.`);
|
||||
}
|
||||
const blocked = addresses.find(isPrivateAddress);
|
||||
if (blocked) {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-SSRF-PRIVATE",
|
||||
`Host '${url.hostname}' resolves to blocked address '${blocked}'.`,
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function safeFetch(
|
||||
input: string | URL,
|
||||
options: SafeFetchOptions = {},
|
||||
): Promise<Response> {
|
||||
const timeoutMs = options.timeoutMs ?? 10_000;
|
||||
const maxRedirects = options.maxRedirects ?? 3;
|
||||
const maxResponseBytes = options.maxResponseBytes ?? 5 * 1024 * 1024;
|
||||
const controller = new AbortController();
|
||||
const externalSignal = options.signal;
|
||||
const abort = () => controller.abort(externalSignal?.reason);
|
||||
externalSignal?.addEventListener("abort", abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error("WRNexus safeFetch timeout")),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
const init: RequestInit = { ...options, signal: controller.signal, redirect: "manual" };
|
||||
delete (init as Record<string, unknown>).timeoutMs;
|
||||
delete (init as Record<string, unknown>).maxRedirects;
|
||||
delete (init as Record<string, unknown>).maxResponseBytes;
|
||||
delete (init as Record<string, unknown>).blockPrivateNetworks;
|
||||
delete (init as Record<string, unknown>).resolver;
|
||||
delete (init as Record<string, unknown>).forwardSensitiveHeaders;
|
||||
delete (init as Record<string, unknown>).base;
|
||||
delete (init as Record<string, unknown>).allowRelative;
|
||||
delete (init as Record<string, unknown>).allowedProtocols;
|
||||
delete (init as Record<string, unknown>).allowedHosts;
|
||||
delete (init as Record<string, unknown>).blockedHosts;
|
||||
delete (init as Record<string, unknown>).allowCredentials;
|
||||
delete (init as Record<string, unknown>).allowDataImages;
|
||||
|
||||
try {
|
||||
let current = validateUrl(input, {
|
||||
...options,
|
||||
allowRelative: false,
|
||||
allowedProtocols: options.allowedProtocols ?? ["https:"],
|
||||
});
|
||||
let previousOrigin = current.origin;
|
||||
for (let redirect = 0; ; redirect++) {
|
||||
await assertPublicHost(current, options);
|
||||
const requestInit: RequestInit = { ...init };
|
||||
if (!options.forwardSensitiveHeaders && current.origin !== previousOrigin) {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.delete("authorization");
|
||||
headers.delete("cookie");
|
||||
headers.delete("proxy-authorization");
|
||||
requestInit.headers = headers;
|
||||
}
|
||||
const response = await fetch(current, requestInit);
|
||||
if (response.status >= 300 && response.status < 400 && response.headers.has("location")) {
|
||||
if (redirect >= maxRedirects) {
|
||||
throw new SecurityError("WRN-SEC-SSRF-REDIRECT", "Too many redirects.", 502);
|
||||
}
|
||||
previousOrigin = current.origin;
|
||||
current = validateUrl(new URL(response.headers.get("location")!, current), {
|
||||
...options,
|
||||
allowRelative: false,
|
||||
allowedProtocols: options.allowedProtocols ?? ["https:"],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get("content-length") ?? "0");
|
||||
if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
|
||||
throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > maxResponseBytes) {
|
||||
throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502);
|
||||
}
|
||||
return new Response(bytes, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
externalSignal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export { SecurityError } from "./errors.ts";
|
||||
export { assertSafeObject, isDangerousObjectKey, safeMerge } from "./object.ts";
|
||||
export type { SafeObjectOptions } from "./object.ts";
|
||||
export { isSafeUrl, sanitizeUrl, validateUrl } from "./url.ts";
|
||||
export type { SafeUrlPolicy } from "./url.ts";
|
||||
export { secureJsonStringify, serializeForHtml } from "./serialization.ts";
|
||||
export type { SecureSerializeOptions } from "./serialization.ts";
|
||||
export { secureCookieOptions, setSecureCookie } from "./cookies.ts";
|
||||
export type { SecureCookieOptions } from "./cookies.ts";
|
||||
export { requestHardening } from "./request.ts";
|
||||
export type { RequestHardeningOptions } from "./request.ts";
|
||||
export { safeFetch, isPrivateAddress } from "./fetch.ts";
|
||||
export type { SafeFetchOptions } from "./fetch.ts";
|
||||
export { securityPreset } from "./presets.ts";
|
||||
export type { SecurityPreset } from "./presets.ts";
|
||||
|
||||
export { createTrustedHtml, isTrustedHtml, unwrapTrustedHtml } from "./trusted-html.ts";
|
||||
export type { TrustedHtmlPolicy, TrustedHtmlValue } from "./trusted-html.ts";
|
||||
@@ -0,0 +1,94 @@
|
||||
import { SecurityError } from "./errors.ts";
|
||||
|
||||
const DANGEROUS_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
export interface SafeObjectOptions {
|
||||
maxDepth?: number;
|
||||
maxKeys?: number;
|
||||
allowInstances?: boolean;
|
||||
}
|
||||
|
||||
export function isDangerousObjectKey(key: string): boolean {
|
||||
return DANGEROUS_KEYS.has(key);
|
||||
}
|
||||
|
||||
export function assertSafeObject(value: unknown, options: SafeObjectOptions = {}): void {
|
||||
const maxDepth = options.maxDepth ?? 32;
|
||||
const maxKeys = options.maxKeys ?? 10_000;
|
||||
const seen = new WeakSet<object>();
|
||||
let keyCount = 0;
|
||||
|
||||
const visit = (current: unknown, depth: number): void => {
|
||||
if (current === null || typeof current !== "object") return;
|
||||
if (depth > maxDepth) {
|
||||
throw new SecurityError("WRN-SEC-OBJECT-DEPTH", `Object depth exceeds ${maxDepth}.`, 413);
|
||||
}
|
||||
if (seen.has(current)) {
|
||||
throw new SecurityError("WRN-SEC-OBJECT-CYCLE", "Cyclic objects are not accepted.");
|
||||
}
|
||||
seen.add(current);
|
||||
|
||||
const proto = Object.getPrototypeOf(current);
|
||||
if (
|
||||
!options.allowInstances &&
|
||||
!Array.isArray(current) &&
|
||||
proto !== Object.prototype &&
|
||||
proto !== null &&
|
||||
!(current instanceof Date) &&
|
||||
!(current instanceof URL)
|
||||
) {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-OBJECT-PROTOTYPE",
|
||||
"Only plain objects, arrays, dates, and URLs are accepted.",
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(current)) {
|
||||
keyCount++;
|
||||
if (keyCount > maxKeys) {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-OBJECT-KEYS",
|
||||
`Object contains more than ${maxKeys} keys.`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
if (isDangerousObjectKey(key)) {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-PROTOTYPE-POLLUTION",
|
||||
`Dangerous object key '${key}' is not allowed.`,
|
||||
);
|
||||
}
|
||||
visit((current as Record<string, unknown>)[key], depth + 1);
|
||||
}
|
||||
seen.delete(current);
|
||||
};
|
||||
|
||||
visit(value, 0);
|
||||
}
|
||||
|
||||
export function safeMerge<T extends Record<string, unknown>>(
|
||||
target: T,
|
||||
...sources: Array<Record<string, unknown> | undefined | null>
|
||||
): T {
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
assertSafeObject(source);
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (isDangerousObjectKey(key)) continue;
|
||||
const existing = target[key];
|
||||
if (
|
||||
value &&
|
||||
existing &&
|
||||
typeof value === "object" &&
|
||||
typeof existing === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
!Array.isArray(existing)
|
||||
) {
|
||||
safeMerge(existing as Record<string, unknown>, value as Record<string, unknown>);
|
||||
} else {
|
||||
target[key as keyof T] = value as T[keyof T];
|
||||
}
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SecurityConfig } from "@wrnexus/core";
|
||||
|
||||
export type SecurityPreset = "balanced" | "strict" | "api";
|
||||
|
||||
export function securityPreset(preset: SecurityPreset = "balanced"): SecurityConfig {
|
||||
if (preset === "api") {
|
||||
return {
|
||||
contentSecurityPolicy: false,
|
||||
frameOptions: "DENY",
|
||||
crossOriginOpenerPolicy: "same-origin",
|
||||
referrerPolicy: "no-referrer",
|
||||
permissionsPolicy: {},
|
||||
extraHeaders: {
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (preset === "strict") {
|
||||
return {
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: true,
|
||||
directives: {
|
||||
"style-src": ["'self'"],
|
||||
"script-src": ["'self'"],
|
||||
"upgrade-insecure-requests": [],
|
||||
},
|
||||
},
|
||||
trustedTypes: { enabled: true, policyNames: ["wrnexus"], requireForScript: true },
|
||||
hsts: { enabled: true, maxAge: 63_072_000, includeSubDomains: true, preload: true },
|
||||
extraHeaders: {
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Origin-Agent-Cluster": "?1",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
contentSecurityPolicy: { useDefaults: true },
|
||||
trustedTypes: { enabled: true, requireForScript: true },
|
||||
extraHeaders: { "Cross-Origin-Resource-Policy": "same-origin" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Context, Middleware, RequestLimitsConfig } from "@wrnexus/core";
|
||||
|
||||
export type RequestHardeningOptions = RequestLimitsConfig;
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
function headerBytes(headers: Headers): { count: number; bytes: number } {
|
||||
let count = 0;
|
||||
let bytes = 0;
|
||||
headers.forEach((value, name) => {
|
||||
count++;
|
||||
bytes += new TextEncoder().encode(`${name}:${value}\r\n`).byteLength;
|
||||
});
|
||||
return { count, bytes };
|
||||
}
|
||||
|
||||
function hostAllowed(host: string, rules: string[]): boolean {
|
||||
const normalized = host.toLowerCase().split(":")[0]!;
|
||||
return rules.some((rule) => {
|
||||
const candidate = rule.toLowerCase();
|
||||
return candidate.startsWith("*.")
|
||||
? normalized.endsWith(candidate.slice(1))
|
||||
: normalized === candidate;
|
||||
});
|
||||
}
|
||||
|
||||
function reject(status: number, message: string): Response {
|
||||
return new Response(message, {
|
||||
status,
|
||||
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
export function requestHardening(options: RequestHardeningOptions = {}): Middleware {
|
||||
const maxUrlLength = options.maxUrlLength ?? 8_192;
|
||||
const maxHeaderCount = options.maxHeaderCount ?? 100;
|
||||
const maxHeaderBytes = options.maxHeaderBytes ?? 32 * 1024;
|
||||
const maxQueryParameters = options.maxQueryParameters ?? 200;
|
||||
const maxBodyBytes = options.maxBodyBytes ?? 10 * 1024 * 1024;
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
const maxConcurrent = options.maxConcurrent ?? 1_000;
|
||||
let concurrent = 0;
|
||||
|
||||
return async (ctx: Context, next) => {
|
||||
if (ctx.req.url.length > maxUrlLength) return reject(414, "URI Too Long");
|
||||
const measured = headerBytes(ctx.req.headers);
|
||||
if (measured.count > maxHeaderCount || measured.bytes > maxHeaderBytes) {
|
||||
return reject(431, "Request Header Fields Too Large");
|
||||
}
|
||||
if ([...ctx.url.searchParams].length > maxQueryParameters) {
|
||||
return reject(400, "Too many query parameters");
|
||||
}
|
||||
const contentLength = Number(ctx.req.headers.get("content-length") ?? "0");
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
||||
return reject(413, "Payload Too Large");
|
||||
}
|
||||
if (options.trustedHosts?.length) {
|
||||
const host = ctx.req.headers.get("host") ?? ctx.url.host;
|
||||
if (!hostAllowed(host, options.trustedHosts)) return reject(421, "Misdirected Request");
|
||||
}
|
||||
if (options.fetchMetadata !== false && !SAFE_METHODS.has(ctx.req.method.toUpperCase())) {
|
||||
const site = ctx.req.headers.get("sec-fetch-site");
|
||||
if (site === "cross-site") return reject(403, "Cross-site request denied");
|
||||
}
|
||||
if (concurrent >= maxConcurrent) return reject(503, "Server Busy");
|
||||
|
||||
concurrent++;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const timeout = new Promise<Response>((resolve) => {
|
||||
timer = setTimeout(() => resolve(reject(504, "Request Timeout")), timeoutMs);
|
||||
});
|
||||
return await Promise.race([Promise.resolve(next()), timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
concurrent--;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { SecurityError } from "./errors.ts";
|
||||
import { assertSafeObject, isDangerousObjectKey } from "./object.ts";
|
||||
|
||||
export interface SecureSerializeOptions {
|
||||
maxDepth?: number;
|
||||
maxKeys?: number;
|
||||
maxBytes?: number;
|
||||
redact?: RegExp | string[];
|
||||
redactedValue?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SECRET =
|
||||
/(?:password|passwd|secret|token|api[-_]?key|private[-_]?key|otp|authorization|cookie)/i;
|
||||
|
||||
function shouldRedact(key: string, rule: SecureSerializeOptions["redact"]): boolean {
|
||||
if (Array.isArray(rule)) return rule.includes(key);
|
||||
return (rule ?? DEFAULT_SECRET).test(key);
|
||||
}
|
||||
|
||||
export function secureJsonStringify(value: unknown, options: SecureSerializeOptions = {}): string {
|
||||
assertSafeObject(value, { maxDepth: options.maxDepth, maxKeys: options.maxKeys });
|
||||
const redactedValue = options.redactedValue ?? "[REDACTED]";
|
||||
|
||||
const json = JSON.stringify(value, function secureReplacer(key, current) {
|
||||
if (isDangerousObjectKey(key)) {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-PROTOTYPE-POLLUTION",
|
||||
`Dangerous key '${key}' is not allowed.`,
|
||||
);
|
||||
}
|
||||
if (key && shouldRedact(key, options.redact)) return redactedValue;
|
||||
if (typeof current === "bigint") return current.toString();
|
||||
if (typeof current === "function" || typeof current === "symbol") {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-SERIALIZE-TYPE",
|
||||
`Cannot serialize ${typeof current} values.`,
|
||||
);
|
||||
}
|
||||
return current;
|
||||
});
|
||||
|
||||
if (json === undefined) {
|
||||
throw new SecurityError("WRN-SEC-SERIALIZE-EMPTY", "Value cannot be serialized.");
|
||||
}
|
||||
|
||||
const safe = json
|
||||
.replace(/&/g, "\\u0026")
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/>/g, "\\u003e")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
const bytes = new TextEncoder().encode(safe).byteLength;
|
||||
const maxBytes = options.maxBytes ?? 256 * 1024;
|
||||
if (bytes > maxBytes) {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-SERIALIZE-SIZE",
|
||||
`Serialized payload exceeds ${maxBytes} bytes.`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
export function serializeForHtml(value: unknown, options: SecureSerializeOptions = {}): string {
|
||||
return secureJsonStringify(value, options);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { SecurityError } from "./errors.ts";
|
||||
|
||||
const TRUSTED_HTML = Symbol("wrnexus.trusted-html");
|
||||
|
||||
export interface TrustedHtmlPolicy {
|
||||
/** Stable policy name used in diagnostics and CSP/Trusted Types integration. */
|
||||
name: string;
|
||||
/** Application-supplied, reviewed sanitizer. It must return sanitized HTML. */
|
||||
sanitize(input: string): string;
|
||||
}
|
||||
|
||||
export interface TrustedHtmlValue {
|
||||
readonly policy: string;
|
||||
readonly value: string;
|
||||
readonly [TRUSTED_HTML]: true;
|
||||
}
|
||||
|
||||
export function createTrustedHtml(input: string, policy: TrustedHtmlPolicy): TrustedHtmlValue {
|
||||
if (!policy?.name?.trim() || typeof policy.sanitize !== "function") {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-TRUSTED-HTML-POLICY",
|
||||
"Trusted HTML requires a named sanitizer policy.",
|
||||
);
|
||||
}
|
||||
const value = policy.sanitize(String(input));
|
||||
if (typeof value !== "string") {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-TRUSTED-HTML-RESULT",
|
||||
`Trusted HTML policy '${policy.name}' must return a string.`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({ policy: policy.name.trim(), value, [TRUSTED_HTML]: true as const });
|
||||
}
|
||||
|
||||
export function isTrustedHtml(value: unknown): value is TrustedHtmlValue {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
(value as Partial<TrustedHtmlValue>)[TRUSTED_HTML] === true &&
|
||||
typeof (value as Partial<TrustedHtmlValue>).value === "string",
|
||||
);
|
||||
}
|
||||
|
||||
export function unwrapTrustedHtml(value: TrustedHtmlValue): string {
|
||||
if (!isTrustedHtml(value)) {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-TRUSTED-HTML-REQUIRED",
|
||||
"Raw HTML must be produced by createTrustedHtml().",
|
||||
);
|
||||
}
|
||||
return value.value;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { SecurityError } from "./errors.ts";
|
||||
|
||||
export interface SafeUrlPolicy {
|
||||
base?: string | URL;
|
||||
allowRelative?: boolean;
|
||||
allowedProtocols?: string[];
|
||||
allowedHosts?: string[];
|
||||
blockedHosts?: string[];
|
||||
allowCredentials?: boolean;
|
||||
allowDataImages?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PROTOCOLS = ["http:", "https:"];
|
||||
|
||||
function hasAsciiControlOrSpace(value: string): boolean {
|
||||
for (const character of value) {
|
||||
const code = character.charCodeAt(0);
|
||||
if (code <= 0x20 || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hostnameMatches(hostname: string, rule: string): boolean {
|
||||
const normalized = rule.toLowerCase().replace(/\.$/, "");
|
||||
const host = hostname.toLowerCase().replace(/\.$/, "");
|
||||
if (normalized.startsWith("*.")) {
|
||||
const suffix = normalized.slice(1);
|
||||
return host.endsWith(suffix) && host.length > suffix.length;
|
||||
}
|
||||
return host === normalized;
|
||||
}
|
||||
|
||||
export function validateUrl(value: string | URL, policy: SafeUrlPolicy = {}): URL {
|
||||
const raw = String(value);
|
||||
if (!raw || hasAsciiControlOrSpace(raw)) {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-URL-CONTROL",
|
||||
"URL contains whitespace or control characters.",
|
||||
);
|
||||
}
|
||||
|
||||
const isRelative = /^(?:\.{0,2}\/|\/|\?|#)/.test(raw);
|
||||
if (isRelative && policy.allowRelative === false) {
|
||||
throw new SecurityError("WRN-SEC-URL-RELATIVE", "Relative URLs are not allowed.");
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw, policy.base ?? "http://wrnexus.invalid");
|
||||
} catch (error) {
|
||||
throw new SecurityError("WRN-SEC-URL-INVALID", "Invalid URL.", 400, { cause: error });
|
||||
}
|
||||
|
||||
if (url.protocol === "data:") {
|
||||
if (policy.allowDataImages && /^data:image\/(?:png|gif|jpeg|webp|avif);/i.test(raw)) return url;
|
||||
throw new SecurityError("WRN-SEC-URL-DATA", "Data URLs are not allowed by this policy.");
|
||||
}
|
||||
|
||||
const protocols = policy.allowedProtocols ?? DEFAULT_PROTOCOLS;
|
||||
if (!protocols.includes(url.protocol)) {
|
||||
throw new SecurityError(
|
||||
"WRN-SEC-URL-PROTOCOL",
|
||||
`URL protocol '${url.protocol}' is not allowed.`,
|
||||
);
|
||||
}
|
||||
if (!policy.allowCredentials && (url.username || url.password)) {
|
||||
throw new SecurityError("WRN-SEC-URL-CREDENTIALS", "Credentials in URLs are not allowed.");
|
||||
}
|
||||
|
||||
if (policy.blockedHosts?.some((rule) => hostnameMatches(url.hostname, rule))) {
|
||||
throw new SecurityError("WRN-SEC-URL-BLOCKED-HOST", `Host '${url.hostname}' is blocked.`);
|
||||
}
|
||||
if (
|
||||
policy.allowedHosts?.length &&
|
||||
!policy.allowedHosts.some((rule) => hostnameMatches(url.hostname, rule))
|
||||
) {
|
||||
throw new SecurityError("WRN-SEC-URL-HOST", `Host '${url.hostname}' is not allowlisted.`);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function isSafeUrl(value: string | URL, policy: SafeUrlPolicy = {}): boolean {
|
||||
try {
|
||||
validateUrl(value, policy);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeUrl(value: unknown, policy: SafeUrlPolicy = {}): string {
|
||||
try {
|
||||
const raw = String(value ?? "");
|
||||
const url = validateUrl(raw, policy);
|
||||
if (/^(?:\.{0,2}\/|\/|\?|#)/.test(raw)) return raw;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return "about:blank";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
assertSafeObject,
|
||||
createTrustedHtml,
|
||||
isPrivateAddress,
|
||||
isTrustedHtml,
|
||||
secureJsonStringify,
|
||||
setSecureCookie,
|
||||
unwrapTrustedHtml,
|
||||
validateUrl,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("@wrnexus/security", () => {
|
||||
test("escapes HTML-significant JSON and redacts secrets", () => {
|
||||
const json = secureJsonStringify({ html: "</script><img>", token: "secret", count: 1 });
|
||||
expect(json).toContain("\\u003c/script\\u003e");
|
||||
expect(json).toContain("[REDACTED]");
|
||||
expect(json).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("rejects prototype-pollution keys and unsafe URL protocols", () => {
|
||||
const unsafe = JSON.parse('{"__proto__":{"admin":true}}');
|
||||
expect(() => assertSafeObject(unsafe)).toThrow();
|
||||
expect(() => validateUrl("javascript:alert(1)")).toThrow();
|
||||
});
|
||||
|
||||
test("identifies private IP ranges", () => {
|
||||
expect(isPrivateAddress("127.0.0.1")).toBe(true);
|
||||
expect(isPrivateAddress("10.1.2.3")).toBe(true);
|
||||
expect(isPrivateAddress("8.8.8.8")).toBe(false);
|
||||
});
|
||||
|
||||
test("enforces __Host cookie rules", () => {
|
||||
const writes: unknown[] = [];
|
||||
const ctx = {
|
||||
url: new URL("https://example.com"),
|
||||
cookies: { set: (...args: unknown[]) => writes.push(args) },
|
||||
} as any;
|
||||
setSecureCookie(ctx, "__Host-session", "value");
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]).toEqual([
|
||||
"__Host-session",
|
||||
"value",
|
||||
expect.objectContaining({ secure: true, httpOnly: true, path: "/", domain: undefined }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("accepts repeated references while still rejecting cycles", () => {
|
||||
const shared = { value: 1 };
|
||||
expect(() => assertSafeObject({ first: shared, second: shared })).not.toThrow();
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
expect(() => assertSafeObject(cyclic)).toThrow();
|
||||
});
|
||||
|
||||
test("trusted HTML requires an explicit sanitizer policy", () => {
|
||||
const value = createTrustedHtml('<p onclick="bad()">Hello</p>', {
|
||||
name: "test-policy",
|
||||
sanitize: (input) => input.replace(/\s+onclick="[^"]*"/g, ""),
|
||||
});
|
||||
expect(isTrustedHtml(value)).toBe(true);
|
||||
expect(unwrapTrustedHtml(value)).toBe("<p>Hello</p>");
|
||||
expect(() => unwrapTrustedHtml({ value: "<b>unsafe</b>" } as any)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/store": "workspace:*"
|
||||
"@wrnexus/store": "workspace:*",
|
||||
"@wrnexus/security": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { serializeForHtml } from "@wrnexus/security";
|
||||
import { createRequestStoreContainer } from "@wrnexus/store/server";
|
||||
import type { StoreContainer } from "@wrnexus/store";
|
||||
|
||||
@@ -19,6 +20,6 @@ export async function disposeRequestStores(request: object): Promise<void> {
|
||||
}
|
||||
|
||||
export function renderStoreHydration(container: StoreContainer, nonce?: string): string {
|
||||
const json = JSON.stringify(container.serialize()).replace(/</g, "\\u003c");
|
||||
const json = serializeForHtml(container.serialize());
|
||||
return `<script type="application/json" data-wrnexus-store-hydration${nonce ? ` nonce="${nonce}"` : ""}>${json}</script>`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/store",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -78,3 +78,76 @@ export function contrast(foreground: string, background: string): ContrastResult
|
||||
level: ratio >= 7 ? "aaa" : ratio >= 4.5 ? "aa" : ratio >= 3 ? "aa-large" : "fail",
|
||||
};
|
||||
}
|
||||
|
||||
export interface CssPerformanceAuditIssue {
|
||||
code: string;
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
function cssLineAt(source: string, offset: number): number {
|
||||
return source.slice(0, offset).split(/\r?\n/).length;
|
||||
}
|
||||
|
||||
/** Detect CSS patterns that commonly increase style, paint, or compositing cost. */
|
||||
export function auditCssPerformance(source: string): CssPerformanceAuditIssue[] {
|
||||
const issues: CssPerformanceAuditIssue[] = [];
|
||||
const checks: Array<{
|
||||
pattern: RegExp;
|
||||
code: string;
|
||||
severity: CssPerformanceAuditIssue["severity"];
|
||||
message: string;
|
||||
}> = [
|
||||
{
|
||||
pattern: /transition\s*:\s*all\b/gi,
|
||||
code: "WRN-CSS-TRANSITION-ALL",
|
||||
severity: "warning",
|
||||
message: "Avoid transition: all; list only properties that should animate.",
|
||||
},
|
||||
{
|
||||
pattern: /backdrop-filter\s*:\s*[^;]*(?:blur\((?:[3-9]\d|\d{3,})px\))/gi,
|
||||
code: "WRN-CSS-BACKDROP-BLUR",
|
||||
severity: "warning",
|
||||
message: "Large backdrop blur areas can be expensive to composite.",
|
||||
},
|
||||
{
|
||||
pattern: /box-shadow\s*:[^;]*(?:,\s*[^;]+){4,}/gi,
|
||||
code: "WRN-CSS-MANY-SHADOWS",
|
||||
severity: "warning",
|
||||
message: "Many layered shadows can increase paint cost.",
|
||||
},
|
||||
{
|
||||
pattern: /(?:^|[},])\s*\*\s*(?:[,{])/gm,
|
||||
code: "WRN-CSS-UNIVERSAL-SELECTOR",
|
||||
severity: "info",
|
||||
message: "Review universal selectors used in large DOM subtrees.",
|
||||
},
|
||||
];
|
||||
for (const check of checks) {
|
||||
for (const match of source.matchAll(check.pattern)) {
|
||||
issues.push({
|
||||
code: check.code,
|
||||
severity: check.severity,
|
||||
message: check.message,
|
||||
line: cssLineAt(source, match.index ?? 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const keyframes = new Map<string, number>();
|
||||
for (const match of source.matchAll(/@keyframes\s+([A-Za-z_][\w-]*)/g)) {
|
||||
const name = match[1]!;
|
||||
keyframes.set(name, (keyframes.get(name) ?? 0) + 1);
|
||||
}
|
||||
for (const [name, count] of keyframes) {
|
||||
if (count > 1) {
|
||||
issues.push({
|
||||
code: "WRN-CSS-DUPLICATE-KEYFRAMES",
|
||||
severity: "warning",
|
||||
message: `@keyframes ${name} is declared ${count} times.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -165,6 +165,10 @@ export interface ObservabilityConfig {
|
||||
sampleRate?: number;
|
||||
exporter?: "console" | "otlp" | "none";
|
||||
endpoint?: string;
|
||||
/** Collect privacy-preserving Core Web Vitals from real browsers. */
|
||||
webVitals?: boolean;
|
||||
/** Same-origin endpoint receiving Web Vitals. */
|
||||
webVitalsEndpoint?: string;
|
||||
}
|
||||
|
||||
export interface TenancyConfig {
|
||||
@@ -184,11 +188,13 @@ export interface BuildConfig {
|
||||
|
||||
export interface NavigationConfig {
|
||||
/**
|
||||
* `client` progressively enhances same-origin links with in-place page swaps.
|
||||
* `auto` (default) omits navigation JavaScript from fully static pages and
|
||||
* progressively enhances routes that already need browser behavior.
|
||||
* `client` always enhances same-origin links with in-place page swaps.
|
||||
* `document` keeps normal browser navigation so every route performs a fresh
|
||||
* server-rendered document request.
|
||||
*/
|
||||
mode?: "client" | "document";
|
||||
mode?: "auto" | "client" | "document";
|
||||
}
|
||||
|
||||
export interface ImportsConfig {
|
||||
|
||||
@@ -152,6 +152,12 @@ export {
|
||||
normalizeStyleSources,
|
||||
tailwindSourceDirectives,
|
||||
contrast,
|
||||
auditCssPerformance,
|
||||
} from "./audit.ts";
|
||||
export type {
|
||||
CssTokenAudit,
|
||||
StyleSource,
|
||||
ContrastResult,
|
||||
CssPerformanceAuditIssue,
|
||||
} from "./audit.ts";
|
||||
export type { CssTokenAudit, StyleSource, ContrastResult } from "./audit.ts";
|
||||
export * from "./theme.ts";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -32,6 +32,14 @@ export interface DiagnoseOptions {
|
||||
accessibility?: boolean;
|
||||
}
|
||||
|
||||
function stripAsciiControlAndSpace(value: string): string {
|
||||
let result = "";
|
||||
for (const character of value) {
|
||||
if (character.charCodeAt(0) > 0x20) result += character;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function maskJavaScriptTrivia(source: string): string {
|
||||
let result = "";
|
||||
let index = 0;
|
||||
@@ -222,19 +230,76 @@ function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[]
|
||||
}
|
||||
|
||||
let interactive = ast.states.length > 0 || ast.effects.length > 0 || ast.watches.length > 0;
|
||||
const urlAttributes = new Set([
|
||||
"href",
|
||||
"src",
|
||||
"action",
|
||||
"formaction",
|
||||
"poster",
|
||||
"cite",
|
||||
"background",
|
||||
"xlink:href",
|
||||
]);
|
||||
walk(ast.view, (node) => {
|
||||
if (node.type === "element" && node.attrs.some((attribute) => attribute.event))
|
||||
interactive = true;
|
||||
if (!options.accessibility || node.type !== "element") return;
|
||||
if (node.type !== "element") return;
|
||||
if (node.attrs.some((attribute) => attribute.event)) interactive = true;
|
||||
const tag = node.tag.toLowerCase();
|
||||
if (tag === "img" && !node.attrs.some((attribute) => attribute.name === "alt")) {
|
||||
diagnostics.push({
|
||||
code: WRN_DIAGNOSTIC_CODES.accessibility,
|
||||
severity: "warning",
|
||||
message: "Image is missing an alt attribute.",
|
||||
hint: 'Add alt text, or alt="" for a decorative image.',
|
||||
file: options.file,
|
||||
});
|
||||
|
||||
for (const attribute of node.attrs) {
|
||||
if (attribute.event || attribute.boolean || !urlAttributes.has(attribute.name.toLowerCase()))
|
||||
continue;
|
||||
if (attribute.value.includes("{")) continue;
|
||||
const value = stripAsciiControlAndSpace(attribute.value.trim()).toLowerCase();
|
||||
if (
|
||||
/^(?:javascript|vbscript|file):/.test(value) ||
|
||||
/^data:(?!image\/(?:png|gif|jpeg|webp|avif);)/.test(value)
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SEC-UNSAFE-URL",
|
||||
severity: "error",
|
||||
message: `Unsafe URL protocol in ${attribute.name} on <${node.tag}>.`,
|
||||
hint: "Use a relative URL, https:, mailto:, tel:, or a framework-validated URL helper.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tag === "a") {
|
||||
const target = node.attrs.find((attribute) => attribute.name === "target")?.value;
|
||||
const rel = node.attrs.find((attribute) => attribute.name === "rel")?.value ?? "";
|
||||
if (target === "_blank" && !/\bnoopener\b/i.test(rel)) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SEC-BLANK-REL",
|
||||
severity: "warning",
|
||||
message: "A target=_blank link should include rel=noopener.",
|
||||
hint: 'Add rel="noopener noreferrer".',
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.accessibility) return;
|
||||
if (tag === "img") {
|
||||
if (!node.attrs.some((attribute) => attribute.name === "alt")) {
|
||||
diagnostics.push({
|
||||
code: WRN_DIAGNOSTIC_CODES.accessibility,
|
||||
severity: "warning",
|
||||
message: "Image is missing an alt attribute.",
|
||||
hint: 'Add alt text, or alt="" for a decorative image.',
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
const hasWidth = node.attrs.some((attribute) => attribute.name === "width");
|
||||
const hasHeight = node.attrs.some((attribute) => attribute.name === "height");
|
||||
if (!hasWidth || !hasHeight) {
|
||||
diagnostics.push({
|
||||
code: "WRN-PERF-IMAGE-DIMENSIONS",
|
||||
severity: "warning",
|
||||
message: "Image width and height are required to prevent layout shifts.",
|
||||
hint: "Declare intrinsic width and height, or use @wrnexus/image.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
if (ast.runtime === "server" && interactive) {
|
||||
@@ -291,6 +356,29 @@ function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[]
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
if (
|
||||
fn.runtime !== "server" &&
|
||||
/\b(?:eval\s*\(|new\s+Function\s*\(|document\.write\s*\(|\.innerHTML\s*=|\.outerHTML\s*=|insertAdjacentHTML\s*\()/.test(
|
||||
fn.body,
|
||||
)
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SEC-DOM-SINK",
|
||||
severity: "error",
|
||||
message: `Client function '${fn.name}' uses an unsafe dynamic-code or HTML sink.`,
|
||||
hint: "Use compiled templates, textContent, typed outputs, or a reviewed TrustedHTML sanitizer.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
if (fn.runtime !== "server" && /\b(?:setTimeout|setInterval)\s*\(\s*["'`]/.test(fn.body)) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SEC-STRING-TIMER",
|
||||
severity: "error",
|
||||
message: `Client function '${fn.name}' passes a string to a timer.`,
|
||||
hint: "Pass a function instead of executable text.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
|
||||
for (const prop of ast.props) {
|
||||
if (containsReadonlyPropMutation(fn.body, prop.name, parameterNames)) {
|
||||
@@ -317,6 +405,18 @@ function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[]
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
if (
|
||||
state.runtime !== "server" &&
|
||||
/\b(?:process\.env|Bun\.env|Deno\.env|ctx\.env|import\.meta\.env)\b/.test(state.expr)
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SEC-SERVER-SECRET-SOURCE",
|
||||
severity: "error",
|
||||
message: `Browser-visible state '${state.name}' reads from a server environment source.`,
|
||||
hint: "Move environment-backed values into server state and return only an explicitly safe result.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (ast.persist) {
|
||||
const stateNames = new Set(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/typecheck",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user