@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
380 lines (379 loc) • 16.3 kB
JavaScript
;
// 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;