UNPKG

@minecraft/creator-tools

Version:

Minecraft Creator Tools command line and libraries.

380 lines (379 loc) 16.3 kB
"use strict"; // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ErrorSeverity = exports.ErrorKind = void 0; /** * ErrorService — centralized routing for uncaught JavaScript errors. * * Sources fed into this service: * - GlobalErrorBoundary.componentDidCatch (React render/lifecycle errors) * - window "error" listener (uncaught synchronous errors) * - window "unhandledrejection" listener (uncaught Promise rejections) * - Electron preload IPC bridge (fatal errors from the main process) * * Sinks fanned out from this service: * - Subscribers to onErrorReported (the GlobalErrorOverlay React surface) * - telemetryService.trackException (mirrors App.setHomeWithError shape) * - Log.error (for parity with the rest of the app) * * Design notes: * - This is a singleton so window listeners and React components can all * converge on the same state without prop drilling. * - addFilter() lets feature code suppress known-benign errors (e.g. cancelled * fetches, Monaco loader noise) without touching this file. * - A recursion guard prevents an exception thrown inside report() from * re-entering and looping forever. * - Save handler is registered by App once it knows how to save. The overlay * calls runSaveAll() so the user can persist work before reloading. */ const ste_events_1 = require("ste-events"); const Log_1 = __importDefault(require("./Log")); const Telemetry_1 = __importDefault(require("../analytics/Telemetry")); const TelemetryConstants_1 = require("../analytics/TelemetryConstants"); const CreatorToolsHost_1 = __importStar(require("../app/CreatorToolsHost")); var ErrorKind; (function (ErrorKind) { ErrorKind["reactRender"] = "reactRender"; ErrorKind["windowError"] = "windowError"; ErrorKind["unhandledRejection"] = "unhandledRejection"; ErrorKind["electronMain"] = "electronMain"; })(ErrorKind || (exports.ErrorKind = ErrorKind = {})); var ErrorSeverity; (function (ErrorSeverity) { /** Render-time error — the React subtree is broken; show full-screen overlay. */ ErrorSeverity["fatal"] = "fatal"; /** UI is still functional — show a dismissible dialog. */ ErrorSeverity["recoverable"] = "recoverable"; })(ErrorSeverity || (exports.ErrorSeverity = ErrorSeverity = {})); class ErrorServiceImpl { _nextId = 1; _errors = []; _filters = []; _categorizers = []; _saveAllHandler; _isReporting = false; _onErrorReported = new ste_events_1.EventDispatcher(); _onErrorsCleared = new ste_events_1.EventDispatcher(); /** Fires whenever a new error makes it past the filters. */ get onErrorReported() { return this._onErrorReported.asEvent(); } /** Fires when dismissAll() is called. */ get onErrorsCleared() { return this._onErrorsCleared.asEvent(); } /** All errors captured this session, in arrival order (most recent last). */ get errors() { return this._errors; } /** Whether this host has any UI surface that could show the overlay. */ get isUiHost() { const t = CreatorToolsHost_1.default.hostType; return (t === CreatorToolsHost_1.HostType.web || t === CreatorToolsHost_1.HostType.electronWeb || t === CreatorToolsHost_1.HostType.vsCodeMainWeb || t === CreatorToolsHost_1.HostType.vsCodeWebWeb || t === CreatorToolsHost_1.HostType.webPlusServices); } /** Register a predicate that returns true to suppress matching errors. */ addFilter(filter) { this._filters.push(filter); } /** * Register a categorizer that tags matching errors with a triage label. * Tagged errors still flow to telemetry; the category is forwarded as the * `errorCode` property so dashboards can group by it. */ addCategorizer(categorizer) { this._categorizers.push(categorizer); } /** Register the function the overlay should call when the user clicks "Save all". */ setSaveAllHandler(handler) { this._saveAllHandler = handler; } /** Whether a save handler is currently registered (used to enable/disable button). */ get hasSaveAllHandler() { return !!this._saveAllHandler; } /** * Run the registered save-all handler. Always resolves; never throws into the caller. * Returns true on success, false on failure (with the error swallowed and logged). */ async runSaveAll() { if (!this._saveAllHandler) { return false; } try { await this._saveAllHandler(); return true; } catch (e) { // Swallow so the overlay UI isn't itself broken by a save failure. Log_1.default.error("ErrorService: save-all handler threw: " + (e instanceof Error ? e.message : String(e))); return false; } } /** * Record an error and notify subscribers. Returns the captured error (with id + * timestamp filled in) or undefined if it was suppressed by a filter. */ report(input) { // Recursion guard: if the act of reporting throws, do not re-enter. if (this._isReporting) { return undefined; } this._isReporting = true; try { const captured = { id: this._nextId++, timestamp: input.timestamp ?? new Date(), kind: input.kind, severity: input.severity, message: input.message || "(no message)", stack: input.stack, componentStack: input.componentStack, source: input.source, category: input.category, }; // Run categorizers first so filters can see the assigned tag if they // want to. The first categorizer to return a non-undefined value wins; // explicit input.category takes precedence over any categorizer. if (!captured.category) { for (const categorizer of this._categorizers) { try { const tag = categorizer(captured); if (tag) { captured.category = tag; break; } } catch { // Ignore categorizer errors — they should never block reporting. } } } // Run filters; any throwing filter is treated as "do not suppress". for (const filter of this._filters) { try { if (filter(captured)) { return undefined; } } catch { // Ignore filter errors — better to over-report than to drop signal. } } this._errors.push(captured); // Mirror to Log so it shows up alongside other diagnostics. try { Log_1.default.error(`[ErrorService] ${captured.kind}: ${captured.message}` + (captured.stack ? "\n" + captured.stack : "")); } catch { // ignore } // Telemetry — same shape as App.setHomeWithError. try { const exception = input.stack && typeof Error !== "undefined" ? Object.assign(new Error(captured.message), { stack: captured.stack }) : new Error(captured.message); Telemetry_1.default.trackException({ exception, properties: { [TelemetryConstants_1.TelemetryProperties.ERROR_MESSAGE]: captured.message, [TelemetryConstants_1.TelemetryProperties.LOCATION]: "ErrorService.report", [TelemetryConstants_1.TelemetryProperties.ACTION_SOURCE]: captured.kind, ...(captured.category ? { [TelemetryConstants_1.TelemetryProperties.ERROR_CODE]: captured.category } : {}), }, severityLevel: captured.severity === ErrorSeverity.fatal ? TelemetryConstants_1.TelemetrySeverity.CRITICAL : TelemetryConstants_1.TelemetrySeverity.ERROR, }); } catch { // ignore telemetry failures } // Fan out to the React overlay. try { this._onErrorReported.dispatch(this, captured); } catch { // ignore subscriber failures } return captured; } finally { this._isReporting = false; } } /** Remove a single error from the list. */ dismiss(id) { const idx = this._errors.findIndex((e) => e.id === id); if (idx >= 0) { this._errors.splice(idx, 1); } } /** Clear every captured error. */ dismissAll() { if (this._errors.length === 0) { return; } this._errors = []; try { this._onErrorsCleared.dispatch(this, undefined); } catch { // ignore } } /** * Build a rich text payload suitable for the "Report this error" clipboard * action. Includes app version + host so issue reports are self-contained. */ formatForReport(error) { // Resolve version lazily to avoid a hard dep on Constants in tests. let version = "unknown"; try { // eslint-disable-next-line @typescript-eslint/no-var-requires const constants = require("./Constants").constants; if (constants?.version) { version = constants.version; } } catch { // ignore } const lines = [ "Minecraft Creator Tools — error report", `Version: ${version}`, `Host: ${CreatorToolsHost_1.HostType[CreatorToolsHost_1.default.hostType] ?? CreatorToolsHost_1.default.hostType}`, `Kind: ${error.kind}`, `Time: ${error.timestamp.toISOString()}`, `Message: ${error.message}`, ]; if (error.source) { lines.push(`Source: ${error.source}`); } if (error.componentStack) { lines.push("", "Component stack:", error.componentStack); } if (error.stack) { lines.push("", "Stack:", error.stack); } return lines.join("\n"); } /** * Build a URL that opens a prefilled GitHub issue against the public Minecraft * Creator Tools repo. The body is composed as a Markdown report with * collapsed <details> sections for the long stack traces. The total URL is * truncated to a safe length (~7.5 KB) because most browsers and GitHub * itself cap query strings around 8 KB — if the report is longer, we trim the * stack and append a notice telling the user to paste the full report (which * the overlay copies to the clipboard at the same time) into the issue body. */ buildGitHubIssueUrl(error) { const base = "https://github.com/Mojang/minecraft-creator-tools/issues/new"; // Resolve version lazily (same approach as formatForReport). let version = "unknown"; try { // eslint-disable-next-line @typescript-eslint/no-var-requires const constants = require("./Constants").constants; if (constants?.version) { version = constants.version; } } catch { // ignore } const host = CreatorToolsHost_1.HostType[CreatorToolsHost_1.default.hostType] ?? String(CreatorToolsHost_1.default.hostType); // `navigator` only exists in browser/webview hosts. The library build // (tsconfig.lib.json) targets Node and does not pull in the DOM lib, so // reach for the global indirectly to avoid a TS2304 in those configs. const navAny = globalThis.navigator; const userAgent = navAny && typeof navAny.userAgent === "string" ? navAny.userAgent : "n/a"; // Title kept short — GitHub displays only ~80 chars in lists. const titleRaw = `Uncaught error (${error.kind}): ${error.message}`; const title = titleRaw.length > 120 ? titleRaw.slice(0, 117) + "…" : titleRaw; const sections = [ "### What happened", "_(please describe what you were doing when this error appeared)_", "", "### Environment", `- **Version:** ${version}`, `- **Host:** ${host}`, `- **Kind:** ${error.kind}`, `- **Severity:** ${error.severity}`, `- **Time:** ${error.timestamp.toISOString()}`, `- **User agent:** ${userAgent}`, "", "### Error message", "```", error.message, "```", ]; if (error.source) { sections.push("", `**Source:** \`${error.source}\``); } if (error.componentStack) { sections.push("", "### Component stack", "```", error.componentStack.trim(), "```"); } if (error.stack) { sections.push("", "### Stack trace", "```", error.stack.trim(), "```"); } const buildUrl = (body) => `${base}?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`; let body = sections.join("\n"); let url = buildUrl(body); // Most browsers cap URLs around 8 KB; GitHub itself rejects very long ones. // Strip the stack/component-stack sections first if we're over the limit, then hard-trim. const MAX_URL = 7500; if (url.length > MAX_URL) { const truncationNote = "\n\n> _Stack trace was too long to fit in the URL. Please paste the full report (copied to your clipboard) here._"; // Drop the stack sections (everything from the first ### Stack/Component header onward). const stripStacks = (text) => text.replace(/\n*### (?:Stack trace|Component stack)[\s\S]*$/m, ""); body = stripStacks(body) + truncationNote; url = buildUrl(body); if (url.length > MAX_URL) { const overflow = url.length - MAX_URL; const trimmedBody = body.slice(0, Math.max(0, body.length - overflow - 32)) + "\n\n> _(truncated)_"; url = buildUrl(trimmedBody); } } return url; } } const ErrorService = new ErrorServiceImpl(); exports.default = ErrorService;