release: WRNexusJS 0.7.0
This commit is contained in:
@@ -6,13 +6,13 @@ export const performanceRules: DevToolbarRule[] = [
|
||||
id: "performance/resources",
|
||||
category: "performance",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks resource count and transfer sizes.",
|
||||
run: ({ performanceEntries }) => {
|
||||
description: "Checks resource, DOM, hydration, and main-thread budgets.",
|
||||
run: ({ document, root, performanceEntries }) => {
|
||||
const resources = performanceEntries.filter(
|
||||
(entry): entry is PerformanceResourceTiming => entry.entryType === "resource",
|
||||
);
|
||||
const issues = [];
|
||||
if (resources.length > 150)
|
||||
if (resources.length > 150) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/resource-count",
|
||||
@@ -24,8 +24,9 @@ export const performanceRules: DevToolbarRule[] = [
|
||||
"Remove duplicates, combine tiny assets where useful, and load non-critical resources later.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const total = resources.reduce((sum, entry) => sum + (entry.transferSize || 0), 0);
|
||||
if (total > 5_000_000)
|
||||
if (total > 5_000_000) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/transfer-size",
|
||||
@@ -37,7 +38,26 @@ export const performanceRules: DevToolbarRule[] = [
|
||||
"Compress images, scripts, styles and fonts; review third-party resources.",
|
||||
}),
|
||||
);
|
||||
for (const entry of resources.filter((item) => item.duration > 2000).slice(0, 20))
|
||||
}
|
||||
|
||||
const javascriptBytes = resources
|
||||
.filter((entry) => /(?:\.m?js)(?:\?|$)/i.test(entry.name))
|
||||
.reduce((sum, entry) => sum + (entry.transferSize || 0), 0);
|
||||
if (javascriptBytes > 150_000) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/javascript-budget",
|
||||
category: "javascript",
|
||||
severity: javascriptBytes > 300_000 ? "error" : "warning",
|
||||
title: "JavaScript budget exceeded",
|
||||
message: `JavaScript transfer is approximately ${(javascriptBytes / 1_000).toFixed(1)} KB.`,
|
||||
recommendation:
|
||||
"Split routes, remove unused client code, and defer optional hydration.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const entry of resources.filter((item) => item.duration > 2_000).slice(0, 20)) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/slow-resource",
|
||||
@@ -49,6 +69,90 @@ export const performanceRules: DevToolbarRule[] = [
|
||||
metadata: { url: entry.name, duration: entry.duration },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const domNodes = root.querySelectorAll("*").length;
|
||||
if (domNodes > 1_500) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/dom-size",
|
||||
category: "performance",
|
||||
severity: domNodes > 3_000 ? "error" : "warning",
|
||||
title: "DOM is large",
|
||||
message: `The page contains ${domNodes} elements.`,
|
||||
recommendation:
|
||||
"Virtualize long lists and avoid rendering hidden or duplicate structures.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const hydrationRoots = root.querySelectorAll("[data-wrn-client-module], [data-wrn-hydrate]");
|
||||
if (hydrationRoots.length > 50) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/hydration-count",
|
||||
category: "runtime",
|
||||
severity: "warning",
|
||||
title: "Many components hydrate",
|
||||
message: `${hydrationRoots.length} hydration boundaries were found.`,
|
||||
recommendation:
|
||||
"Use visible, idle, or interaction hydration and keep static components server-only.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const image of root.querySelectorAll<HTMLImageElement>("img")) {
|
||||
if (!image.complete || !image.naturalWidth) continue;
|
||||
const renderedWidth = Math.max(1, image.getBoundingClientRect().width);
|
||||
if (image.naturalWidth > renderedWidth * 2.5) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/oversized-image",
|
||||
category: "images",
|
||||
severity: "warning",
|
||||
title: "Image is larger than rendered size",
|
||||
message: `${image.naturalWidth}px image is rendered at about ${Math.round(renderedWidth)}px.`,
|
||||
element: image,
|
||||
recommendation: "Generate responsive srcset candidates and accurate sizes.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const blocking = document.querySelectorAll(
|
||||
'head script:not([async]):not([defer]):not([type="module"]), head link[rel="stylesheet"]:not([media])',
|
||||
);
|
||||
if (blocking.length > 4) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/render-blocking",
|
||||
category: "performance",
|
||||
severity: "warning",
|
||||
title: "Multiple render-blocking resources",
|
||||
message: `${blocking.length} potentially render-blocking resources were found.`,
|
||||
recommendation: "Inline only critical CSS and defer non-critical scripts and styles.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const longTasks = performanceEntries.filter(
|
||||
(entry) => entry.entryType === "longtask" && entry.duration > 50,
|
||||
);
|
||||
if (longTasks.length) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "performance/long-tasks",
|
||||
category: "javascript",
|
||||
severity: "warning",
|
||||
title: "Long main-thread tasks detected",
|
||||
message: `${longTasks.length} task(s) exceeded 50 ms.`,
|
||||
recommendation:
|
||||
"Split expensive work, reduce hydration, and move non-UI work off the main thread.",
|
||||
metadata: { longestMs: Math.max(...longTasks.map((entry) => entry.duration)) },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { DevToolbarRule } from "./types.ts";
|
||||
import { createIssue } from "./helpers.ts";
|
||||
|
||||
const SECRET_KEY = /pass(word)?|token|secret|api[-_]?key|authorization|session/i;
|
||||
|
||||
export const securityRules: DevToolbarRule[] = [
|
||||
{
|
||||
id: "security/page",
|
||||
category: "security",
|
||||
defaultSeverity: "warning",
|
||||
description: "Checks development-visible security mistakes.",
|
||||
run: ({ url, root }) => {
|
||||
run: ({ document, window, url, root }) => {
|
||||
const issues = [];
|
||||
for (const [key] of url.searchParams)
|
||||
if (/pass(word)?|token|secret|api[-_]?key/i.test(key))
|
||||
for (const [key] of url.searchParams) {
|
||||
if (SECRET_KEY.test(key)) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/secret-query",
|
||||
@@ -22,7 +24,10 @@ export const securityRules: DevToolbarRule[] = [
|
||||
"Send secrets in a secure request body or authorization header, not a URL.",
|
||||
}),
|
||||
);
|
||||
for (const form of root.querySelectorAll<HTMLFormElement>('form[action^="http://"]'))
|
||||
}
|
||||
}
|
||||
|
||||
for (const form of root.querySelectorAll<HTMLFormElement>('form[action^="http://"]')) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/insecure-form",
|
||||
@@ -34,6 +39,133 @@ export const securityRules: DevToolbarRule[] = [
|
||||
recommendation: "Submit to an HTTPS endpoint.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const form of root.querySelectorAll<HTMLFormElement>("form")) {
|
||||
const method = (form.method || "get").toUpperCase();
|
||||
if (
|
||||
!["GET", "HEAD"].includes(method) &&
|
||||
!form.querySelector('input[name="wire-csrf"], input[name="_csrf"]')
|
||||
) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/missing-csrf",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "State-changing form has no CSRF token",
|
||||
message: `${method} form does not contain a recognized CSRF field.`,
|
||||
element: form,
|
||||
recommendation: "Enable WRNexus CSRF middleware and use the generated form token.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const script of root.querySelectorAll<HTMLScriptElement>("script:not([src])")) {
|
||||
if (script.type === "application/json" || script.hasAttribute("nonce")) continue;
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/inline-script",
|
||||
category: "security",
|
||||
severity: "warning",
|
||||
title: "Inline script has no CSP nonce",
|
||||
message: "A strict Content Security Policy will block this inline script.",
|
||||
element: script,
|
||||
recommendation: "Move code into a client module or attach the request CSP nonce.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const element of root.querySelectorAll<HTMLElement>("[style]")) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/inline-style",
|
||||
category: "security",
|
||||
severity: "suggestion",
|
||||
title: "Inline style weakens strict CSP",
|
||||
message: "The element uses a style attribute.",
|
||||
element,
|
||||
recommendation:
|
||||
"Prefer extracted CSS classes or a nonce/hash-compatible style strategy.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const element of root.querySelectorAll<HTMLElement>("[src], [href], [action]")) {
|
||||
const raw =
|
||||
element.getAttribute("src") ??
|
||||
element.getAttribute("href") ??
|
||||
element.getAttribute("action");
|
||||
if (window.location.protocol === "https:" && raw?.startsWith("http://")) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/mixed-content",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "Mixed-content resource",
|
||||
message: `${raw} is loaded over insecure HTTP.`,
|
||||
element,
|
||||
recommendation: "Use HTTPS or serve the resource from the same secure origin.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const frame of root.querySelectorAll<HTMLIFrameElement>("iframe:not([sandbox])")) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/iframe-sandbox",
|
||||
category: "security",
|
||||
severity: "warning",
|
||||
title: "Iframe is not sandboxed",
|
||||
message: "Third-party or untrusted iframe content has broad browser capabilities.",
|
||||
element: frame,
|
||||
recommendation: "Add the narrowest possible sandbox and permissions policy.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index) ?? "";
|
||||
if (SECRET_KEY.test(key)) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/sensitive-local-storage",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "Sensitive value may be stored in localStorage",
|
||||
message: `Storage key “${key}” looks authentication- or secret-related.`,
|
||||
recommendation: "Keep sessions and credentials in Secure, HttpOnly cookies.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Storage may be unavailable in restricted browser contexts.
|
||||
}
|
||||
|
||||
const hydration = document.querySelectorAll<HTMLScriptElement>(
|
||||
'script[type="application/json"][data-wrnexus-state], script[data-wrnexus-store-state]',
|
||||
);
|
||||
for (const script of hydration) {
|
||||
if (SECRET_KEY.test(script.textContent ?? "")) {
|
||||
issues.push(
|
||||
createIssue({
|
||||
ruleId: "security/hydration-secret",
|
||||
category: "security",
|
||||
severity: "error",
|
||||
title: "Hydration payload may contain a secret",
|
||||
message: "Sensitive-looking field names were found in serialized client state.",
|
||||
element: script,
|
||||
recommendation: "Move sensitive state to server-only state and regenerate the page.",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user