270 lines
14 KiB
JavaScript
270 lines
14 KiB
JavaScript
// public/js/diag.js — the client's error log and the diagnostics a player copies into a bug report (设置 → 问题反馈).
|
|
//
|
|
// Nothing leaves the browser. The log is kept in memory (the last LOG_MAX entries, an identical error in a row counted
|
|
// on its entry); the report is plain text the player copies and pastes into a GitHub issue, with the player names,
|
|
// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in
|
|
// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle
|
|
// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev
|
|
// tools replay.
|
|
//
|
|
// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
|
|
// error the battle isolated) | 'runner' (the local battle runner) | 'request'
|
|
// (a failed request or error push the player was shown)
|
|
// errorLog() / errorCount() / clearErrorLog()
|
|
// setBattleSource(fn) the battle runner registers how to read the battle on screen
|
|
// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in)
|
|
// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen)
|
|
//
|
|
// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language.
|
|
|
|
import { APP_VERSION } from '../../shared/constants.js';
|
|
|
|
export const LOG_MAX = 40;
|
|
export const STACK_LINES = 6;
|
|
const MESSAGE_MAX = 300;
|
|
|
|
/** @type {{ source: string, message: string, stack: string|null, detail: string|null, at: number, lastAt: number, count: number }[]} */
|
|
const log = [];
|
|
let total = 0;
|
|
let dropped = 0;
|
|
const pageStart = Date.now();
|
|
|
|
/** `name: message (code)` of an Error, a NetError, a {code, msg} push, a string or anything else. */
|
|
export function messageOf(err) {
|
|
let s;
|
|
if (err == null) s = String(err);
|
|
else if (typeof err === 'string') s = err;
|
|
else if (typeof err === 'object') {
|
|
const name = typeof err.name === 'string' && err.name && err.name !== 'Error' ? `${err.name}: ` : '';
|
|
const text = typeof err.message === 'string' && err.message ? err.message : typeof err.msg === 'string' ? err.msg : '';
|
|
const code = typeof err.code === 'string' || typeof err.code === 'number' ? String(err.code) : '';
|
|
if (text) s = `${name}${text}${code && !text.includes(code) ? ` (${code})` : ''}`;
|
|
else if (code) s = `${name}${code}`;
|
|
else { try { s = JSON.stringify(err); } catch { s = Object.prototype.toString.call(err); } }
|
|
} else s = String(err);
|
|
s = s.replace(/\s+/g, ' ').trim();
|
|
return s.length > MESSAGE_MAX ? `${s.slice(0, MESSAGE_MAX - 1)}…` : s;
|
|
}
|
|
|
|
/** The first STACK_LINES frames of `err.stack`, each without the page's scheme and host (`/js/ui/x.js:12:5`). */
|
|
export function stackOf(err) {
|
|
const raw = err && typeof err === 'object' && typeof err.stack === 'string' ? err.stack : '';
|
|
if (!raw) return null;
|
|
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
// V8 starts with "Name: message"; Firefox / Safari list frames only
|
|
const frames = lines.filter((l, i) => !(i === 0 && !/^at |@|:\d+:\d+\)?$/.test(l)));
|
|
const out = frames.slice(0, STACK_LINES).map(stripHosts);
|
|
return out.length ? out.join('\n') : null;
|
|
}
|
|
|
|
/** Cut `scheme://host[:port]` from every URL (a LAN server's address, a domain): the path is what locates the code. */
|
|
export const stripHosts = (s) => String(s).replace(/\b[a-z][a-z0-9+.-]*:\/\/[^/\s)]+/gi, '');
|
|
|
|
/**
|
|
* Record an error. An entry identical to the newest one (same source, message and stack) raises its count instead.
|
|
* @param {string} source @param {any} err @param {string|null} [detail] @param {number} [at] epoch ms
|
|
*/
|
|
export function recordError(source, err, detail = null, at = Date.now()) {
|
|
const message = stripHosts(messageOf(err));
|
|
const stack = stackOf(err);
|
|
const det = detail == null ? null : stripHosts(String(detail)).slice(0, MESSAGE_MAX);
|
|
total++;
|
|
const last = log[log.length - 1];
|
|
if (last && last.source === source && last.message === message && last.stack === stack && last.detail === det) {
|
|
last.count++;
|
|
last.lastAt = at;
|
|
return last;
|
|
}
|
|
const e = { source: String(source), message, stack, detail: det, at, lastAt: at, count: 1 };
|
|
log.push(e);
|
|
while (log.length > LOG_MAX) { log.shift(); dropped++; }
|
|
return e;
|
|
}
|
|
|
|
/** The log, oldest first (copies). */
|
|
export const errorLog = () => log.map((e) => ({ ...e }));
|
|
/** Errors recorded on this page (repeats included). */
|
|
export const errorCount = () => total;
|
|
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; }
|
|
|
|
let battleSource = null;
|
|
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
|
|
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; }
|
|
/** The battle on screen for a report, or null. */
|
|
export function currentBattle() {
|
|
try { return battleSource ? battleSource() || null : null; } catch { return null; }
|
|
}
|
|
|
|
/** Secrets worth replacing: non-empty strings, later pair winning when the same value is listed twice. */
|
|
function secretMap(pairs) {
|
|
const map = new Map();
|
|
for (const [v, ph] of pairs) {
|
|
if (typeof v === 'string' && v.trim()) map.set(v, ph);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Replace each secret only as a whole token, longest first, in one pass (a placeholder is never scanned again).
|
|
* A token is bounded by the start or end of the text, or by anything that is not a letter, a digit or `_`, so
|
|
* "char" does not eat `chess_char_…`, "player" does not eat "players", and "W" does not eat "Windows". "W-W" is
|
|
* two tokens. The left boundary is a consumed character (or the start): a lookbehind is a SyntaxError in Safari
|
|
* before 16.4.
|
|
* @param {string} text @param {[string, string][]} pairs [value, placeholder]
|
|
*/
|
|
export function redact(text, pairs) {
|
|
const map = secretMap(pairs);
|
|
const list = [...map.keys()].sort((a, b) => b.length - a.length);
|
|
if (!list.length) return String(text);
|
|
const esc = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const word = '[\\p{L}\\p{N}_]';
|
|
const re = new RegExp(`(?<pre>^|(?!${word})[\\s\\S])(?<hit>${list.map(esc).join('|')})(?!${word})`, 'gu');
|
|
return String(text).replace(re, (...args) => {
|
|
const g = args[args.length - 1];
|
|
return `${g.pre}${map.get(g.hit)}`;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Copy `value`, replacing a string only when it is exactly a secret (object keys included). A longer string that
|
|
* merely contains the secret, such as a chess id or a note, is kept. This is what the attached battle JSON uses;
|
|
* token replacement would still rewrite `"charge the char"` when the name is `char`.
|
|
* @param {any} value @param {[string, string][]} pairs
|
|
*/
|
|
export function redactValues(value, pairs) {
|
|
const map = secretMap(pairs);
|
|
const walk = (v) => {
|
|
if (typeof v === 'string') return map.has(v) ? map.get(v) : v;
|
|
if (Array.isArray(v)) return v.map(walk);
|
|
if (v && typeof v === 'object') {
|
|
const out = {};
|
|
for (const [k, val] of Object.entries(v)) out[map.has(k) ? map.get(k) : k] = walk(val);
|
|
return out;
|
|
}
|
|
return v;
|
|
};
|
|
return walk(value);
|
|
}
|
|
|
|
/** [value, placeholder] pairs of what a report must not show: player names, the room code, the reconnect token. */
|
|
export function secretsOf(state) {
|
|
const pairs = [];
|
|
const names = new Map();
|
|
const addName = (n) => {
|
|
if (typeof n !== 'string' || !n.trim() || names.has(n)) return;
|
|
names.set(n, n === state?.me?.name ? '<me>' : `<player${names.size + 1}>`);
|
|
};
|
|
addName(state?.me?.name);
|
|
for (const s of state?.room?.seats || []) addName(s?.name);
|
|
for (const s of state?.room?.spectators || []) addName(s?.name);
|
|
for (const p of state?.match?.public?.players || []) addName(p?.name);
|
|
for (const [n, ph] of names) pairs.push([n, ph]);
|
|
if (state?.room?.code) pairs.push([String(state.room.code), '<room>']);
|
|
if (state?.me?.token) pairs.push([String(state.me.token), '<token>']);
|
|
return pairs;
|
|
}
|
|
|
|
const pad2 = (n) => String(n).padStart(2, '0');
|
|
const clockOf = (ms) => { const d = new Date(ms); return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`; };
|
|
const spanOf = (ms) => {
|
|
const s = Math.max(0, Math.round(ms / 1000));
|
|
return s >= 3600 ? `${Math.floor(s / 3600)}h ${pad2(Math.floor(s / 60) % 60)}m` : `${Math.floor(s / 60)}m ${pad2(s % 60)}s`;
|
|
};
|
|
const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== false).join(' | ');
|
|
|
|
/**
|
|
* The report text: a ```text block (version, time, browser, screen, connection, where the player is, settings, the
|
|
* error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
|
|
* prose and exact string values in the JSON.
|
|
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
|
|
* now?: number, pageStart?: number, version?: string }} input
|
|
*/
|
|
export function buildDiagnostics(input) {
|
|
const { state = {}, env = {}, settings = null, battle = null } = input;
|
|
const entries = input.entries ?? errorLog();
|
|
const now = input.now ?? Date.now();
|
|
const lines = [];
|
|
lines.push('Stronghold-Protocol diagnostics');
|
|
lines.push(`version: ${input.version ?? APP_VERSION}`);
|
|
lines.push(`time: ${new Date(now).toISOString()} | page open ${spanOf(now - (input.pageStart ?? pageStart))}`);
|
|
if (env.ua) lines.push(`browser: ${env.ua}`);
|
|
if (env.gpu) lines.push(`gpu: ${env.gpu}`);
|
|
lines.push(`device: ${join(env.viewport, env.dpr ? `@${env.dpr}x` : null, env.touch ? 'touch' : null, env.cores ? `${env.cores} threads` : null,
|
|
env.memoryGB ? `${env.memoryGB} GB` : null, env.lang ? `lang ${env.lang}` : null, env.uiLang ? `ui ${env.uiLang}` : null) || 'unknown'}`);
|
|
const c = state.connection || {};
|
|
lines.push(`connection: ${join(c.status || 'unknown', Number.isFinite(c.ping) ? `ping ${Math.round(c.ping)} ms` : null,
|
|
c.attempt ? `retry ${c.attempt}` : null, c.lastError ? `last error ${messageOf(c.lastError)}` : null)}`);
|
|
const pub = state.match?.public || null;
|
|
const room = state.room || null;
|
|
lines.push(`where: ${join(pub?.phase ? `phase ${pub.phase}` : room ? 'room' : state.session?.entered ? 'lobby' : 'title',
|
|
Number.isFinite(pub?.round) ? `round ${pub.round}` : null, room?.mode ? `mode ${room.mode}` : null,
|
|
room?.difficulty ? `difficulty ${room.difficulty}` : null,
|
|
Array.isArray(pub?.players) ? `players ${pub.players.length}` : Array.isArray(room?.seats) ? `seats ${room.seats.filter(Boolean).length}` : null)}`);
|
|
const b = state.match?.battle || null;
|
|
if (b) {
|
|
lines.push(`battle: ${join(b.kind, b.fieldId ? `field ${b.fieldId}` : null, b.authoritative ? 'authoritative' : b.watch ? 'watching' : null,
|
|
b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`);
|
|
}
|
|
if (settings) {
|
|
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`);
|
|
}
|
|
const shown = entries.slice().reverse();
|
|
const tot = input.total ?? total;
|
|
const drop = input.dropped ?? dropped;
|
|
lines.push('');
|
|
lines.push(`errors: ${tot ? `${tot} recorded${drop ? `, ${drop} oldest entries dropped` : ''}` : 'none recorded'}`);
|
|
shown.forEach((e, i) => {
|
|
lines.push(`#${i + 1} ${clockOf(e.at)}${e.count > 1 ? ` (x${e.count}, last ${clockOf(e.lastAt)})` : ''} ${e.source}: ${e.message}`);
|
|
if (e.detail) lines.push(` ${e.detail}`);
|
|
if (e.stack) for (const l of e.stack.split('\n')) lines.push(` ${l}`);
|
|
});
|
|
const pairs = secretsOf(state);
|
|
let text = redact(`\`\`\`text\n${lines.join('\n')}\n\`\`\``, pairs);
|
|
if (battle && battle.spec) {
|
|
const head = join(`battle ${battle.battleId}`, battle.kind, Number.isFinite(battle.spec.round) ? `round ${battle.spec.round}` : null,
|
|
Number.isFinite(battle.time) ? `copied at ${battle.time.toFixed(2)} s` : null);
|
|
const payload = redactValues({
|
|
t: 'b.start', battleId: battle.battleId, fieldId: battle.fieldId, kind: battle.kind, spec: battle.spec,
|
|
}, pairs);
|
|
text += `\n\n${redact(head, pairs)}\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
let gpuName;
|
|
/** What the report says about this browser and device (null fields stay out). */
|
|
export function browserEnv() {
|
|
const g = globalThis;
|
|
if (gpuName === undefined) {
|
|
gpuName = null;
|
|
try {
|
|
const cv = g.document?.createElement('canvas');
|
|
const gl = cv && (cv.getContext('webgl2') || cv.getContext('webgl'));
|
|
const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
|
|
gpuName = gl ? String(ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL';
|
|
} catch { gpuName = null; }
|
|
}
|
|
let uiLang = null;
|
|
try { uiLang = g.document?.documentElement?.lang || null; } catch { /* ignore */ }
|
|
return {
|
|
ua: g.navigator?.userAgent ?? null,
|
|
gpu: gpuName,
|
|
viewport: Number.isFinite(g.innerWidth) ? `${g.innerWidth}x${g.innerHeight}` : null,
|
|
dpr: g.devicePixelRatio ?? null,
|
|
touch: !!(g.matchMedia && g.matchMedia('(pointer: coarse)').matches),
|
|
cores: g.navigator?.hardwareConcurrency ?? null,
|
|
memoryGB: g.navigator?.deviceMemory ?? null,
|
|
lang: g.navigator?.language ?? null,
|
|
uiLang,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The report of this page: the given store state and settings, this browser, the log and, with `attachBattle`, the
|
|
* battle on screen.
|
|
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
|
|
*/
|
|
export function diagnosticsText({ state, settings = null, attachBattle = false }) {
|
|
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
|
|
}
|