perf(panel): 日志控制台按帧批量提交、分块渲染只重绘变动块,§ 与 ANSI 颜色码解析为样式文本
This commit is contained in:
8 files changed
+680
-39
No files matched your search
@@ -1629,6 +1629,10 @@ function streamBuildLogs(
|
|||||||
Connection: "keep-alive",
|
Connection: "keep-alive",
|
||||||
});
|
});
|
||||||
res.write(": connected\n\n");
|
res.write(": connected\n\n");
|
||||||
|
// A modpack boot floods the console; the burst exercises the frame batching.
|
||||||
|
for (let n = 0; n < 400; n++) {
|
||||||
|
res.write(`data: [11:59:59] [main/INFO]: Loading mod ${n + 1}/400 (mock-mod-${n})\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
if (i < lines.length) {
|
if (i < lines.length) {
|
||||||
@@ -2129,6 +2133,8 @@ function streamConsole(
|
|||||||
"[12:00:02] [Server thread/WARN]: Mock world uses in-memory state only",
|
"[12:00:02] [Server thread/WARN]: Mock world uses in-memory state only",
|
||||||
"[12:00:03] [Server thread/INFO]: Preparing spawn area: 100%",
|
"[12:00:03] [Server thread/INFO]: Preparing spawn area: 100%",
|
||||||
"[12:00:04] [Server thread/INFO]: Done (4.123s)! For help, type \"help\"",
|
"[12:00:04] [Server thread/INFO]: Done (4.123s)! For help, type \"help\"",
|
||||||
|
"[12:00:05] [Server thread/INFO]: [Essentials] §aReady§r — §6§lwelcome§r to §x§5§5§c§c§f§fFelis",
|
||||||
|
"[12:00:06] [Server thread/WARN]: \x1b[33mTPS dropped to 18.2\x1b[0m",
|
||||||
];
|
];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
@@ -2138,6 +2144,10 @@ function streamConsole(
|
|||||||
Connection: "keep-alive",
|
Connection: "keep-alive",
|
||||||
});
|
});
|
||||||
res.write(": connected\n\n");
|
res.write(": connected\n\n");
|
||||||
|
// A modpack boot floods the console; the burst exercises the frame batching.
|
||||||
|
for (let n = 0; n < 400; n++) {
|
||||||
|
res.write(`data: [11:59:59] [main/INFO]: Loading mod ${n + 1}/400 (mock-mod-${n})\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
res.write(`data: ${lines[i % lines.length]}\n\n`);
|
res.write(`data: ${lines[i % lines.length]}\n\n`);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { LogConsole } from "./LogConsole";
|
||||||
|
|
||||||
|
const tier = vi.hoisted(() => ({ revalidate: () => Promise.resolve() }));
|
||||||
|
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
|
||||||
|
|
||||||
|
// sameChunk is wrapped so a test can see which chunks React skipped.
|
||||||
|
const spies = vi.hoisted(() => ({ sameChunk: null as unknown as ReturnType<typeof vi.fn> }));
|
||||||
|
vi.mock("@/lib/logchunks", async (importActual) => {
|
||||||
|
const actual = await importActual<typeof import("@/lib/logchunks")>();
|
||||||
|
spies.sameChunk = vi.fn(actual.sameChunk);
|
||||||
|
return { ...actual, sameChunk: spies.sameChunk };
|
||||||
|
});
|
||||||
|
|
||||||
|
// FakeEventSource replaces the browser one: jsdom has none.
|
||||||
|
class FakeEventSource {
|
||||||
|
static last: FakeEventSource | null = null;
|
||||||
|
onopen: ((ev: unknown) => void) | null = null;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||||
|
onerror: ((ev: unknown) => void) | null = null;
|
||||||
|
readyState = 0;
|
||||||
|
constructor() {
|
||||||
|
FakeEventSource.last = this;
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.readyState = 2;
|
||||||
|
}
|
||||||
|
addEventListener() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send delivers lines and waits past the frame that commits them.
|
||||||
|
async function send(...lines: string[]) {
|
||||||
|
await act(async () => {
|
||||||
|
for (const line of lines) FakeEventSource.last!.onmessage?.({ data: line });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = (from: number, to: number) => Array.from({ length: to - from }, (_, i) => `line ${from + i}`);
|
||||||
|
const lineRows = (container: HTMLElement) => container.querySelectorAll(".whitespace-pre-wrap");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("EventSource", FakeEventSource);
|
||||||
|
spies.sameChunk.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LogConsole", () => {
|
||||||
|
it("renders colour codes as styled text and never as markup", async () => {
|
||||||
|
const { container } = render(<LogConsole url="/c" />);
|
||||||
|
await send("§c<b>boom</b> §lloud§r plain");
|
||||||
|
|
||||||
|
const red = screen.getByText("<b>boom</b>", { exact: false });
|
||||||
|
expect(red.tagName).toBe("SPAN");
|
||||||
|
expect(red.style.color).toBe("rgb(255, 85, 85)");
|
||||||
|
expect(screen.getByText("loud").style.fontWeight).toBe("700");
|
||||||
|
expect(container.querySelector("b")).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("§");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps showing new lines in a chunk that was already on screen, and after a clear", async () => {
|
||||||
|
const { container } = render(<LogConsole url="/c" />);
|
||||||
|
await send(...range(0, 150));
|
||||||
|
expect(screen.getByText("line 149")).toBeTruthy();
|
||||||
|
|
||||||
|
await send(...range(150, 160));
|
||||||
|
expect(screen.getByText("line 159")).toBeTruthy();
|
||||||
|
expect(lineRows(container)).toHaveLength(160);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: i18next.t("servers:log_clear_btn") }));
|
||||||
|
await send("after clear");
|
||||||
|
expect(screen.getByText("after clear")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("line 159")).toBeNull();
|
||||||
|
expect(lineRows(container)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the oldest lines from the first chunk as the buffer trims", async () => {
|
||||||
|
const { container } = render(<LogConsole url="/c" />);
|
||||||
|
await send(...range(0, 2000));
|
||||||
|
expect(screen.getByText("line 0")).toBeTruthy();
|
||||||
|
|
||||||
|
await send(...range(2000, 2050));
|
||||||
|
expect(screen.queryByText("line 0")).toBeNull();
|
||||||
|
expect(screen.queryByText("line 49")).toBeNull();
|
||||||
|
expect(screen.getByText("line 50")).toBeTruthy();
|
||||||
|
expect(lineRows(container)).toHaveLength(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-renders only the chunk a new line lands in", async () => {
|
||||||
|
render(<LogConsole url="/c" />);
|
||||||
|
await send(...range(0, 250));
|
||||||
|
spies.sameChunk.mockClear();
|
||||||
|
|
||||||
|
await send("line 250");
|
||||||
|
expect(screen.getByText("line 250")).toBeTruthy();
|
||||||
|
const verdicts = spies.sameChunk.mock.results.map((r) => r.value);
|
||||||
|
expect(verdicts.filter((same) => same === true)).toHaveLength(2);
|
||||||
|
expect(verdicts.filter((same) => same === false)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||||
import { ArrowDown, RotateCw, Trash2 } from "lucide-react";
|
import { ArrowDown, RotateCw, Trash2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useLogStream } from "@/lib/useLogStream";
|
import { useLogStream } from "@/lib/useLogStream";
|
||||||
import type { LogLevel, StreamStatus } from "@/lib/logstream";
|
import { chunkLines, sameChunk } from "@/lib/logchunks";
|
||||||
|
import type { LogLevel, LogLine, StreamStatus } from "@/lib/logstream";
|
||||||
|
import type { Segment } from "@/lib/mcformat";
|
||||||
|
|
||||||
// Per-level tint. Plain/info are the default readable foreground; warn/error draw
|
// Per-level tint. Plain/info are the default readable foreground; warn/error draw
|
||||||
// the eye. Debug is dimmed so it recedes. The console body is a fixed dark
|
// the eye. Debug is dimmed so it recedes. The console body is a fixed dark
|
||||||
@@ -19,6 +21,37 @@ const LEVEL_CLASS: Record<LogLevel, string> = {
|
|||||||
|
|
||||||
const PIN_THRESHOLD_PX = 24;
|
const PIN_THRESHOLD_PX = 24;
|
||||||
|
|
||||||
|
function segmentStyle(s: Segment): CSSProperties | undefined {
|
||||||
|
const decoration = [s.underline && "underline", s.strike && "line-through"].filter(Boolean).join(" ");
|
||||||
|
if (!s.color && !s.bold && !s.italic && !decoration) return undefined;
|
||||||
|
return {
|
||||||
|
color: s.color,
|
||||||
|
fontWeight: s.bold ? 700 : undefined,
|
||||||
|
fontStyle: s.italic ? "italic" : undefined,
|
||||||
|
textDecorationLine: decoration || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A chunk off screen is sized from its last layout, or before it has had one,
|
||||||
|
// from 100 unwrapped lines of text-xs at leading-relaxed (19.5px each).
|
||||||
|
const LogChunk = memo(function LogChunk({ lines }: { lines: LogLine[] }) {
|
||||||
|
return (
|
||||||
|
<div className="[content-visibility:auto] [contain-intrinsic-size:auto_1950px]">
|
||||||
|
{lines.map((line) => (
|
||||||
|
<div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}>
|
||||||
|
{line.segments
|
||||||
|
? line.segments.map((s, i) => (
|
||||||
|
<span key={i} style={segmentStyle(s)}>
|
||||||
|
{s.text}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: line.text || "\u00A0"}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, sameChunk);
|
||||||
|
|
||||||
function StatusIndicator({ status }: { status: StreamStatus }) {
|
function StatusIndicator({ status }: { status: StreamStatus }) {
|
||||||
const { t } = useTranslation("servers");
|
const { t } = useTranslation("servers");
|
||||||
const map: Record<StreamStatus, { dot: string; label: string; text: string }> = {
|
const map: Record<StreamStatus, { dot: string; label: string; text: string }> = {
|
||||||
@@ -45,11 +78,13 @@ function StatusIndicator({ status }: { status: StreamStatus }) {
|
|||||||
*
|
*
|
||||||
* UX follows modern log viewers: it follows the tail, but if you scroll up to
|
* UX follows modern log viewers: it follows the tail, but if you scroll up to
|
||||||
* read history it stops yanking you down and offers a "Jump to latest" pill;
|
* read history it stops yanking you down and offers a "Jump to latest" pill;
|
||||||
* scrolling back to the bottom re-pins. The buffer is bounded by the controller.
|
* scrolling back to the bottom re-pins. The buffer is bounded by the controller,
|
||||||
|
* which also batches lines to one update per frame.
|
||||||
*/
|
*/
|
||||||
export function LogConsole({ url, className }: { url: string; className?: string }) {
|
export function LogConsole({ url, className }: { url: string; className?: string }) {
|
||||||
const { t } = useTranslation("servers");
|
const { t } = useTranslation("servers");
|
||||||
const { lines, status, clear, reconnect } = useLogStream(url);
|
const { lines, status, clear, reconnect } = useLogStream(url);
|
||||||
|
const chunks = useMemo(() => chunkLines(lines), [lines]);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [pinned, setPinned] = useState(true);
|
const [pinned, setPinned] = useState(true);
|
||||||
|
|
||||||
@@ -113,11 +148,7 @@ export function LogConsole({ url, className }: { url: string; className?: string
|
|||||||
: t("log_waiting")}
|
: t("log_waiting")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
lines.map((line) => (
|
chunks.map((chunk) => <LogChunk key={chunk.key} lines={chunk.lines} />)
|
||||||
<div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}>
|
|
||||||
{line.text || "\u00A0"}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { LogLine } from "./logstream";
|
||||||
|
|
||||||
|
// Lines render in chunks of CHUNK_LINES, grouped by seq so a line never moves
|
||||||
|
// between chunks. A full chunk never changes again, so on each frame React
|
||||||
|
// re-renders only the tail chunk (and the head one while the ring buffer
|
||||||
|
// trims), whatever the buffer holds. Chunks off screen skip layout and paint
|
||||||
|
// through content-visibility. The DOM keeps every line, unlike a virtualised
|
||||||
|
// list, so find-in-page and selecting across lines to copy both keep working.
|
||||||
|
export const CHUNK_LINES = 100;
|
||||||
|
|
||||||
|
/** chunkLines groups a buffer into chunks keyed by seq / CHUNK_LINES. */
|
||||||
|
export function chunkLines(lines: LogLine[]): { key: number; lines: LogLine[] }[] {
|
||||||
|
const chunks: { key: number; lines: LogLine[] }[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const key = Math.floor(line.seq / CHUNK_LINES);
|
||||||
|
const last = chunks[chunks.length - 1];
|
||||||
|
if (last?.key === key) last.lines.push(line);
|
||||||
|
else chunks.push({ key, lines: [line] });
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sameChunk: lines are immutable and a chunk's seqs are consecutive, so the
|
||||||
|
* same first and last line mean the same content. The first changes when the
|
||||||
|
* ring buffer trims the chunk, the last when a line lands in it. */
|
||||||
|
export function sameChunk(a: { lines: LogLine[] }, b: { lines: LogLine[] }): boolean {
|
||||||
|
return a.lines[0] === b.lines[0] && a.lines[a.lines.length - 1] === b.lines[b.lines.length - 1];
|
||||||
|
}
|
||||||
+130
-22
@@ -4,6 +4,7 @@ import {
|
|||||||
classifyLogLine,
|
classifyLogLine,
|
||||||
type EventSourceFactory,
|
type EventSourceFactory,
|
||||||
type EventSourceLike,
|
type EventSourceLike,
|
||||||
|
type FrameScheduler,
|
||||||
} from "./logstream";
|
} from "./logstream";
|
||||||
|
|
||||||
// FakeEventSource drives the controller without a browser: Node has no
|
// FakeEventSource drives the controller without a browser: Node has no
|
||||||
@@ -50,14 +51,34 @@ class FakeEventSource implements EventSourceLike {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeFactory(): { factory: EventSourceFactory; created: FakeEventSource[] } {
|
// ManualFrames stands in for requestAnimationFrame: queued callbacks run only
|
||||||
|
// when the test says a frame has passed.
|
||||||
|
class ManualFrames {
|
||||||
|
private queue: Array<() => void> = [];
|
||||||
|
schedule: FrameScheduler = (cb) => {
|
||||||
|
this.queue.push(cb);
|
||||||
|
return () => {
|
||||||
|
this.queue = this.queue.filter((f) => f !== cb);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get queued(): number {
|
||||||
|
return this.queue.length;
|
||||||
|
}
|
||||||
|
run(): void {
|
||||||
|
const due = this.queue;
|
||||||
|
this.queue = [];
|
||||||
|
due.forEach((f) => f());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFactory(): { factory: EventSourceFactory; created: FakeEventSource[]; frames: ManualFrames } {
|
||||||
const created: FakeEventSource[] = [];
|
const created: FakeEventSource[] = [];
|
||||||
const factory: EventSourceFactory = (url) => {
|
const factory: EventSourceFactory = (url) => {
|
||||||
const es = new FakeEventSource(url);
|
const es = new FakeEventSource(url);
|
||||||
created.push(es);
|
created.push(es);
|
||||||
return es;
|
return es;
|
||||||
};
|
};
|
||||||
return { factory, created };
|
return { factory, created, frames: new ManualFrames() };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("classifyLogLine", () => {
|
describe("classifyLogLine", () => {
|
||||||
@@ -84,8 +105,8 @@ describe("classifyLogLine", () => {
|
|||||||
|
|
||||||
describe("LogStreamController", () => {
|
describe("LogStreamController", () => {
|
||||||
it("transitions connecting -> open and appends classified lines", () => {
|
it("transitions connecting -> open and appends classified lines", () => {
|
||||||
const { factory, created } = makeFactory();
|
const { factory, created, frames } = makeFactory();
|
||||||
const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory });
|
const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory, frame: frames.schedule });
|
||||||
ctrl.open();
|
ctrl.open();
|
||||||
expect(created).toHaveLength(1);
|
expect(created).toHaveLength(1);
|
||||||
expect(created[0].url).toBe("/api/v1/servers/s/console");
|
expect(created[0].url).toBe("/api/v1/servers/s/console");
|
||||||
@@ -95,6 +116,7 @@ describe("LogStreamController", () => {
|
|||||||
expect(ctrl.getSnapshot().status).toBe("open");
|
expect(ctrl.getSnapshot().status).toBe("open");
|
||||||
|
|
||||||
created[0].emitMessage("[12:00:00] [Server thread/WARN]: heads up");
|
created[0].emitMessage("[12:00:00] [Server thread/WARN]: heads up");
|
||||||
|
frames.run();
|
||||||
const { lines } = ctrl.getSnapshot();
|
const { lines } = ctrl.getSnapshot();
|
||||||
expect(lines).toHaveLength(1);
|
expect(lines).toHaveLength(1);
|
||||||
expect(lines[0]).toMatchObject({ seq: 0, level: "warn" });
|
expect(lines[0]).toMatchObject({ seq: 0, level: "warn" });
|
||||||
@@ -102,12 +124,13 @@ describe("LogStreamController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("bounds the ring buffer, dropping the oldest lines", () => {
|
it("bounds the ring buffer, dropping the oldest lines", () => {
|
||||||
const { factory, created } = makeFactory();
|
const { factory, created, frames } = makeFactory();
|
||||||
const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 2 });
|
const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 2, frame: frames.schedule });
|
||||||
ctrl.open();
|
ctrl.open();
|
||||||
created[0].emitMessage("line0");
|
created[0].emitMessage("line0");
|
||||||
created[0].emitMessage("line1");
|
created[0].emitMessage("line1");
|
||||||
created[0].emitMessage("line2");
|
created[0].emitMessage("line2");
|
||||||
|
frames.run();
|
||||||
const { lines } = ctrl.getSnapshot();
|
const { lines } = ctrl.getSnapshot();
|
||||||
expect(lines).toHaveLength(2);
|
expect(lines).toHaveLength(2);
|
||||||
// Oldest dropped; the survivors keep their monotonic seq (1 then 2).
|
// Oldest dropped; the survivors keep their monotonic seq (1 then 2).
|
||||||
@@ -116,34 +139,38 @@ describe("LogStreamController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns a stable snapshot reference between mutations", () => {
|
it("returns a stable snapshot reference between mutations", () => {
|
||||||
const { factory, created } = makeFactory();
|
const { factory, created, frames } = makeFactory();
|
||||||
const ctrl = new LogStreamController({ url: "/c", factory });
|
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
|
||||||
ctrl.open(); // status already 'connecting' -> no commit, snapshot stable
|
ctrl.open(); // status already 'connecting' -> no commit, snapshot stable
|
||||||
const s1 = ctrl.getSnapshot();
|
const s1 = ctrl.getSnapshot();
|
||||||
expect(ctrl.getSnapshot()).toBe(s1);
|
expect(ctrl.getSnapshot()).toBe(s1);
|
||||||
created[0].emitMessage("x");
|
created[0].emitMessage("x");
|
||||||
|
expect(ctrl.getSnapshot()).toBe(s1); // queued until the frame
|
||||||
|
frames.run();
|
||||||
expect(ctrl.getSnapshot()).not.toBe(s1);
|
expect(ctrl.getSnapshot()).not.toBe(s1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("notifies subscribers on mutation and stops after unsubscribe", () => {
|
it("notifies subscribers on mutation and stops after unsubscribe", () => {
|
||||||
const { factory, created } = makeFactory();
|
const { factory, created, frames } = makeFactory();
|
||||||
const ctrl = new LogStreamController({ url: "/c", factory });
|
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
|
||||||
ctrl.open();
|
ctrl.open();
|
||||||
let hits = 0;
|
let hits = 0;
|
||||||
const unsub = ctrl.subscribe(() => {
|
const unsub = ctrl.subscribe(() => {
|
||||||
hits++;
|
hits++;
|
||||||
});
|
});
|
||||||
created[0].emitOpen(); // commit
|
created[0].emitOpen(); // commit
|
||||||
created[0].emitMessage("a"); // commit
|
created[0].emitMessage("a");
|
||||||
|
frames.run(); // commit
|
||||||
expect(hits).toBe(2);
|
expect(hits).toBe(2);
|
||||||
unsub();
|
unsub();
|
||||||
created[0].emitMessage("b");
|
created[0].emitMessage("b");
|
||||||
|
frames.run();
|
||||||
expect(hits).toBe(2); // no further notifications
|
expect(hits).toBe(2); // no further notifications
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps a transient drop to reconnecting and a fatal close to ended", () => {
|
it("maps a transient drop to reconnecting and a fatal close to ended", () => {
|
||||||
const { factory, created } = makeFactory();
|
const { factory, created, frames } = makeFactory();
|
||||||
const ctrl = new LogStreamController({ url: "/c", factory });
|
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
|
||||||
ctrl.open();
|
ctrl.open();
|
||||||
created[0].emitOpen();
|
created[0].emitOpen();
|
||||||
created[0].emitError(0); // readyState CONNECTING -> auto-retrying
|
created[0].emitError(0); // readyState CONNECTING -> auto-retrying
|
||||||
@@ -153,8 +180,8 @@ describe("LogStreamController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ignores events after close (teardown linchpin)", () => {
|
it("ignores events after close (teardown linchpin)", () => {
|
||||||
const { factory, created } = makeFactory();
|
const { factory, created, frames } = makeFactory();
|
||||||
const ctrl = new LogStreamController({ url: "/c", factory });
|
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
|
||||||
ctrl.open();
|
ctrl.open();
|
||||||
created[0].emitOpen();
|
created[0].emitOpen();
|
||||||
ctrl.close();
|
ctrl.close();
|
||||||
@@ -167,8 +194,8 @@ describe("LogStreamController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reconnect closes the old stream and dials a fresh one", () => {
|
it("reconnect closes the old stream and dials a fresh one", () => {
|
||||||
const { factory, created } = makeFactory();
|
const { factory, created, frames } = makeFactory();
|
||||||
const ctrl = new LogStreamController({ url: "/c", factory });
|
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
|
||||||
ctrl.open();
|
ctrl.open();
|
||||||
created[0].emitError(2); // ended
|
created[0].emitError(2); // ended
|
||||||
ctrl.reconnect();
|
ctrl.reconnect();
|
||||||
@@ -180,27 +207,108 @@ describe("LogStreamController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clear empties the buffer without disturbing status", () => {
|
it("clear empties the buffer without disturbing status", () => {
|
||||||
const { factory, created } = makeFactory();
|
const { factory, created, frames } = makeFactory();
|
||||||
const ctrl = new LogStreamController({ url: "/c", factory });
|
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
|
||||||
ctrl.open();
|
ctrl.open();
|
||||||
created[0].emitOpen();
|
created[0].emitOpen();
|
||||||
created[0].emitMessage("a");
|
created[0].emitMessage("a");
|
||||||
|
frames.run();
|
||||||
|
created[0].emitMessage("b"); // still queued
|
||||||
ctrl.clear();
|
ctrl.clear();
|
||||||
|
frames.run();
|
||||||
expect(ctrl.getSnapshot().lines).toHaveLength(0);
|
expect(ctrl.getSnapshot().lines).toHaveLength(0);
|
||||||
expect(ctrl.getSnapshot().status).toBe("open");
|
expect(ctrl.getSnapshot().status).toBe("open");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ends for good when the server revokes the stream", () => {
|
it("ends for good when the server revokes the stream", () => {
|
||||||
const { factory, created } = makeFactory();
|
const { factory, created, frames } = makeFactory();
|
||||||
const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory });
|
const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory, frame: frames.schedule });
|
||||||
ctrl.open();
|
ctrl.open();
|
||||||
const es = created[0];
|
const es = created[0];
|
||||||
es.emitOpen();
|
es.emitOpen();
|
||||||
es.emitMessage("[12:00:00] [Server thread/INFO]: hello");
|
es.emitMessage("[12:00:00] [Server thread/INFO]: hello");
|
||||||
es.emitEvent("revoked");
|
es.emitEvent("revoked"); // no frame ran: the status change carries the line
|
||||||
expect(ctrl.getSnapshot().status).toBe("ended");
|
expect(ctrl.getSnapshot().status).toBe("ended");
|
||||||
expect(es.closed).toBe(true);
|
expect(es.closed).toBe(true);
|
||||||
expect(ctrl.getSnapshot().lines).toHaveLength(1);
|
expect(ctrl.getSnapshot().lines).toHaveLength(1);
|
||||||
expect(created).toHaveLength(1);
|
expect(created).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
it("delivers a burst of lines in one commit per frame", () => {
|
||||||
|
const { factory, created, frames } = makeFactory();
|
||||||
|
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
|
||||||
|
ctrl.open();
|
||||||
|
created[0].emitOpen();
|
||||||
|
let hits = 0;
|
||||||
|
ctrl.subscribe(() => hits++);
|
||||||
|
|
||||||
|
for (let i = 0; i < 500; i++) created[0].emitMessage(`mod ${i}`);
|
||||||
|
expect(hits).toBe(0);
|
||||||
|
expect(frames.queued).toBe(1);
|
||||||
|
|
||||||
|
frames.run();
|
||||||
|
expect(hits).toBe(1);
|
||||||
|
expect(ctrl.getSnapshot().lines.map((l) => l.text)).toEqual(
|
||||||
|
Array.from({ length: 500 }, (_, i) => `mod ${i}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
created[0].emitMessage("next");
|
||||||
|
expect(frames.queued).toBe(1);
|
||||||
|
frames.run();
|
||||||
|
expect(hits).toBe(2);
|
||||||
|
expect(ctrl.getSnapshot().lines.at(-1)?.seq).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the queue bounded while no frame runs, as in a background tab", () => {
|
||||||
|
const { factory, created, frames } = makeFactory();
|
||||||
|
const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 3, frame: frames.schedule });
|
||||||
|
ctrl.open();
|
||||||
|
for (let i = 0; i < 100; i++) created[0].emitMessage(`line${i}`);
|
||||||
|
expect((ctrl as unknown as { pending: unknown[] }).pending.length).toBeLessThan(6);
|
||||||
|
|
||||||
|
frames.run();
|
||||||
|
expect(ctrl.getSnapshot().lines.map((l) => l.text)).toEqual(["line97", "line98", "line99"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows queued lines together with a status change", () => {
|
||||||
|
const { factory, created, frames } = makeFactory();
|
||||||
|
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
|
||||||
|
ctrl.open();
|
||||||
|
created[0].emitOpen();
|
||||||
|
let hits = 0;
|
||||||
|
ctrl.subscribe(() => hits++);
|
||||||
|
created[0].emitMessage("Stopping server");
|
||||||
|
created[0].emitMessage("Saving chunks");
|
||||||
|
created[0].emitError(2);
|
||||||
|
|
||||||
|
expect(hits).toBe(1);
|
||||||
|
expect(ctrl.getSnapshot().status).toBe("ended");
|
||||||
|
expect(ctrl.getSnapshot().lines.map((l) => l.text)).toEqual(["Stopping server", "Saving chunks"]);
|
||||||
|
expect(frames.queued).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the queued frame on close", () => {
|
||||||
|
const { factory, created, frames } = makeFactory();
|
||||||
|
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
|
||||||
|
ctrl.open();
|
||||||
|
created[0].emitMessage("late");
|
||||||
|
ctrl.close();
|
||||||
|
expect(frames.queued).toBe(0);
|
||||||
|
frames.run();
|
||||||
|
expect(ctrl.getSnapshot().lines).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips colour codes before classifying and keeps them as segments", () => {
|
||||||
|
const { factory, created, frames } = makeFactory();
|
||||||
|
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
|
||||||
|
ctrl.open();
|
||||||
|
created[0].emitMessage("[12:00:00] [Server thread/§cERROR§r]: §eplugin§r failed");
|
||||||
|
created[0].emitMessage("[12:00:01] [Server thread/INFO]: plain");
|
||||||
|
frames.run();
|
||||||
|
|
||||||
|
const [colored, plain] = ctrl.getSnapshot().lines;
|
||||||
|
expect(colored.text).toBe("[12:00:00] [Server thread/ERROR]: plugin failed");
|
||||||
|
expect(colored.level).toBe("error");
|
||||||
|
expect(colored.segments?.find((s) => s.text === "plugin")?.color).toBe("#ffff55");
|
||||||
|
expect(plain.segments).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -7,9 +7,12 @@
|
|||||||
//
|
//
|
||||||
// This module is deliberately framework-agnostic: the EventSource is injected
|
// This module is deliberately framework-agnostic: the EventSource is injected
|
||||||
// (see EventSourceFactory) so the lifecycle — status transitions, the bounded
|
// (see EventSourceFactory) so the lifecycle — status transitions, the bounded
|
||||||
// ring buffer, reconnect, teardown — is unit-tested under Node against a fake,
|
// ring buffer, frame batching, reconnect, teardown — is unit-tested under Node
|
||||||
// not left to a "tsc + build" green that only proves it compiles. The thin React
|
// against a fake, not left to a "tsc + build" green that only proves it
|
||||||
// binding lives in useLogStream.ts; the viewport in components/LogConsole.tsx.
|
// compiles. The thin React binding lives in useLogStream.ts; the viewport in
|
||||||
|
// components/LogConsole.tsx.
|
||||||
|
|
||||||
|
import { parseFormatting, type Segment } from "./mcformat";
|
||||||
|
|
||||||
/** LogLevel is the coarse severity used only for tinting a line. */
|
/** LogLevel is the coarse severity used only for tinting a line. */
|
||||||
export type LogLevel = "error" | "warn" | "info" | "debug" | "plain";
|
export type LogLevel = "error" | "warn" | "info" | "debug" | "plain";
|
||||||
@@ -29,8 +32,11 @@ export type StreamStatus = "connecting" | "open" | "reconnecting" | "ended";
|
|||||||
/** A single rendered log line. seq is a monotonic id for stable React keys. */
|
/** A single rendered log line. seq is a monotonic id for stable React keys. */
|
||||||
export interface LogLine {
|
export interface LogLine {
|
||||||
seq: number;
|
seq: number;
|
||||||
|
/** The line without § or ANSI codes. */
|
||||||
text: string;
|
text: string;
|
||||||
level: LogLevel;
|
level: LogLevel;
|
||||||
|
/** Styled runs when the line carried colour codes (see mcformat.ts). */
|
||||||
|
segments?: Segment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Immutable snapshot consumed by useSyncExternalStore. */
|
/** Immutable snapshot consumed by useSyncExternalStore. */
|
||||||
@@ -65,11 +71,25 @@ export interface EventSourceLike {
|
|||||||
/** Builds an EventSource for a URL (production: the browser ctor; tests: a fake). */
|
/** Builds an EventSource for a URL (production: the browser ctor; tests: a fake). */
|
||||||
export type EventSourceFactory = (url: string) => EventSourceLike;
|
export type EventSourceFactory = (url: string) => EventSourceLike;
|
||||||
|
|
||||||
|
/** Runs cb once before the next paint and returns a cancel (tests drive it by hand). */
|
||||||
|
export type FrameScheduler = (cb: () => void) => () => void;
|
||||||
|
|
||||||
|
const nextFrame: FrameScheduler = (cb) => {
|
||||||
|
if (typeof requestAnimationFrame === "function") {
|
||||||
|
const id = requestAnimationFrame(cb);
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}
|
||||||
|
const id = setTimeout(cb, 16);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
};
|
||||||
|
|
||||||
export interface LogStreamOptions {
|
export interface LogStreamOptions {
|
||||||
url: string;
|
url: string;
|
||||||
factory: EventSourceFactory;
|
factory: EventSourceFactory;
|
||||||
/** Ring-buffer bound; older lines are dropped past this (default 2000). */
|
/** Ring-buffer bound; older lines are dropped past this (default 2000). */
|
||||||
maxLines?: number;
|
maxLines?: number;
|
||||||
|
/** Defers a batch commit; requestAnimationFrame in the browser. */
|
||||||
|
frame?: FrameScheduler;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MAX_LINES = 2000;
|
const DEFAULT_MAX_LINES = 2000;
|
||||||
@@ -114,6 +134,9 @@ export function classifyLogLine(line: string): LogLevel {
|
|||||||
*
|
*
|
||||||
* - status transitions driven by EventSource events,
|
* - status transitions driven by EventSource events,
|
||||||
* - the bounded ring buffer (memory stays capped under a long live follow),
|
* - the bounded ring buffer (memory stays capped under a long live follow),
|
||||||
|
* - frame batching: a modpack boot logs hundreds of lines a second, so lines
|
||||||
|
* queue up and reach subscribers at most once per frame, one copy of the
|
||||||
|
* buffer per frame instead of one per line,
|
||||||
* - reconnect (manual, after a fatal `ended`),
|
* - reconnect (manual, after a fatal `ended`),
|
||||||
* - teardown (close()) — the linchpin that the React effect's cleanup calls so
|
* - teardown (close()) — the linchpin that the React effect's cleanup calls so
|
||||||
* a StrictMode dev double-mount, or a route change, never leaks a stream.
|
* a StrictMode dev double-mount, or a route change, never leaks a stream.
|
||||||
@@ -121,11 +144,15 @@ export function classifyLogLine(line: string): LogLevel {
|
|||||||
export class LogStreamController {
|
export class LogStreamController {
|
||||||
private es: EventSourceLike | null = null;
|
private es: EventSourceLike | null = null;
|
||||||
private lines: LogLine[] = [];
|
private lines: LogLine[] = [];
|
||||||
|
// Lines that arrived since the last frame.
|
||||||
|
private pending: LogLine[] = [];
|
||||||
|
private cancelFrame: (() => void) | null = null;
|
||||||
private status: StreamStatus = "connecting";
|
private status: StreamStatus = "connecting";
|
||||||
private seq = 0;
|
private seq = 0;
|
||||||
private readonly url: string;
|
private readonly url: string;
|
||||||
private readonly factory: EventSourceFactory;
|
private readonly factory: EventSourceFactory;
|
||||||
private readonly maxLines: number;
|
private readonly maxLines: number;
|
||||||
|
private readonly frame: FrameScheduler;
|
||||||
private readonly listeners = new Set<() => void>();
|
private readonly listeners = new Set<() => void>();
|
||||||
// snapshot is rebuilt only inside commit(), so getSnapshot returns a stable
|
// snapshot is rebuilt only inside commit(), so getSnapshot returns a stable
|
||||||
// reference between mutations — required by useSyncExternalStore to avoid an
|
// reference between mutations — required by useSyncExternalStore to avoid an
|
||||||
@@ -136,6 +163,7 @@ export class LogStreamController {
|
|||||||
this.url = opts.url;
|
this.url = opts.url;
|
||||||
this.factory = opts.factory;
|
this.factory = opts.factory;
|
||||||
this.maxLines = opts.maxLines ?? DEFAULT_MAX_LINES;
|
this.maxLines = opts.maxLines ?? DEFAULT_MAX_LINES;
|
||||||
|
this.frame = opts.frame ?? nextFrame;
|
||||||
this.snapshot = { lines: this.lines, status: this.status };
|
this.snapshot = { lines: this.lines, status: this.status };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,12 +189,14 @@ export class LogStreamController {
|
|||||||
|
|
||||||
/** clear empties the buffer without touching the stream. */
|
/** clear empties the buffer without touching the stream. */
|
||||||
clear(): void {
|
clear(): void {
|
||||||
|
this.dropPending();
|
||||||
this.lines = [];
|
this.lines = [];
|
||||||
this.commit();
|
this.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** close tears the stream down for good. Idempotent. */
|
/** close tears the stream down for good. Idempotent. */
|
||||||
close(): void {
|
close(): void {
|
||||||
|
this.dropPending();
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,17 +238,44 @@ export class LogStreamController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private pushLine(text: string): void {
|
private pushLine(raw: string): void {
|
||||||
const next = this.lines.concat({ seq: this.seq++, text, level: classifyLogLine(text) });
|
const { text, segments } = parseFormatting(raw);
|
||||||
if (next.length > this.maxLines) {
|
const line: LogLine = { seq: this.seq++, text, level: classifyLogLine(text) };
|
||||||
next.splice(0, next.length - this.maxLines);
|
if (segments) line.segments = segments;
|
||||||
|
this.pending.push(line);
|
||||||
|
// A background tab gets no frames, so the queue is bounded too. It trims
|
||||||
|
// only once it holds twice the buffer, keeping each push O(1) on average.
|
||||||
|
if (this.pending.length >= this.maxLines * 2) {
|
||||||
|
this.pending = this.pending.slice(-this.maxLines);
|
||||||
}
|
}
|
||||||
this.lines = next;
|
this.cancelFrame ??= this.frame(() => {
|
||||||
this.commit();
|
if (this.flushPending()) this.commit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushPending moves the queued lines into the buffer and reports whether
|
||||||
|
// there were any. It also forgets the scheduled frame, whether it is the one
|
||||||
|
// running now or one a status change got ahead of.
|
||||||
|
private flushPending(): boolean {
|
||||||
|
this.cancelFrame?.();
|
||||||
|
this.cancelFrame = null;
|
||||||
|
if (this.pending.length === 0) return false;
|
||||||
|
const next = this.lines.concat(this.pending);
|
||||||
|
this.pending = [];
|
||||||
|
this.lines = next.length > this.maxLines ? next.slice(-this.maxLines) : next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropPending(): void {
|
||||||
|
this.cancelFrame?.();
|
||||||
|
this.cancelFrame = null;
|
||||||
|
this.pending = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private setStatus(s: StreamStatus): void {
|
private setStatus(s: StreamStatus): void {
|
||||||
if (this.status === s) return;
|
if (this.status === s) return;
|
||||||
|
// Lines that came before the change are shown with it, never after.
|
||||||
|
this.flushPending();
|
||||||
this.status = s;
|
this.status = s;
|
||||||
this.commit();
|
this.commit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseFormatting } from "./mcformat";
|
||||||
|
|
||||||
|
describe("parseFormatting", () => {
|
||||||
|
it("returns a plain line untouched, without segments", () => {
|
||||||
|
expect(parseFormatting("[12:00:00] [Server thread/INFO]: Done")).toEqual({
|
||||||
|
text: "[12:00:00] [Server thread/INFO]: Done",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("colours text after a § colour code and resets on §r", () => {
|
||||||
|
expect(parseFormatting("§aReady§r now")).toEqual({
|
||||||
|
text: "Ready now",
|
||||||
|
segments: [
|
||||||
|
{ text: "Ready", color: "#55ff55" },
|
||||||
|
{ text: " now" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads codes in either case and lifts black so it shows on the console", () => {
|
||||||
|
expect(parseFormatting("§Cred§0dark").segments).toEqual([
|
||||||
|
{ text: "red", color: "#ff5555" },
|
||||||
|
{ text: "dark", color: "#71717a" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks formatting codes until a colour code clears them", () => {
|
||||||
|
expect(parseFormatting("§l§nTitle§6gold").segments).toEqual([
|
||||||
|
{ text: "Title", bold: true, underline: true },
|
||||||
|
{ text: "gold", color: "#ffaa00" },
|
||||||
|
]);
|
||||||
|
expect(parseFormatting("§o§mold§r").segments).toEqual([{ text: "old", italic: true, strike: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the colour when formatting is added after it", () => {
|
||||||
|
expect(parseFormatting("§b§lBold aqua").segments).toEqual([
|
||||||
|
{ text: "Bold aqua", color: "#55ffff", bold: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the §x hex form", () => {
|
||||||
|
expect(parseFormatting("§x§F§f§8§8§0§0orange").segments).toEqual([
|
||||||
|
{ text: "orange", color: "#ff8800" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a broken §x, scrambled text and unknown codes without styling", () => {
|
||||||
|
expect(parseFormatting("§xhi")).toEqual({ text: "hi" });
|
||||||
|
expect(parseFormatting("§kmagic§z!")).toEqual({ text: "magic!" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads what follows a short §x as ordinary codes", () => {
|
||||||
|
expect(parseFormatting("§x§f§fhi").segments).toEqual([{ text: "hi", color: "#ffffff" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a § at the end of a line", () => {
|
||||||
|
expect(parseFormatting("tail§")).toEqual({ text: "tail" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits segments when codes only reset", () => {
|
||||||
|
expect(parseFormatting("§rplain§r")).toEqual({ text: "plain" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps ANSI SGR colours, bright colours and resets", () => {
|
||||||
|
expect(parseFormatting("\x1b[31merror\x1b[0m ok \x1b[1;92mgreat\x1b[m done")).toEqual({
|
||||||
|
text: "error ok great done",
|
||||||
|
segments: [
|
||||||
|
{ text: "error", color: "#cd3131" },
|
||||||
|
{ text: " ok " },
|
||||||
|
{ text: "great", color: "#23d18b", bold: true },
|
||||||
|
{ text: " done" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 256-colour and true-colour escapes", () => {
|
||||||
|
expect(parseFormatting("\x1b[38;5;196ma\x1b[38;5;67mb\x1b[38;5;244mc\x1b[38;2;1;2;3md").segments).toEqual([
|
||||||
|
{ text: "a", color: "#ff0000" },
|
||||||
|
{ text: "b", color: "#5f87af" },
|
||||||
|
{ text: "c", color: "#808080" },
|
||||||
|
{ text: "d", color: "#010203" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips background colours along with their arguments", () => {
|
||||||
|
expect(parseFormatting("\x1b[48;5;1;33mx")).toEqual({
|
||||||
|
text: "x",
|
||||||
|
segments: [{ text: "x", color: "#e5e510" }],
|
||||||
|
});
|
||||||
|
expect(parseFormatting("\x1b[33;48;2;9;9;9mx").segments).toEqual([{ text: "x", color: "#e5e510" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns single attributes off", () => {
|
||||||
|
expect(parseFormatting("\x1b[1;4;36mon\x1b[22moff\x1b[24;39mplain").segments).toEqual([
|
||||||
|
{ text: "on", bold: true, underline: true, color: "#11a8cd" },
|
||||||
|
{ text: "off", underline: true, color: "#11a8cd" },
|
||||||
|
{ text: "plain" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops cursor and erase escapes, and a lone ESC", () => {
|
||||||
|
expect(parseFormatting("\x1b[2K\x1b[1Gline\x1b")).toEqual({ text: "line" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lets markup through as anything but text", () => {
|
||||||
|
const out = parseFormatting('§c<img src=x onerror="alert(1)">');
|
||||||
|
expect(out.text).toBe('<img src=x onerror="alert(1)">');
|
||||||
|
expect(out.segments).toEqual([{ text: '<img src=x onerror="alert(1)">', color: "#ff5555" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// Console formatting codes. Minecraft servers and plugins colour text with §
|
||||||
|
// codes (§a green, §l bold, §r reset, and the §x§r§r§g§g§b§b hex form), and a
|
||||||
|
// server started with a colour terminal writes ANSI SGR escapes instead. Shown
|
||||||
|
// raw, both turn a log into noise. parseFormatting splits a line into styled
|
||||||
|
// segments once, when the line arrives; the console renders each segment as a
|
||||||
|
// React text node inside a styled span, so nothing from the log ever reaches
|
||||||
|
// the page as markup.
|
||||||
|
|
||||||
|
export interface SegmentStyle {
|
||||||
|
/** A CSS colour, e.g. "#55ff55". */
|
||||||
|
color?: string;
|
||||||
|
bold?: boolean;
|
||||||
|
italic?: boolean;
|
||||||
|
underline?: boolean;
|
||||||
|
strike?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Segment extends SegmentStyle {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Formatted {
|
||||||
|
/** The line with every code removed: what the level check and copy see. */
|
||||||
|
text: string;
|
||||||
|
/** Styled runs of text, absent when the line carries no styling. */
|
||||||
|
segments?: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Java Edition palette, with the two darkest colours lifted so they stay
|
||||||
|
// readable on the console's black background.
|
||||||
|
const MC_COLORS: Record<string, string> = {
|
||||||
|
"0": "#71717a",
|
||||||
|
"1": "#5c7cfa",
|
||||||
|
"2": "#00aa00",
|
||||||
|
"3": "#00aaaa",
|
||||||
|
"4": "#aa0000",
|
||||||
|
"5": "#aa00aa",
|
||||||
|
"6": "#ffaa00",
|
||||||
|
"7": "#aaaaaa",
|
||||||
|
"8": "#555555",
|
||||||
|
"9": "#5555ff",
|
||||||
|
a: "#55ff55",
|
||||||
|
b: "#55ffff",
|
||||||
|
c: "#ff5555",
|
||||||
|
d: "#ff55ff",
|
||||||
|
e: "#ffff55",
|
||||||
|
f: "#ffffff",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ANSI 30–37 and 90–97, in the same order as SGR numbers them; black is lifted
|
||||||
|
// like §0.
|
||||||
|
const ANSI_COLORS = [
|
||||||
|
"#71717a", "#cd3131", "#0dbc79", "#e5e510", "#2472c8", "#bc3fbc", "#11a8cd", "#e5e5e5",
|
||||||
|
"#8a8a8a", "#f14c4c", "#23d18b", "#f5f543", "#3b8eea", "#d670d6", "#29b8db", "#ffffff",
|
||||||
|
];
|
||||||
|
|
||||||
|
const hex2 = (n: number) => n.toString(16).padStart(2, "0");
|
||||||
|
const rgb = (r: number, g: number, b: number) => `#${hex2(r)}${hex2(g)}${hex2(b)}`;
|
||||||
|
|
||||||
|
// ansi256 maps a 256-colour index: the 16 base colours, a 6×6×6 cube, then 24
|
||||||
|
// greys.
|
||||||
|
function ansi256(n: number): string | undefined {
|
||||||
|
if (!Number.isInteger(n) || n < 0 || n > 255) return undefined;
|
||||||
|
if (n < 16) return ANSI_COLORS[n];
|
||||||
|
if (n < 232) {
|
||||||
|
const c = n - 16;
|
||||||
|
const level = (v: number) => (v === 0 ? 0 : 55 + v * 40);
|
||||||
|
return rgb(level(Math.floor(c / 36)), level(Math.floor(c / 6) % 6), level(c % 6));
|
||||||
|
}
|
||||||
|
const grey = 8 + (n - 232) * 10;
|
||||||
|
return rgb(grey, grey, grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byte = (s: string | undefined) => {
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isInteger(n) && n >= 0 && n <= 255 ? n : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// applySgr folds one SGR parameter list into the running style. Background
|
||||||
|
// colours and anything else a log has no use for are skipped.
|
||||||
|
function applySgr(style: SegmentStyle, params: string): SegmentStyle {
|
||||||
|
const p = params === "" ? ["0"] : params.split(";");
|
||||||
|
let s = { ...style };
|
||||||
|
for (let i = 0; i < p.length; i++) {
|
||||||
|
const n = Number(p[i] || "0");
|
||||||
|
if (n === 0) s = {};
|
||||||
|
else if (n === 1) s.bold = true;
|
||||||
|
else if (n === 3) s.italic = true;
|
||||||
|
else if (n === 4) s.underline = true;
|
||||||
|
else if (n === 9) s.strike = true;
|
||||||
|
else if (n === 22) s.bold = undefined;
|
||||||
|
else if (n === 23) s.italic = undefined;
|
||||||
|
else if (n === 24) s.underline = undefined;
|
||||||
|
else if (n === 29) s.strike = undefined;
|
||||||
|
else if (n >= 30 && n <= 37) s.color = ANSI_COLORS[n - 30];
|
||||||
|
else if (n >= 90 && n <= 97) s.color = ANSI_COLORS[n - 90 + 8];
|
||||||
|
else if (n === 39) s.color = undefined;
|
||||||
|
else if (n === 38 || n === 48) {
|
||||||
|
// Extended colour: 5;n or 2;r;g;b. Its arguments are consumed either
|
||||||
|
// way, so they are never read as codes of their own.
|
||||||
|
let color: string | undefined;
|
||||||
|
if (p[i + 1] === "5") {
|
||||||
|
color = ansi256(Number(p[i + 2]));
|
||||||
|
i += 2;
|
||||||
|
} else if (p[i + 1] === "2") {
|
||||||
|
const [r, g, b] = [byte(p[i + 2]), byte(p[i + 3]), byte(p[i + 4])];
|
||||||
|
if (r !== undefined && g !== undefined && b !== undefined) color = rgb(r, g, b);
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
if (n === 38 && color) s.color = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styled = (s: SegmentStyle) =>
|
||||||
|
s.color !== undefined || s.bold || s.italic || s.underline || s.strike;
|
||||||
|
|
||||||
|
const HEX_DIGIT = /^[0-9a-f]$/i;
|
||||||
|
|
||||||
|
/** parseFormatting strips § and ANSI codes from a log line and returns the
|
||||||
|
* styled runs they described. */
|
||||||
|
export function parseFormatting(raw: string): Formatted {
|
||||||
|
if (!raw.includes("§") && !raw.includes("\x1b")) return { text: raw };
|
||||||
|
|
||||||
|
const segments: Segment[] = [];
|
||||||
|
let style: SegmentStyle = {};
|
||||||
|
let run = "";
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
const setStyle = (next: SegmentStyle) => {
|
||||||
|
if (run) {
|
||||||
|
segments.push({ ...style, text: run });
|
||||||
|
run = "";
|
||||||
|
}
|
||||||
|
style = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < raw.length) {
|
||||||
|
const ch = raw[i];
|
||||||
|
if (ch === "§") {
|
||||||
|
const code = (raw[i + 1] ?? "").toLowerCase();
|
||||||
|
if (code in MC_COLORS) {
|
||||||
|
// A colour code also clears bold, italic and the rest, as in game.
|
||||||
|
setStyle({ color: MC_COLORS[code] });
|
||||||
|
} else if (code === "l") setStyle({ ...style, bold: true });
|
||||||
|
else if (code === "o") setStyle({ ...style, italic: true });
|
||||||
|
else if (code === "n") setStyle({ ...style, underline: true });
|
||||||
|
else if (code === "m") setStyle({ ...style, strike: true });
|
||||||
|
else if (code === "r") setStyle({});
|
||||||
|
else if (code === "x") {
|
||||||
|
// §x§R§R§G§G§B§B: six § pairs, one hex digit each.
|
||||||
|
let hex = "";
|
||||||
|
for (let k = 0; k < 6; k++) {
|
||||||
|
const at = i + 2 + k * 2;
|
||||||
|
if (raw[at] !== "§" || !HEX_DIGIT.test(raw[at + 1] ?? "")) break;
|
||||||
|
hex += raw[at + 1];
|
||||||
|
}
|
||||||
|
if (hex.length === 6) {
|
||||||
|
setStyle({ color: `#${hex.toLowerCase()}` });
|
||||||
|
i += 14;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// §k (scrambled text) and unknown codes only disappear. A § at the very
|
||||||
|
// end of the line has no code to take with it.
|
||||||
|
i += code ? 2 : 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\x1b") {
|
||||||
|
// CSI: ESC [ parameters final-byte. Only SGR (final "m") styles text; the
|
||||||
|
// rest (cursor moves, line clears) are dropped.
|
||||||
|
const m = /^\x1b\[([0-9;?]*)([@-~])/.exec(raw.slice(i, i + 64));
|
||||||
|
if (m) {
|
||||||
|
if (m[2] === "m") setStyle(applySgr(style, m[1]));
|
||||||
|
i += m[0].length;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
run += ch;
|
||||||
|
text += ch;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
setStyle({});
|
||||||
|
|
||||||
|
return segments.some(styled) ? { text, segments } : { text };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user