release: WRNexusJS 0.7.0

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