perf(panel): 日志控制台按帧批量提交、分块渲染只重绘变动块,§ 与 ANSI 颜色码解析为样式文本

This commit is contained in:
Lemon-miaow committed 2026-09-25 11:07:11 +08:00
1 parent 175c9721d4
commit 34ea733775
8 files changed
+680 -39

No files matched your search

+10
View File
@@ -1629,6 +1629,10 @@ function streamBuildLogs(
Connection: "keep-alive",
});
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(() => {
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:03] [Server thread/INFO]: Preparing spawn area: 100%",
"[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;
@@ -2138,6 +2144,10 @@ function streamConsole(
Connection: "keep-alive",
});
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(() => {
res.write(`data: ${lines[i % lines.length]}\n\n`);
+106
View File
@@ -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);
});
});
+39 -8
View File
@@ -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 { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
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
// 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;
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 }) {
const { t } = useTranslation("servers");
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
* 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 }) {
const { t } = useTranslation("servers");
const { lines, status, clear, reconnect } = useLogStream(url);
const chunks = useMemo(() => chunkLines(lines), [lines]);
const scrollRef = useRef<HTMLDivElement>(null);
const [pinned, setPinned] = useState(true);
@@ -113,11 +148,7 @@ export function LogConsole({ url, className }: { url: string; className?: string
: t("log_waiting")}
</p>
) : (
lines.map((line) => (
<div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}>
{line.text || "\u00A0"}
</div>
))
chunks.map((chunk) => <LogChunk key={chunk.key} lines={chunk.lines} />)
)}
</div>
+28
View File
@@ -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
View File
@@ -4,6 +4,7 @@ import {
classifyLogLine,
type EventSourceFactory,
type EventSourceLike,
type FrameScheduler,
} from "./logstream";
// 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 factory: EventSourceFactory = (url) => {
const es = new FakeEventSource(url);
created.push(es);
return es;
};
return { factory, created };
return { factory, created, frames: new ManualFrames() };
}
describe("classifyLogLine", () => {
@@ -84,8 +105,8 @@ describe("classifyLogLine", () => {
describe("LogStreamController", () => {
it("transitions connecting -> open and appends classified lines", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory });
const { factory, created, frames } = makeFactory();
const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory, frame: frames.schedule });
ctrl.open();
expect(created).toHaveLength(1);
expect(created[0].url).toBe("/api/v1/servers/s/console");
@@ -95,6 +116,7 @@ describe("LogStreamController", () => {
expect(ctrl.getSnapshot().status).toBe("open");
created[0].emitMessage("[12:00:00] [Server thread/WARN]: heads up");
frames.run();
const { lines } = ctrl.getSnapshot();
expect(lines).toHaveLength(1);
expect(lines[0]).toMatchObject({ seq: 0, level: "warn" });
@@ -102,12 +124,13 @@ describe("LogStreamController", () => {
});
it("bounds the ring buffer, dropping the oldest lines", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 2 });
const { factory, created, frames } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 2, frame: frames.schedule });
ctrl.open();
created[0].emitMessage("line0");
created[0].emitMessage("line1");
created[0].emitMessage("line2");
frames.run();
const { lines } = ctrl.getSnapshot();
expect(lines).toHaveLength(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", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
const { factory, created, frames } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
ctrl.open(); // status already 'connecting' -> no commit, snapshot stable
const s1 = ctrl.getSnapshot();
expect(ctrl.getSnapshot()).toBe(s1);
created[0].emitMessage("x");
expect(ctrl.getSnapshot()).toBe(s1); // queued until the frame
frames.run();
expect(ctrl.getSnapshot()).not.toBe(s1);
});
it("notifies subscribers on mutation and stops after unsubscribe", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
const { factory, created, frames } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
ctrl.open();
let hits = 0;
const unsub = ctrl.subscribe(() => {
hits++;
});
created[0].emitOpen(); // commit
created[0].emitMessage("a"); // commit
created[0].emitMessage("a");
frames.run(); // commit
expect(hits).toBe(2);
unsub();
created[0].emitMessage("b");
frames.run();
expect(hits).toBe(2); // no further notifications
});
it("maps a transient drop to reconnecting and a fatal close to ended", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
const { factory, created, frames } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
ctrl.open();
created[0].emitOpen();
created[0].emitError(0); // readyState CONNECTING -> auto-retrying
@@ -153,8 +180,8 @@ describe("LogStreamController", () => {
});
it("ignores events after close (teardown linchpin)", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
const { factory, created, frames } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
ctrl.open();
created[0].emitOpen();
ctrl.close();
@@ -167,8 +194,8 @@ describe("LogStreamController", () => {
});
it("reconnect closes the old stream and dials a fresh one", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
const { factory, created, frames } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
ctrl.open();
created[0].emitError(2); // ended
ctrl.reconnect();
@@ -180,27 +207,108 @@ describe("LogStreamController", () => {
});
it("clear empties the buffer without disturbing status", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
const { factory, created, frames } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory, frame: frames.schedule });
ctrl.open();
created[0].emitOpen();
created[0].emitMessage("a");
frames.run();
created[0].emitMessage("b"); // still queued
ctrl.clear();
frames.run();
expect(ctrl.getSnapshot().lines).toHaveLength(0);
expect(ctrl.getSnapshot().status).toBe("open");
});
it("ends for good when the server revokes the stream", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory });
const { factory, created, frames } = makeFactory();
const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory, frame: frames.schedule });
ctrl.open();
const es = created[0];
es.emitOpen();
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(es.closed).toBe(true);
expect(ctrl.getSnapshot().lines).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();
});
});
+66 -9
View File
@@ -7,9 +7,12 @@
//
// This module is deliberately framework-agnostic: the EventSource is injected
// (see EventSourceFactory) so the lifecycle — status transitions, the bounded
// ring buffer, reconnect, teardown — is unit-tested under Node against a fake,
// not left to a "tsc + build" green that only proves it compiles. The thin React
// binding lives in useLogStream.ts; the viewport in components/LogConsole.tsx.
// ring buffer, frame batching, reconnect, teardown — is unit-tested under Node
// against a fake, not left to a "tsc + build" green that only proves it
// 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. */
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. */
export interface LogLine {
seq: number;
/** The line without § or ANSI codes. */
text: string;
level: LogLevel;
/** Styled runs when the line carried colour codes (see mcformat.ts). */
segments?: Segment[];
}
/** 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). */
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 {
url: string;
factory: EventSourceFactory;
/** Ring-buffer bound; older lines are dropped past this (default 2000). */
maxLines?: number;
/** Defers a batch commit; requestAnimationFrame in the browser. */
frame?: FrameScheduler;
}
const DEFAULT_MAX_LINES = 2000;
@@ -114,6 +134,9 @@ export function classifyLogLine(line: string): LogLevel {
*
* - status transitions driven by EventSource events,
* - 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`),
* - 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.
@@ -121,11 +144,15 @@ export function classifyLogLine(line: string): LogLevel {
export class LogStreamController {
private es: EventSourceLike | null = null;
private lines: LogLine[] = [];
// Lines that arrived since the last frame.
private pending: LogLine[] = [];
private cancelFrame: (() => void) | null = null;
private status: StreamStatus = "connecting";
private seq = 0;
private readonly url: string;
private readonly factory: EventSourceFactory;
private readonly maxLines: number;
private readonly frame: FrameScheduler;
private readonly listeners = new Set<() => void>();
// snapshot is rebuilt only inside commit(), so getSnapshot returns a stable
// reference between mutations — required by useSyncExternalStore to avoid an
@@ -136,6 +163,7 @@ export class LogStreamController {
this.url = opts.url;
this.factory = opts.factory;
this.maxLines = opts.maxLines ?? DEFAULT_MAX_LINES;
this.frame = opts.frame ?? nextFrame;
this.snapshot = { lines: this.lines, status: this.status };
}
@@ -161,12 +189,14 @@ export class LogStreamController {
/** clear empties the buffer without touching the stream. */
clear(): void {
this.dropPending();
this.lines = [];
this.commit();
}
/** close tears the stream down for good. Idempotent. */
close(): void {
this.dropPending();
this.disconnect();
}
@@ -208,17 +238,44 @@ export class LogStreamController {
}
}
private pushLine(text: string): void {
const next = this.lines.concat({ seq: this.seq++, text, level: classifyLogLine(text) });
if (next.length > this.maxLines) {
next.splice(0, next.length - this.maxLines);
private pushLine(raw: string): void {
const { text, segments } = parseFormatting(raw);
const line: LogLine = { seq: this.seq++, text, level: classifyLogLine(text) };
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.commit();
this.cancelFrame ??= this.frame(() => {
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 {
if (this.status === s) return;
// Lines that came before the change are shown with it, never after.
this.flushPending();
this.status = s;
this.commit();
}
+111
View File
@@ -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" }]);
});
});
+190
View File
@@ -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 };
}