release: WRNexusJS 0.2.75
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import type { DevToolbarIssue } from "../types.ts";
|
||||
|
||||
export type DevToolbarIssueListener = (issues: DevToolbarIssue[]) => void;
|
||||
|
||||
export interface DevToolbarCollector {
|
||||
add(issue: DevToolbarIssue): void;
|
||||
addMany(issues: DevToolbarIssue[]): void;
|
||||
clear(scope?: string): void;
|
||||
getIssues(pathname?: string): DevToolbarIssue[];
|
||||
subscribe(listener: DevToolbarIssueListener): () => void;
|
||||
}
|
||||
|
||||
export function createDevToolbarCollector(): DevToolbarCollector {
|
||||
const issues = new Map<string, DevToolbarIssue>();
|
||||
const listeners = new Set<DevToolbarIssueListener>();
|
||||
const emit = () => {
|
||||
const snapshot = [...issues.values()].sort((a, b) => b.createdAt - a.createdAt);
|
||||
for (const listener of listeners) listener(snapshot);
|
||||
};
|
||||
return {
|
||||
add(issue) {
|
||||
issues.set(issue.fingerprint, issue);
|
||||
emit();
|
||||
},
|
||||
addMany(next) {
|
||||
for (const issue of next) issues.set(issue.fingerprint, issue);
|
||||
emit();
|
||||
},
|
||||
clear(scope) {
|
||||
if (!scope) issues.clear();
|
||||
else
|
||||
for (const [key, issue] of issues) {
|
||||
const pathname =
|
||||
typeof issue.metadata?.pathname === "string" ? issue.metadata.pathname : undefined;
|
||||
if (pathname === scope || issue.source?.file === scope || issue.category === scope)
|
||||
issues.delete(key);
|
||||
}
|
||||
emit();
|
||||
},
|
||||
getIssues(pathname) {
|
||||
const values = [...issues.values()];
|
||||
if (!pathname) return values;
|
||||
return values.filter(
|
||||
(issue) => !issue.metadata?.pathname || issue.metadata.pathname === pathname,
|
||||
);
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
export interface OpenEditorRequest {
|
||||
file: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
export interface OpenEditorOptions {
|
||||
root: string;
|
||||
editor?: string;
|
||||
spawn?: (command: string[], options?: { cwd?: string }) => unknown;
|
||||
}
|
||||
|
||||
const EDITORS: Record<string, (file: string, line: number, column: number) => string[]> = {
|
||||
code: (file, line, column) => ["code", "--goto", `${file}:${line}:${column}`],
|
||||
"code-insiders": (file, line, column) => ["code-insiders", "--goto", `${file}:${line}:${column}`],
|
||||
cursor: (file, line, column) => ["cursor", "--goto", `${file}:${line}:${column}`],
|
||||
windsurf: (file, line, column) => ["windsurf", "--goto", `${file}:${line}:${column}`],
|
||||
zed: (file, line, column) => ["zed", `${file}:${line}:${column}`],
|
||||
sublime: (file, line, column) => ["subl", `${file}:${line}:${column}`],
|
||||
webstorm: (file, line) => ["webstorm", "--line", String(line), file],
|
||||
idea: (file, line) => ["idea", "--line", String(line), file],
|
||||
};
|
||||
|
||||
export function resolveEditorFile(root: string, file: string): string {
|
||||
if (!file || file.includes("\0") || /^https?:\/\//i.test(file))
|
||||
throw new Error("Invalid source file path.");
|
||||
const resolvedRoot = resolve(root);
|
||||
const resolvedFile = isAbsolute(file) ? resolve(file) : resolve(resolvedRoot, file);
|
||||
const rel = relative(resolvedRoot, resolvedFile);
|
||||
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) return resolvedFile;
|
||||
throw new Error("Source file is outside the configured project root.");
|
||||
}
|
||||
|
||||
export function buildEditorCommand(
|
||||
request: OpenEditorRequest,
|
||||
options: OpenEditorOptions,
|
||||
): string[] {
|
||||
const file = resolveEditorFile(options.root, request.file);
|
||||
const line = Math.max(1, Math.floor(request.line ?? 1));
|
||||
const column = Math.max(1, Math.floor(request.column ?? 1));
|
||||
const editor =
|
||||
options.editor ??
|
||||
process.env.WRNEXUS_EDITOR ??
|
||||
process.env.VISUAL ??
|
||||
process.env.EDITOR ??
|
||||
"code";
|
||||
const factory = EDITORS[editor];
|
||||
if (!factory) throw new Error(`Unsupported editor: ${editor}`);
|
||||
return factory(file, line, column);
|
||||
}
|
||||
|
||||
export function openInEditor(request: OpenEditorRequest, options: OpenEditorOptions): void {
|
||||
const command = buildEditorCommand(request, options);
|
||||
const spawn =
|
||||
options.spawn ??
|
||||
((args: string[], spawnOptions?: { cwd?: string }) =>
|
||||
Bun.spawn(args, { cwd: spawnOptions?.cwd, stdout: "ignore", stderr: "ignore" }));
|
||||
spawn(command, { cwd: resolve(options.root) });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./collector.ts";
|
||||
export * from "./registry.ts";
|
||||
export * from "./serialize.ts";
|
||||
export * from "./issues.ts";
|
||||
export * from "./editor.ts";
|
||||
export * from "./routes.ts";
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
DevToolbarCategory,
|
||||
DevToolbarIssue,
|
||||
DevToolbarSeverity,
|
||||
DevToolbarSourceLocation,
|
||||
} from "../types.ts";
|
||||
|
||||
export function createServerIssue(input: {
|
||||
ruleId: string;
|
||||
category: DevToolbarCategory;
|
||||
severity: DevToolbarSeverity;
|
||||
title: string;
|
||||
message: string;
|
||||
pathname?: string;
|
||||
source?: DevToolbarSourceLocation;
|
||||
stack?: string;
|
||||
recommendation?: string;
|
||||
}): DevToolbarIssue {
|
||||
const sourceKey = input.source?.file
|
||||
? `${input.source.file}:${input.source.line ?? 0}:${input.source.column ?? 0}`
|
||||
: "";
|
||||
return {
|
||||
id: `wrn-server-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
ruleId: input.ruleId,
|
||||
category: input.category,
|
||||
severity: input.severity,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
source: input.source,
|
||||
recommendation: input.recommendation,
|
||||
fingerprint: [input.ruleId, input.pathname ?? "", sourceKey, input.message].join("|"),
|
||||
createdAt: Date.now(),
|
||||
confidence: "high",
|
||||
metadata: { pathname: input.pathname, stack: input.stack },
|
||||
};
|
||||
}
|
||||
|
||||
export function issueFromError(
|
||||
error: unknown,
|
||||
input: {
|
||||
ruleId?: string;
|
||||
category?: DevToolbarCategory;
|
||||
title?: string;
|
||||
pathname?: string;
|
||||
source?: DevToolbarSourceLocation;
|
||||
} = {},
|
||||
): DevToolbarIssue {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error));
|
||||
return createServerIssue({
|
||||
ruleId: input.ruleId ?? "server/unhandled-error",
|
||||
category: input.category ?? "server",
|
||||
severity: "error",
|
||||
title: input.title ?? normalized.name ?? "Server error",
|
||||
message: normalized.message,
|
||||
pathname: input.pathname,
|
||||
source: input.source,
|
||||
stack: normalized.stack,
|
||||
recommendation: "Inspect the stack trace and open the referenced source file.",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface DevToolbarApp {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
entrypoint?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface DevToolbarRegistry {
|
||||
registerApp(app: DevToolbarApp): void;
|
||||
unregisterApp(id: string): void;
|
||||
getApps(): DevToolbarApp[];
|
||||
}
|
||||
|
||||
export function createDevToolbarRegistry(): DevToolbarRegistry {
|
||||
const apps = new Map<string, DevToolbarApp>();
|
||||
return {
|
||||
registerApp(app) {
|
||||
apps.set(app.id, app);
|
||||
},
|
||||
unregisterApp(id) {
|
||||
apps.delete(id);
|
||||
},
|
||||
getApps() {
|
||||
return [...apps.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { DevToolbarCollector } from "./collector.ts";
|
||||
import { DEV_TOOLBAR_CSS, DEV_TOOLBAR_RUNTIME } from "../client/index.ts";
|
||||
import { openInEditor } from "./editor.ts";
|
||||
import { serializeDevToolbarJson } from "./serialize.ts";
|
||||
|
||||
export interface DevToolbarRouteOptions {
|
||||
mode: string;
|
||||
root: string;
|
||||
collector: DevToolbarCollector;
|
||||
editor?: string;
|
||||
allowOpenEditor?: boolean;
|
||||
}
|
||||
|
||||
const json = (value: unknown, status = 200) =>
|
||||
new Response(serializeDevToolbarJson(value), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
||||
});
|
||||
|
||||
export async function handleDevToolbarRoute(
|
||||
request: Request,
|
||||
options: DevToolbarRouteOptions,
|
||||
): Promise<Response | null> {
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.startsWith("/__wrnexus/dev-toolbar")) return null;
|
||||
if (options.mode !== "development") return new Response("Not found", { status: 404 });
|
||||
if (url.pathname === "/__wrnexus/dev-toolbar.js" && request.method === "GET")
|
||||
return new Response(DEV_TOOLBAR_RUNTIME, {
|
||||
headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" },
|
||||
});
|
||||
if (url.pathname === "/__wrnexus/dev-toolbar.css" && request.method === "GET")
|
||||
return new Response(DEV_TOOLBAR_CSS, {
|
||||
headers: { "content-type": "text/css; charset=utf-8", "cache-control": "no-store" },
|
||||
});
|
||||
if (url.pathname === "/__wrnexus/dev-toolbar/issues" && request.method === "GET")
|
||||
return json({
|
||||
issues: options.collector.getIssues(url.searchParams.get("pathname") ?? undefined),
|
||||
});
|
||||
if (url.pathname === "/__wrnexus/dev-toolbar/open-editor" && request.method === "POST") {
|
||||
if (options.allowOpenEditor === false)
|
||||
return json({ error: "Open in editor is disabled." }, 403);
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
file?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
} | null;
|
||||
if (!body?.file) return json({ error: "file is required" }, 400);
|
||||
try {
|
||||
openInEditor(
|
||||
{ file: body.file, line: body.line, column: body.column },
|
||||
{ root: options.root, editor: options.editor },
|
||||
);
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
||||
}
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function serializeDevToolbarJson(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/>/g, "\\u003e")
|
||||
.replace(/&/g, "\\u0026")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
}
|
||||
Reference in New Issue
Block a user