release: WRNexusJS 0.8.0
This commit is contained in:
@@ -7,6 +7,9 @@ Development-only page quality toolbar for WRNexusJS.
|
||||
- Runtime, resource and unhandled promise error capture
|
||||
- Accessibility, SEO, image, media, color, HTML, form, link, responsive and security checks
|
||||
- Performance and network observations
|
||||
- First-class application tabs for runtime, stores, cache, accessibility, SEO, performance,
|
||||
security, images, links, and JavaScript
|
||||
- Plugin-contributed applications with badges, descriptions, issue feeds, and structured data
|
||||
- Element highlighting and issue filtering
|
||||
- Server-side issue collector
|
||||
- Development-only asset strings for direct serving by `@wrnexus/dev-server`
|
||||
@@ -21,3 +24,7 @@ Serve `DEV_TOOLBAR_RUNTIME` at `/__wrnexus/dev-toolbar.js` and `DEV_TOOLBAR_CSS`
|
||||
```
|
||||
|
||||
The browser runtime exposes `window.__wrnexusDevToolbar`.
|
||||
|
||||
Plugin panels returned through `devToolbarPanels()` are automatically added to the application
|
||||
strip. Their issue category is filterable, and structured `data` is rendered as escaped diagnostic
|
||||
content so a plugin never needs to inject toolbar HTML.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -19,7 +19,7 @@
|
||||
"check": "bun run typecheck && bun run test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
|
||||
fetch("/__wrnexus/dev-toolbar.css").then(r => r.ok ? r.text() : "").then(css => style.textContent = css).catch(() => {});
|
||||
root.appendChild(style);
|
||||
const shell = document.createElement("div"); shell.className="wrn-shell"; shell.dataset.position=state.config.position;
|
||||
shell.innerHTML='<section class="wrn-panel"><header class="wrn-panel-head"><div><div class="wrn-panel-title">WRNexus DevToolbar</div><div class="wrn-panel-meta"></div></div><button class="wrn-small" data-close>Close</button></header><div class="wrn-toolbar-row"><input class="wrn-search" placeholder="Search issues, rules, files…"><button class="wrn-small" data-category="all">Issues</button><button class="wrn-small" data-category="runtime">Runtime</button><button class="wrn-small" data-category="stores">Stores</button><button class="wrn-small" data-scan>Rescan</button><button class="wrn-small" data-clear>Clear</button></div><div class="wrn-list"></div></section><nav class="wrn-bar"><span class="wrn-brand">WRNexus</span><button class="wrn-count" data-filter="error"><span class="wrn-dot error"></span><span data-errors>0</span></button><button class="wrn-count" data-filter="warning"><span class="wrn-dot warning"></span><span data-warnings>0</span></button><button class="wrn-count" data-filter="suggestion"><span class="wrn-dot suggestion"></span><span data-suggestions>0</span></button><button class="wrn-button" data-toggle>Inspect</button><button class="wrn-button" data-scan>↻</button></nav>';
|
||||
shell.innerHTML='<section class="wrn-panel"><header class="wrn-panel-head"><div><div class="wrn-panel-title">WRNexus DevToolbar</div><div class="wrn-panel-meta"></div></div><button class="wrn-small" data-close>Close</button></header><div class="wrn-toolbar-row"><input class="wrn-search" placeholder="Search issues, rules, files…"><span class="wrn-apps"><button class="wrn-small" data-category="all">Issues</button><button class="wrn-small" data-category="runtime">Runtime</button><button class="wrn-small" data-category="stores">Stores</button><button class="wrn-small" data-category="cache">Cache</button><button class="wrn-small" data-category="accessibility">A11y</button><button class="wrn-small" data-category="seo">SEO</button><button class="wrn-small" data-category="performance">Performance</button><button class="wrn-small" data-category="security">Security</button><button class="wrn-small" data-category="images">Images</button><button class="wrn-small" data-category="links">Links</button><button class="wrn-small" data-category="javascript">JavaScript</button></span><button class="wrn-small" data-scan>Rescan</button><button class="wrn-small" data-clear>Clear</button></div><div class="wrn-list"></div></section><nav class="wrn-bar"><span class="wrn-brand">WRNexus</span><button class="wrn-count" data-filter="error"><span class="wrn-dot error"></span><span data-errors>0</span></button><button class="wrn-count" data-filter="warning"><span class="wrn-dot warning"></span><span data-warnings>0</span></button><button class="wrn-count" data-filter="suggestion"><span class="wrn-dot suggestion"></span><span data-suggestions>0</span></button><button class="wrn-button" data-toggle>Inspect</button><button class="wrn-button" data-scan>↻</button></nav>';
|
||||
root.appendChild(shell);
|
||||
const panel=root.querySelector(".wrn-panel"), list=root.querySelector(".wrn-list"), meta=root.querySelector(".wrn-panel-meta"), search=root.querySelector(".wrn-search");
|
||||
const esc = value => String(value ?? "").replace(/[&<>\"']/g, char => ({"&":"&","<":"<",">":">",'"':""","'":"'"})[char]);
|
||||
@@ -44,12 +44,22 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
|
||||
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);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)}));
|
||||
let checkedSelectors=0,unusedSelectors=0;for(const sheet of [...document.styleSheets]){let rules;try{rules=[...(sheet.cssRules||[])]}catch{continue}for(const rule of rules){if(checkedSelectors>=2000)break;const selector=rule.selectorText;if(!selector||selector.includes(":"))continue;checkedSelectors++;try{if(!document.querySelector(selector))unusedSelectors++}catch{}}}if(unusedSelectors)found.push(issue("css/unused-selectors","css","suggestion","Potentially unused CSS",unusedSelectors+" of "+checkedSelectors+" inspected selectors do not match this page.",null,"Review across routes before removing selectors.","medium",{checkedSelectors,unusedSelectors}));const memory=performance.memory;if(memory&&memory.jsHeapSizeLimit&&memory.usedJSHeapSize/memory.jsHeapSizeLimit>.8)found.push(issue("performance/memory-pressure","performance","warning","High JavaScript heap usage",Math.round(memory.usedJSHeapSize/1048576)+" MiB of "+Math.round(memory.jsHeapSizeLimit/1048576)+" MiB is in use.",null,"Inspect retained objects and repeated hydration."));
|
||||
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()];
|
||||
try{
|
||||
const inspection=await fetch("/__wrnexus/cache",{headers:{accept:"application/json"}}).then(response=>response.ok?response.json():null);
|
||||
if(inspection&&inspection.layers){
|
||||
for(const name of ["data","component","page"]){
|
||||
const entries=inspection.layers[name]||[];
|
||||
found.push(issue("cache/layer","cache","info",name+" cache",entries.length+" entries · "+entries.filter(entry=>entry.state==="fresh").length+" fresh · "+entries.filter(entry=>entry.state==="stale").length+" stale",null,"Use tags for related invalidation and vary by tenant, user, or language when needed.","high",{layer:name,entries}));
|
||||
}
|
||||
}
|
||||
}catch{}
|
||||
const unique=new Map();[...state.issues.filter(x=>["runtime","stores","cache","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,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)};
|
||||
@@ -64,7 +74,7 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
|
||||
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(()=>{});
|
||||
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||[];const apps=root.querySelector(".wrn-apps");const existing=new Set([...apps.querySelectorAll("[data-category]")].map(button=>button.dataset.category));for(const app of state.panels){if(!existing.has(app.id)){const button=document.createElement("button");button.className="wrn-small";button.dataset.category=app.id;button.textContent=app.title+(app.badge!==undefined?" ("+app.badge+")":"");button.title=app.description||app.title;apps.appendChild(button);}if(app.data!==undefined||app.issues?.length){const payload=app.data!==undefined?app.data:app.issues;state.issues.push(issue("plugin/"+app.id,app.id,"info",app.title,JSON.stringify(payload,null,2),null,app.description||"Plugin-provided development information.","high",{panel:app.id,data:payload}));}}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"]});
|
||||
setTimeout(scan,100);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const DEV_TOOLBAR_CSS = String.raw`
|
||||
:host{all:initial;color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--bg:#111318;--panel:#181b22;--line:#2b303b;--text:#f4f6fb;--muted:#9ca6b8;--error:#ff6b6b;--warning:#ffc857;--info:#72a7ff;--suggestion:#a78bfa}
|
||||
*{box-sizing:border-box}.wrn-shell{position:fixed;z-index:2147483646;bottom:16px;left:50%;transform:translateX(-50%);color:var(--text);font-size:13px;line-height:1.4}.wrn-shell[data-position="bottom-left"]{left:16px;transform:none}.wrn-shell[data-position="bottom-right"]{left:auto;right:16px;transform:none}.wrn-bar{display:flex;align-items:center;gap:6px;padding:7px;border:1px solid var(--line);border-radius:14px;background:rgba(17,19,24,.96);box-shadow:0 18px 60px rgba(0,0,0,.42);backdrop-filter:blur(18px)}button{appearance:none;border:0;font:inherit}.wrn-button,.wrn-count{display:inline-flex;align-items:center;justify-content:center;min-height:32px;border-radius:9px;padding:0 10px;background:#222631;color:var(--text);cursor:pointer}.wrn-button:hover,.wrn-count:hover{background:#2b303c}.wrn-count.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.wrn-button:focus-visible,.wrn-count:focus-visible{outline:2px solid #72a7ff;outline-offset:2px}.wrn-brand{font-weight:800;letter-spacing:-.02em;padding:0 8px}.wrn-count{gap:5px;font-variant-numeric:tabular-nums}.wrn-dot{width:7px;height:7px;border-radius:999px}.wrn-dot.error{background:var(--error)}.wrn-dot.warning{background:var(--warning)}.wrn-dot.suggestion{background:var(--suggestion)}.wrn-panel{position:absolute;bottom:50px;left:50%;transform:translateX(-50%);width:min(780px,calc(100vw - 24px));height:min(620px,calc(100vh - 100px));display:none;overflow:hidden;border:1px solid var(--line);border-radius:16px;background:var(--bg);box-shadow:0 24px 80px rgba(0,0,0,.5)}.wrn-panel.open{display:grid;grid-template-rows:auto auto 1fr}.wrn-panel-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)}.wrn-panel-title{font-size:15px;font-weight:800}.wrn-panel-meta{color:var(--muted);font-size:12px}.wrn-toolbar-row{display:flex;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line)}.wrn-search{width:100%;height:34px;border:1px solid var(--line);border-radius:9px;background:#20242d;color:var(--text);padding:0 10px;outline:none}.wrn-search:focus{border-color:#72a7ff}.wrn-list{overflow:auto;padding:10px}.wrn-empty{display:grid;place-items:center;height:100%;color:var(--muted);text-align:center;padding:40px}.wrn-issue{display:grid;grid-template-columns:8px 1fr auto;gap:10px;padding:12px;margin-bottom:8px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.wrn-severity{border-radius:999px;background:var(--info)}.wrn-severity.error{background:var(--error)}.wrn-severity.warning{background:var(--warning)}.wrn-severity.suggestion{background:var(--suggestion)}.wrn-issue-title{font-weight:750}.wrn-issue-message{margin-top:4px;color:#c8cfdb}.wrn-issue-meta{margin-top:7px;color:var(--muted);font-size:11px}.wrn-actions{display:flex;gap:5px}.wrn-small{height:28px;padding:0 8px;border-radius:7px;background:#252a35;color:var(--text);cursor:pointer}.wrn-small:hover{background:#303644}.wrn-small.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.wrn-highlight{position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #72a7ff;background:rgba(114,167,255,.12);box-shadow:0 0 0 99999px rgba(0,0,0,.08)}@media(max-width:640px){.wrn-shell{bottom:8px}.wrn-brand{display:none}.wrn-bar{gap:3px;padding:5px}.wrn-count{padding:0 7px}.wrn-panel{bottom:44px;height:calc(100vh - 62px)}.wrn-issue{grid-template-columns:6px 1fr}.wrn-actions{grid-column:2}.wrn-panel-meta{display:none}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
|
||||
*{box-sizing:border-box}.wrn-shell{position:fixed;z-index:2147483646;bottom:16px;left:50%;transform:translateX(-50%);color:var(--text);font-size:13px;line-height:1.4}.wrn-shell[data-position="bottom-left"]{left:16px;transform:none}.wrn-shell[data-position="bottom-right"]{left:auto;right:16px;transform:none}.wrn-bar{display:flex;align-items:center;gap:6px;padding:7px;border:1px solid var(--line);border-radius:14px;background:rgba(17,19,24,.96);box-shadow:0 18px 60px rgba(0,0,0,.42);backdrop-filter:blur(18px)}button{appearance:none;border:0;font:inherit}.wrn-button,.wrn-count{display:inline-flex;align-items:center;justify-content:center;min-height:32px;border-radius:9px;padding:0 10px;background:#222631;color:var(--text);cursor:pointer}.wrn-button:hover,.wrn-count:hover{background:#2b303c}.wrn-count.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.wrn-button:focus-visible,.wrn-count:focus-visible{outline:2px solid #72a7ff;outline-offset:2px}.wrn-brand{font-weight:800;letter-spacing:-.02em;padding:0 8px}.wrn-count{gap:5px;font-variant-numeric:tabular-nums}.wrn-dot{width:7px;height:7px;border-radius:999px}.wrn-dot.error{background:var(--error)}.wrn-dot.warning{background:var(--warning)}.wrn-dot.suggestion{background:var(--suggestion)}.wrn-panel{position:absolute;bottom:50px;left:50%;transform:translateX(-50%);width:min(900px,calc(100vw - 24px));height:min(620px,calc(100vh - 100px));display:none;overflow:hidden;border:1px solid var(--line);border-radius:16px;background:var(--bg);box-shadow:0 24px 80px rgba(0,0,0,.5)}.wrn-panel.open{display:grid;grid-template-rows:auto auto 1fr}.wrn-panel-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)}.wrn-panel-title{font-size:15px;font-weight:800}.wrn-panel-meta{color:var(--muted);font-size:12px}.wrn-toolbar-row{display:flex;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);overflow-x:auto}.wrn-apps{display:flex;gap:5px;flex:0 0 auto}.wrn-search{min-width:180px;width:100%;height:34px;border:1px solid var(--line);border-radius:9px;background:#20242d;color:var(--text);padding:0 10px;outline:none}.wrn-search:focus{border-color:#72a7ff}.wrn-list{overflow:auto;padding:10px}.wrn-empty{display:grid;place-items:center;height:100%;color:var(--muted);text-align:center;padding:40px}.wrn-issue{display:grid;grid-template-columns:8px 1fr auto;gap:10px;padding:12px;margin-bottom:8px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.wrn-severity{border-radius:999px;background:var(--info)}.wrn-severity.error{background:var(--error)}.wrn-severity.warning{background:var(--warning)}.wrn-severity.suggestion{background:var(--suggestion)}.wrn-issue-title{font-weight:750}.wrn-issue-message{margin-top:4px;color:#c8cfdb;white-space:pre-wrap}.wrn-issue-meta{margin-top:7px;color:var(--muted);font-size:11px}.wrn-actions{display:flex;gap:5px}.wrn-small{height:28px;padding:0 8px;border-radius:7px;background:#252a35;color:var(--text);cursor:pointer;white-space:nowrap}.wrn-small:hover{background:#303644}.wrn-small.active{background:#394150;box-shadow:inset 0 0 0 1px #596579}.wrn-plugin-panel{display:block}.wrn-highlight{position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #72a7ff;background:rgba(114,167,255,.12);box-shadow:0 0 0 99999px rgba(0,0,0,.08)}@media(max-width:640px){.wrn-shell{bottom:8px}.wrn-brand{display:none}.wrn-bar{gap:3px;padding:5px}.wrn-count{padding:0 7px}.wrn-panel{bottom:44px;height:calc(100vh - 62px)}.wrn-issue{grid-template-columns:6px 1fr}.wrn-actions{grid-column:2}.wrn-panel-meta{display:none}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { basename, extname, join, relative } from "node:path";
|
||||
import type { DevToolbarPanel, DevToolbarPlatformSnapshot } from "../types.ts";
|
||||
|
||||
function walk(root: string, test: (file: string) => boolean): string[] {
|
||||
if (!existsSync(root)) return [];
|
||||
const result: string[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
const file = join(root, entry.name);
|
||||
if (entry.isDirectory()) result.push(...walk(file, test));
|
||||
else if (test(file)) result.push(file);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function flatten(value: unknown, prefix = ""): string[] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return prefix ? [prefix] : [];
|
||||
return Object.entries(value).flatMap(([key, child]) =>
|
||||
flatten(child, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
export interface BuiltinPanelOptions {
|
||||
root: string;
|
||||
platform?: DevToolbarPlatformSnapshot;
|
||||
database?: { queries: unknown[]; issues: unknown[] };
|
||||
version?: { current: string; latest?: string };
|
||||
}
|
||||
|
||||
/** Produce bounded, credential-free first-party inspector data. */
|
||||
export function builtinDevToolbarPanels(options: BuiltinPanelOptions): DevToolbarPanel[] {
|
||||
const app = join(options.root, "app");
|
||||
const queueFiles = walk(join(app, "queues"), (file) => /\.[cm]?[jt]s$/.test(file));
|
||||
const localeFiles = walk(join(app, "locales"), (file) => extname(file) === ".json");
|
||||
const locales = localeFiles.map((file) => {
|
||||
try {
|
||||
return {
|
||||
name: basename(file, ".json"),
|
||||
keys: flatten(JSON.parse(readFileSync(file, "utf8"))),
|
||||
};
|
||||
} catch {
|
||||
return { name: basename(file, ".json"), keys: [] as string[], invalid: true };
|
||||
}
|
||||
});
|
||||
const allKeys = new Set(locales.flatMap((locale) => locale.keys));
|
||||
const translations = locales.map((locale) => ({
|
||||
...locale,
|
||||
missing: [...allKeys].filter((key) => !locale.keys.includes(key)).slice(0, 100),
|
||||
}));
|
||||
const styleFiles = walk(app, (file) => /\.(?:css|wrn)$/.test(file));
|
||||
const cssBytes = styleFiles.reduce((sum, file) => sum + statSync(file).size, 0);
|
||||
const memory = typeof process.memoryUsage === "function" ? process.memoryUsage() : undefined;
|
||||
const database = options.database ?? { queries: [], issues: [] };
|
||||
const realtimeMonitor = (globalThis as typeof globalThis & { __wrnexusRealtimeMonitor?: unknown })
|
||||
.__wrnexusRealtimeMonitor;
|
||||
const latest = options.version?.latest;
|
||||
return [
|
||||
{
|
||||
id: "database",
|
||||
title: "SQL",
|
||||
category: "database",
|
||||
badge: database.issues.length,
|
||||
data: { recent: database.queries.slice(-100), issues: database.issues.slice(-100) },
|
||||
},
|
||||
{
|
||||
id: "queues",
|
||||
title: "Queues",
|
||||
category: "queues",
|
||||
badge: queueFiles.length,
|
||||
data: {
|
||||
definitions: queueFiles.map((file) => relative(options.root, file).replace(/\\/g, "/")),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "realtime",
|
||||
title: "Realtime",
|
||||
category: "realtime",
|
||||
badge: options.platform?.routes?.realtime ?? 0,
|
||||
data: {
|
||||
routes: options.platform?.routes?.realtime ?? 0,
|
||||
monitor: realtimeMonitor ?? { rooms: 0, messages: 0, acknowledgements: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "translations",
|
||||
title: "Translations",
|
||||
category: "translations",
|
||||
badge: translations.reduce((sum, locale) => sum + locale.missing.length, 0),
|
||||
data: { locales: translations },
|
||||
},
|
||||
{
|
||||
id: "css",
|
||||
title: "CSS",
|
||||
category: "css",
|
||||
badge: styleFiles.length,
|
||||
data: { files: styleFiles.length, bytes: cssBytes, browserUnusedSelectorScan: true },
|
||||
},
|
||||
{
|
||||
id: "performance",
|
||||
title: "Memory & Vitals",
|
||||
category: "performance",
|
||||
data: { memory, webVitalsEndpoint: "/__wrnexus/metrics/vitals" },
|
||||
},
|
||||
{
|
||||
id: "upgrades",
|
||||
title: "Upgrades",
|
||||
category: "upgrades",
|
||||
badge: latest && latest !== options.version?.current ? 1 : 0,
|
||||
data: { current: options.version?.current, latest, updateCommand: "wrnexus update --latest" },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from "./serialize.ts";
|
||||
export * from "./issues.ts";
|
||||
export * from "./editor.ts";
|
||||
export * from "./routes.ts";
|
||||
export * from "./builtin-panels.ts";
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface DevToolbarRouteOptions {
|
||||
editor?: string;
|
||||
allowOpenEditor?: boolean;
|
||||
platform?: DevToolbarPlatformSnapshot;
|
||||
panels?: DevToolbarPanel[];
|
||||
panels?: DevToolbarPanel[] | (() => DevToolbarPanel[] | Promise<DevToolbarPanel[]>);
|
||||
}
|
||||
|
||||
const json = (value: unknown, status = 200) =>
|
||||
@@ -39,8 +39,10 @@ export async function handleDevToolbarRoute(
|
||||
return json({
|
||||
issues: options.collector.getIssues(url.searchParams.get("pathname") ?? undefined),
|
||||
});
|
||||
if (url.pathname === "/__wrnexus/dev-toolbar/platform" && request.method === "GET")
|
||||
return json({ platform: options.platform ?? null, panels: options.panels ?? [] });
|
||||
if (url.pathname === "/__wrnexus/dev-toolbar/platform" && request.method === "GET") {
|
||||
const panels = typeof options.panels === "function" ? await options.panels() : options.panels;
|
||||
return json({ platform: options.platform ?? null, panels: panels ?? [] });
|
||||
}
|
||||
if (url.pathname === "/__wrnexus/dev-toolbar/open-editor" && request.method === "POST") {
|
||||
if (options.allowOpenEditor === false)
|
||||
return json({ error: "Open in editor is disabled." }, 403);
|
||||
|
||||
@@ -3,6 +3,15 @@ export type DevToolbarSeverity = "error" | "warning" | "info" | "suggestion";
|
||||
export type DevToolbarCategory =
|
||||
| "runtime"
|
||||
| "stores"
|
||||
| "cache"
|
||||
| "components"
|
||||
| "hydration"
|
||||
| "database"
|
||||
| "queues"
|
||||
| "realtime"
|
||||
| "translations"
|
||||
| "configuration"
|
||||
| "upgrades"
|
||||
| "compiler"
|
||||
| "server"
|
||||
| "routing"
|
||||
@@ -150,6 +159,8 @@ export interface DevToolbarPanel {
|
||||
order?: number;
|
||||
issues?: unknown[];
|
||||
data?: unknown;
|
||||
/** Optional issue category to show when this application is selected. Defaults to id. */
|
||||
category?: DevToolbarCategory;
|
||||
}
|
||||
|
||||
export interface DevToolbarPlatformSnapshot {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { builtinDevToolbarPanels } from "../src/server/builtin-panels.ts";
|
||||
|
||||
test("built-in providers expose bounded operational data without environment secrets", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-toolbar-providers-"));
|
||||
try {
|
||||
mkdirSync(join(root, "app", "queues"), { recursive: true });
|
||||
mkdirSync(join(root, "app", "locales"), { recursive: true });
|
||||
writeFileSync(join(root, "app", "queues", "email.ts"), "export default {};\n");
|
||||
writeFileSync(
|
||||
join(root, "app", "locales", "en.json"),
|
||||
JSON.stringify({ hello: "Hello", bye: "Bye" }),
|
||||
);
|
||||
writeFileSync(join(root, "app", "locales", "fr.json"), JSON.stringify({ hello: "Bonjour" }));
|
||||
const panels = builtinDevToolbarPanels({
|
||||
root,
|
||||
platform: { routes: { pages: 1, api: 2, realtime: 3 } },
|
||||
database: { queries: [{ durationMs: 120 }], issues: [{ code: "SLOW" }] },
|
||||
version: { current: "1.0.0", latest: "1.1.0" },
|
||||
});
|
||||
expect(panels.map((panel) => panel.id)).toEqual([
|
||||
"database",
|
||||
"queues",
|
||||
"realtime",
|
||||
"translations",
|
||||
"css",
|
||||
"performance",
|
||||
"upgrades",
|
||||
]);
|
||||
expect(panels.find((panel) => panel.id === "queues")?.badge).toBe(1);
|
||||
expect(panels.find((panel) => panel.id === "translations")?.badge).toBe(1);
|
||||
expect(JSON.stringify(panels)).not.toContain("process.env");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -4,5 +4,11 @@ import { DEV_TOOLBAR_CSS, DEV_TOOLBAR_RUNTIME } from "../../src/client/index.ts"
|
||||
test("exports usable development assets", () => {
|
||||
expect(DEV_TOOLBAR_RUNTIME).toContain("__wrnexusDevToolbar");
|
||||
expect(DEV_TOOLBAR_RUNTIME).toContain("wrnexus:navigated");
|
||||
expect(DEV_TOOLBAR_RUNTIME).toContain('data-category="cache"');
|
||||
expect(DEV_TOOLBAR_RUNTIME).toContain("/__wrnexus/cache");
|
||||
expect(DEV_TOOLBAR_RUNTIME).toContain('data-category="accessibility"');
|
||||
expect(DEV_TOOLBAR_RUNTIME).toContain('issue("plugin/"+app.id');
|
||||
expect(DEV_TOOLBAR_RUNTIME).toContain("apps.appendChild(button)");
|
||||
expect(DEV_TOOLBAR_CSS).toContain(".wrn-panel");
|
||||
expect(DEV_TOOLBAR_CSS).toContain(".wrn-plugin-panel");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user