weer
Version:
Web Extensions Error Reporter catches global errors, shows notifications and opens error reporter in one click
887 lines (678 loc) • 23.1 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.Weer = factory());
}(this, (function () { 'use strict';
function createCommonjsModule(fn, module) {
return module = { exports: {} }, fn(module, module.exports), module.exports;
}
var errio = createCommonjsModule(function (module, exports) {
// Default options for all serializations.
var defaultOptions = {
recursive: true, // Recursively serialize and deserialize nested errors
inherited: true, // Include inherited properties
stack: false, // Include stack property
private: false, // Include properties with leading or trailing underscores
exclude: [], // Property names to exclude (low priority)
include: [] // Property names to include (high priority)
};
// Overwrite global default options.
exports.setDefaults = function(options) {
for (var key in options) defaultOptions[key] = options[key];
};
// Object containing registered error constructors and their options.
var errors = {};
// Register an error constructor for serialization and deserialization with
// option overrides. Name can be specified in options, otherwise it will be
// taken from the prototype's name property (if it is not set to Error), the
// constructor's name property, or the name property of an instance of the
// constructor.
exports.register = function(constructor, options) {
options = options || {};
var prototypeName = constructor.prototype.name !== 'Error'
? constructor.prototype.name
: null;
var name = options.name
|| prototypeName
|| constructor.name
|| new constructor().name;
errors[name] = { constructor: constructor, options: options };
};
// Register an array of error constructors all with the same option overrides.
exports.registerAll = function(constructors, options) {
constructors.forEach(function(constructor) {
exports.register(constructor, options);
});
};
// Shallow clone a plain object.
function cloneObject(object) {
var clone = {};
for (var key in object) {
if (object.hasOwnProperty(key)) clone[key] = object[key];
}
return clone;
}
// Register a plain object of constructor names mapped to constructors with
// common option overrides.
exports.registerObject = function(constructors, commonOptions) {
for (var name in constructors) {
if (!constructors.hasOwnProperty(name)) continue;
var constructor = constructors[name];
var options = cloneObject(commonOptions);
options.name = name;
exports.register(constructor, options);
}
};
// Register the built-in error constructors.
exports.registerAll([
Error,
EvalError,
RangeError,
ReferenceError,
SyntaxError,
TypeError,
URIError
]);
// Serialize an error instance to a plain object with option overrides, applied
// on top of the global defaults and the registered option overrides. If the
// constructor of the error instance has not been registered yet, register it
// with the provided options.
exports.toObject = function(error, callOptions) {
callOptions = callOptions || {};
if (!errors[error.name]) {
// Make sure we register with the name of this instance.
callOptions.name = error.name;
exports.register(error.constructor, callOptions);
}
var errorOptions = errors[error.name].options;
var options = {};
for (var key in defaultOptions) {
if (callOptions.hasOwnProperty(key)) options[key] = callOptions[key];
else if (errorOptions.hasOwnProperty(key)) options[key] = errorOptions[key];
else options[key] = defaultOptions[key];
}
// Always explicitly include essential error properties.
var object = {
name: error.name,
message: error.message
};
// Explicitly include stack since it is not always an enumerable property.
if (options.stack) object.stack = error.stack;
for (var prop in error) {
// Skip exclusion checks if property is in include list.
if (options.include.indexOf(prop) === -1) {
if (typeof error[prop] === 'function') continue;
if (options.exclude.indexOf(prop) !== -1) continue;
if (!options.inherited)
if (!error.hasOwnProperty(prop)) continue;
if (!options.stack)
if (prop === 'stack') continue;
if (!options.private)
if (prop[0] === '_' || prop[prop.length - 1] === '_') continue;
}
var value = error[prop];
// Recurse if nested object has name and message properties.
if (typeof value === 'object' && value && value.name && value.message) {
if (options.recursive) {
object[prop] = exports.toObject(value, callOptions);
}
continue;
}
object[prop] = value;
}
return object;
};
// Deserialize a plain object to an instance of a registered error constructor
// with option overrides. If the specific constructor is not registered,
// return a generic Error instance. If stack was not serialized, capture a new
// stack trace.
exports.fromObject = function(object, callOptions) {
callOptions = callOptions || {};
var registration = errors[object.name];
if (!registration) registration = errors.Error;
var constructor = registration.constructor;
var errorOptions = registration.options;
var options = {};
for (var key in defaultOptions) {
if (callOptions.hasOwnProperty(key)) options[key] = callOptions[key];
else if (errorOptions.hasOwnProperty(key)) options[key] = errorOptions[key];
else options[key] = defaultOptions[key];
}
// Instantiate the error without actually calling the constructor.
var error = Object.create(constructor.prototype);
for (var prop in object) {
// Recurse if nested object has name and message properties.
if (options.recursive && typeof object[prop] === 'object') {
var nested = object[prop];
if (nested && nested.name && nested.message) {
error[prop] = exports.fromObject(nested, callOptions);
continue;
}
}
error[prop] = object[prop];
}
// Capture a new stack trace such that the first trace line is the caller of
// fromObject.
if (!error.stack && Error.captureStackTrace) {
Error.captureStackTrace(error, exports.fromObject);
}
return error;
};
// Serialize an error instance to a JSON string with option overrides.
exports.stringify = function(error, callOptions) {
return JSON.stringify(exports.toObject(error, callOptions));
};
// Deserialize a JSON string to an instance of a registered error constructor.
exports.parse = function(string, callOptions) {
return exports.fromObject(JSON.parse(string), callOptions);
};
});
var errio_1 = errio.setDefaults;
var errio_2 = errio.register;
var errio_3 = errio.registerAll;
var errio_4 = errio.registerObject;
var errio_5 = errio.toObject;
var errio_6 = errio.fromObject;
var errio_7 = errio.stringify;
var errio_8 = errio.parse;
/*
# Purpose
1. `timeouted` wrapper that makes error catching possible.
2. Convert error-first callbacks for use by chrome API: `chromified`.
3. Add utils for safer coding: `mandatory`, `throwIfError`.
*/
const Utils = {
mandatory() {
throw new TypeError('Missing required argument. Be explicit if you swallow errors.');
},
throwIfError(err) {
if (err) {
throw err;
}
},
checkChromeError() {
// Chrome API calls your cb in a context different from the point of API
// method invokation.
const err = chrome.runtime.lastError || chrome.extension.lastError;
if (!err) {
return;
}
/*
Example of lastError:
`chrome.runtime.openOptionsPage(() => console.log(chrome.runtime.lastError))`
{message: "Could not create an options page."}
*/
return new Error(err.message); // Add stack.
},
timeouted(cb = Utils.mandatory) {
// setTimeout fixes error context, see https://crbug.com/357568
return (...args) => { setTimeout(() => cb(...args), 0); };
},
chromified(cb = Utils.mandatory()) {
// Take error first callback and convert it to chrome API callback.
return function wrapper(...args) {
const err = Utils.checkChromeError();
Utils.timeouted(cb)(err, ...args);
};
},
getOrDie(cb = Utils.mandatory()) {
return Utils.chromified((err, ...args) => {
if (err) {
throw err;
}
cb(...args);
});
},
assert(value, message) {
if (!value) {
throw new Error(message || `Assertion failed, value: ${value}`);
}
},
errorToPlainObject(error) {
return errio.toObject(error, { stack: true, private: true });
},
errorEventToPlainObject(errorEvent) {
const plainObj = [
'message',
'filename',
'lineno',
'colno',
'type',
'path',
].reduce((acc, prop) => {
acc[prop] = errorEvent[prop];
return acc;
}, {});
if (plainObj.path) {
const pathStr = plainObj.path.map((o) => {
let res = '';
if (o.tagName) {
res += `<${o.tagName.toLowerCase()}`;
if (o.attributes) {
res += Array.from(o.attributes).map((atr) => ` ${atr.name}="${atr.value}"`).join('');
}
res += '>';
}
if (!res) {
res += `${o}`;
}
return res;
}).join(', ');
plainObj.path = `[${pathStr}]`;
}
if (errorEvent.error && typeof errorEvent === 'object') {
plainObj.error = this.errorToPlainObject(errorEvent.error);
} else {
plainObj.error = errorEvent.error;
}
return plainObj;
},
};
var Debug = window.debug || ((/* Logger ID */) => (/* Log message */) => { /* Ignore all */ });
const debug = Debug('weer:catcher');
const bgName = 'BG';
var ErrorCatchers = {
installListenersOn({
hostWindow = window,
nameForDebug = bgName,
handleErrorMessage,
} = {}, cb) {
const ifInBg = hostWindow === window;
const bgIsBg = `Background window can't have name other than "${bgName}". `
+ `Default value is "${bgName}".`;
if (ifInBg) {
Utils.assert(
typeof handleErrorMessage === 'function',
'Messaging from BG window to itself is not allowed,'
+ ' provide message handler for such cases.',
);
Utils.assert(nameForDebug === 'BG', bgIsBg);
} else {
Utils.assert(nameForDebug !== 'BG', bgIsBg);
}
const listener = (errorEvent) => {
debug(nameForDebug, errorEvent);
const plainObj = Utils.errorEventToPlainObject(errorEvent);
const msg = {
to: 'error-reporter',
payload: plainObj,
};
if (ifInBg) {
// Because self messaging is not allowed.
handleErrorMessage(msg);
} else {
hostWindow.chrome.runtime.sendMessage(msg);
}
};
const ifUseCapture = true;
hostWindow.addEventListener('error', listener, ifUseCapture);
const rejHandler = (event) => {
event.preventDefault();
debug(nameForDebug, 'rethrowing promise...');
throw event.reason;
};
hostWindow.addEventListener('unhandledrejection', rejHandler, ifUseCapture);
if (cb) {
Utils.timeouted(cb)();
}
return function uninstallListeners() {
hostWindow.removeEventListener('error', listener, ifUseCapture);
hostWindow.removeEventListener('unhandledrejection', rejHandler, ifUseCapture);
};
},
};
/*
# Documentation
1. https://developer.chrome.com/extensions/manifest/version
2. https://www.chromium.org/developers/version-numbers
# Purpose
1. Compare versions.
2. Shorthands for getting info about version (full version, build).
*/
const versionLimit = 2 ** 16;
const versionToArray = (vStr) => [...vStr.split('.'), 0, 0, 0].slice(0, 4);
const versionToInteger = (vStr) =>
versionToArray(vStr)
.reverse()
.reduce(
(acc, value, i) =>
acc + (parseInt(value, 10) * (versionLimit ** i)),
0,
);
const currentVersion = chrome.runtime.getManifest().version;
const Versions = {
current: currentVersion,
currentBuild: versionToArray(currentVersion).slice(-2).join('.'),
compare: (a, b) => versionToInteger(a) - versionToInteger(b),
};
const getSettingsAsync = () =>
new Promise((resolve) =>
chrome.proxy.settings.get(
{},
Utils.getOrDie(resolve),
),
);
const ProxySettings = {
/*
* Possible values for levelOfControl:
*
* 1. "not_controllable"
* 2. "controlled_by_other_extensions"
* 3. "controllable_by_this_extension"
* 4. "controlled_by_this_extension"
*
* See: https://developer.chrome.com/extensions/proxy
* */
async areControllableAsync(details_) {
const details = details_ || await getSettingsAsync();
return details.levelOfControl.endsWith('this_extension');
},
async areControlledAsync(details_) {
const details = details_ || await getSettingsAsync();
return details.levelOfControl.startsWith('controlled_by_this');
},
messages: {
searchSettingsForAsUrl(niddle) {
// `niddle` may be: 'proxy'.
const localedNiddle = chrome.i18n.getMessage(niddle) || niddle;
return `chrome://settings/search#${localedNiddle}`;
},
whichExtensionAsHtml() {
// Example: "Other extension controls proxy! <a...>Which?</a>"
const otherMsg = chrome.i18n.getMessage('errreporter_noControl') || 'Other extension controls proxy!';
return `
${otherMsg}
<a href="${this.searchSettingsForAsUrl('proxy')}">
${chrome.i18n.getMessage('errreporter_which') || 'Which?'}
</a>`;
},
},
};
const CreateLocalStorage = function CreateLocalStorage(prefix) {
return function state(originalKey, value) {
const key = prefix + originalKey;
if (value === null) {
return window.localStorage.removeItem(key);
}
if (value === undefined) {
const item = window.localStorage.getItem(key);
return item && JSON.parse(item);
}
if (value instanceof Date) {
throw new TypeError('Converting Date format to JSON is not supported.');
}
window.localStorage.setItem(key, JSON.stringify(value));
};
};
const debug$1 = Debug('weer:notifier');
/*
Loads icon by url or generates icon from text when offline.
Returns blob url.
*/
const loadIconAsBlobUrlAsync = function loadIconAsBlobUrlAsync(iconUrl = Utils.mandatory()) {
const img = new Image();
img.crossOrigin = 'anonymous';
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
return new Promise((resolve) => {
const dumpCanvas = () =>
canvas.toBlob(
(blob) => resolve(URL.createObjectURL(blob)),
);
img.onload = () => {
ctx.drawImage(img, 0, 0, size, size);
dumpCanvas();
};
img.onerror = () => {
// I did my best centering it.
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, size, size);
ctx.font = '50px arial';
ctx.fillStyle = 'white';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
const half = size / 2;
ctx.fillText('error', half, half, size);
dumpCanvas();
};
img.src = iconUrl;
});
};
const notyPrefix = 'reporter-';
const ifPrefix = 'if-on-';
const extName = chrome.runtime.getManifest().name;
const extBuild = Versions.currentBuild;
const defaultClickHandler = function defaultClickHandler(
{
toEmail = Utils.mandatory(),
reportLangs = Utils.mandatory(),
},
message,
report,
) {
const json = JSON.stringify(report);
const url = `${
'https://error-reporter.github.io/v0/error/view/?title={{message}}&json={{json}}&reportLangs={{reportLangs}}'
.replace('{{message}}', encodeURIComponent(message))
.replace('{{json}}', encodeURIComponent(json))
.replace('{{reportLangs}}', encodeURIComponent(reportLangs.join(',')))
}#toEmail=${encodeURIComponent(toEmail)}`;
chrome.tabs.create(
{ url },
(tab) => chrome.windows.update(tab.windowId, { focused: true }),
);
};
const createErrorNotifiers = (
{
sendReports: {
toEmail = undefined,
inLanguages = ['en'],
},
onNotificationClick = defaultClickHandler,
// Icons:
extErrorIconUrl = 'https://error-reporter.github.io/v0/icons/ext-error-128.png',
pacErrorIconUrl = 'https://error-reporter.github.io/v0/icons/pac-error-128.png',
maskIconUrl = false,
} = {},
) => {
let onNotyClick;
{
const ifDefault = onNotificationClick === defaultClickHandler;
Utils.assert(
!ifDefault || (toEmail && inLanguages && inLanguages.length),
'Default click handler requires { sendReports: { toEmail: \'foo@example.com\', inLanguages: [\'en\'] } } config to be set.',
);
onNotyClick = ifDefault
? (...args) =>
onNotificationClick(
{
toEmail,
reportLangs: [...new Set(inLanguages)].map(
(lang) => lang.toLowerCase(),
),
},
...args,
)
: onNotificationClick;
}
const errorTypeToIconUrl = {
'ext-error': extErrorIconUrl,
'pac-error': pacErrorIconUrl,
};
const errorNotifiers = {
state: CreateLocalStorage('error-handlers-'),
// ErrorLike is `{message: ...}`. E.g. ErrorEvent.
typeToErrorLike: {},
viewError(errorType) {
const payload = this.typeToErrorLike[errorType] || this.typeToErrorLike;
const report = Object.assign({}, {
payload,
errorType,
extName,
version: Versions.current,
userAgent: navigator.userAgent,
platform: navigator.platform,
});
const err = payload.error || payload;
const msg = (err && err.message) || 'I Found a Bug';
onNotyClick(msg, report);
},
isOn(eventName = Utils.mandatory()) {
Utils.assert(['ext-error', 'pac-error'].includes(eventName));
// 'On' by default.
return this.state(ifPrefix + eventName) !== 'off';
},
async mayNotify(
errorType,
title,
errorLikeOrMessage = Utils.mandatory(),
{
context = `${extName} ${extBuild}`,
ifSticky = true,
} = {},
) {
if (!this.isOn(errorType)) {
return Promise.resolve(false);
}
this.typeToErrorLike[errorType] = errorLikeOrMessage;
const message = errorLikeOrMessage.message || errorLikeOrMessage.toString();
const iconUrl = await loadIconAsBlobUrlAsync(
errorTypeToIconUrl[errorType],
);
const opts = {
title,
message,
contextMessage: context,
type: 'basic',
iconUrl,
isClickable: true,
};
if (maskIconUrl) {
const url = await loadIconAsBlobUrlAsync(maskIconUrl);
Object.assign(opts, {
appIconMaskUrl: url,
});
}
if (!/Firefox/.test(navigator.userAgent)) {
Object.assign(opts, {
requireInteraction: ifSticky,
});
}
return new Promise(
(resolve) =>
chrome.notifications.create(
`${notyPrefix}${errorType}`,
opts,
() => resolve(true),
),
);
},
install() {
/*
You can't send message from bg to itself, call this function
instead when caught error in BG window.
See: https://stackoverflow.com/questions/17899769
*/
const handleErrorMessage = (messageObj) => {
const errLike = messageObj.payload;
return this.mayNotify('ext-error', 'Extension error', errLike);
};
chrome.runtime.onMessage.addListener((messageObj) => {
debug$1('Received:', messageObj);
if (messageObj.to !== 'error-reporter') {
return;
}
handleErrorMessage(messageObj);
});
chrome.notifications.onClicked.addListener(Utils.timeouted((notyId) => {
if (!notyId.startsWith(notyPrefix)) {
return;
}
chrome.notifications.clear(notyId);
const errorType = notyId.substr(notyPrefix.length);
errorNotifiers.viewError(errorType);
}));
if (chrome.proxy) {
chrome.proxy.onProxyError.addListener(Utils.timeouted(async (details) => {
const ifControlled = await ProxySettings.areControlledAsync();
if (!ifControlled) {
return;
}
/*
Example:
details: "line: 7: Uncaught Error: This is error, man.",
error: "net::ERR_PAC_SCRIPT_FAILED",
fatal: false,
*/
const ifConFail = details.error === 'net::ERR_PROXY_CONNECTION_FAILED';
if (ifConFail) {
// Happens if you return neither prixies nor "DIRECT".
// Ignore it.
return;
}
// TOOD: add "view pac script at this line" button.
errorNotifiers.mayNotify(
'pac-error',
'PAC Error!',
`${details.error}\n${details.details}`,
);
}));
}
return handleErrorMessage;
},
};
return errorNotifiers;
};
let singleton = false;
function GetNotifiersSingleton(configs) {
if (singleton) {
return singleton;
}
const notifiers = createErrorNotifiers(configs);
const handleErrorMessage = notifiers.install();
singleton = {
// Public API.
handleErrorMessage,
getErrorTypeToLabelMap() {
return new Map([
['pac-error', 'PAC script error'],
['ext-error', 'extension error'],
]);
},
switch(onOffStr = Utils.mandatory(), eventName) {
Utils.assert(
['on', 'off'].includes(onOffStr),
'First argument bust be "on" or "off".',
);
const eventNames = eventName
? [eventName]
: [...this.getErrorTypeToLabelMap().keys()];
eventNames.forEach(
(name) => notifiers.state(
ifPrefix + name,
onOffStr === 'on' ? 'on' : 'off',
),
);
},
isOn(eventName) {
return notifiers.isOn(eventName);
},
};
return singleton;
}
const install = (configs) => {
const { handleErrorMessage } = GetNotifiersSingleton(configs);
const uninstall = ErrorCatchers.installListenersOn({ handleErrorMessage });
return uninstall;
};
var index = {
Utils,
ErrorCatchers,
GetNotifiersSingleton,
install,
};
return index;
})));