@naturalcycles/js-lib
Version:
Standard library for universal (browser + Node.js) javascript
345 lines (344 loc) • 11.7 kB
JavaScript
import { isServerSide } from '../env.js';
import { _jsonParseIfPossible } from '../string/json.util.js';
import { _truncate, _truncateMiddle } from '../string/string.util.js';
import { _stringify } from '../string/stringify.js';
/**
* Useful to ensure that error in `catch (err) { ... }`
* is indeed an Error (and not e.g `string` or `undefined`).
* 99% of the cases it will be Error already.
* Becomes more useful since TypeScript 4.4 made `err` of type `unknown` by default.
*
* Alternatively, if you're sure it's Error - you can use `_assertIsError(err)`.
*/
export function _anyToError(o, errorClass = Error, errorData) {
let e;
if (o instanceof errorClass) {
e = o;
}
else {
// If it's an instance of Error, but ErrorClass is something else (e.g AppError) - it'll be "repacked" into AppError
const errorObject = _anyToErrorObject(o);
e = _errorObjectToError(errorObject, errorClass);
}
if (errorData) {
;
e.data = {
...e.data,
...errorData,
};
}
return e;
}
/**
* Converts "anything" to ErrorObject.
* Detects if it's HttpErrorResponse, HttpErrorObject, ErrorObject, Error, etc..
* If object is Error - Error.message will be used.
* Objects (not Errors) get converted to prettified JSON string (via `_stringify`).
*/
export function _anyToErrorObject(o, errorData) {
let eo;
if (_isErrorLike(o)) {
eo = _errorLikeToErrorObject(o);
}
else {
o = _jsonParseIfPossible(o);
if (_isBackendErrorResponseObject(o)) {
eo = o.error;
}
else if (_isErrorObject(o)) {
eo = o;
}
else if (_isErrorLike(o)) {
eo = _errorLikeToErrorObject(o);
}
else {
// Here we are sure it has no `data` property,
// so, fair to return `data: {}` in the end
// Also we're sure it includes no "error name", e.g no `Error: ...`,
// so, fair to include `name: 'Error'`
const message = _stringify(o);
eo = {
name: 'Error',
message,
data: {}, // empty
};
}
}
Object.assign(eo.data, errorData);
return eo;
}
export function _errorLikeToErrorObject(e) {
// If it's already an ErrorObject - just return it
// AppError satisfies ErrorObject interface
// Error does not satisfy (lacks `data`)
// UPD: no, we expect a "plain object" here as an output,
// because Error classes sometimes have non-enumerable properties (e.g data)
if (!(e instanceof Error) && _isErrorObject(e)) {
return e;
}
const obj = {
name: e.name,
message: e.message,
data: { ...e.data }, // empty by default
};
if (e.stack)
obj.stack = e.stack;
if (e.cause) {
obj.cause = _anyToErrorObject(e.cause);
}
return obj;
}
export function _errorObjectToError(o, errorClass = Error) {
if (o instanceof errorClass)
return o;
// Here we pass constructor values assuming it's AppError or sub-class of it
// If not - will be checked at the next step
// We cannot check `if (errorClass instanceof AppError)`, only `err instanceof AppError`
const { name, cause } = o;
const err = new errorClass(o.message, o.data, { name, cause });
// name: err.name, // cannot be assigned to a readonly property like this
// stack: o.stack, // also readonly e.g in Firefox
if (o.stack) {
Object.defineProperty(err, 'stack', {
value: o.stack,
});
}
if (!(err instanceof AppError)) {
// Following actions are only needed for non-AppError-like errors
Object.defineProperties(err, {
name: {
value: name,
configurable: true,
writable: true,
},
data: {
value: o.data,
writable: true,
configurable: true,
enumerable: false,
},
cause: {
value: cause,
writable: true,
configurable: true,
enumerable: true,
},
});
Object.defineProperty(err.constructor, 'name', {
value: name,
configurable: true,
writable: true,
});
}
return err;
}
// These "common" error classes will not be printed as part of the Error snippet
const commonErrorClasses = new Set([
'Error',
'AppError',
'AssertionError',
'HttpRequestError',
'JoiValidationError',
]);
/**
* Provides a short semi-user-friendly error message snippet,
* that would allow to give a hint to the user what went wrong,
* also to developers and CS to distinguish between different errors.
*
* It's not supposed to have full information about the error, just a small extract from it.
*/
export function _errorSnippet(err, opt = {}) {
const { maxLineLength = 60, maxLines = 3 } = opt;
const e = _anyToErrorObject(err);
const lines = [errorObjectToSnippet(e)];
let { cause } = e;
while (cause && lines.length < maxLines) {
lines.push('Caused by ' + errorObjectToSnippet(cause));
cause = cause.cause; // insert DiCaprio Inception meme
}
return lines.map(line => _truncate(line, maxLineLength)).join('\n');
function errorObjectToSnippet(e) {
// Return snippet if it was already prepared
if (e.data.snippet)
return e.data.snippet;
// Code already serves the purpose of the snippet, so we can just return it
if (e.data.code)
return e.data.code;
return [
!commonErrorClasses.has(e.name) && e.name,
// replace "1+ white space characters" with a single space
e.message.replaceAll(/\s+/gm, ' ').trim(),
]
.filter(Boolean)
.join(': ');
}
}
export function _isBackendErrorResponseObject(o) {
return _isErrorObject(o?.error);
}
export function _isHttpRequestErrorObject(o) {
return !!o && o.name === 'HttpRequestError' && typeof o.data?.requestUrl === 'string';
}
/**
* Note: any instance of AppError is also automatically an ErrorObject
*/
export function _isErrorObject(o) {
return (!!o &&
typeof o === 'object' &&
typeof o.name === 'string' &&
typeof o.message === 'string' &&
typeof o.data === 'object');
}
export function _isErrorLike(o) {
return !!o && typeof o === 'object' && typeof o.name === 'string' && typeof o.message === 'string';
}
/**
* Convenience function to safely add properties to Error's `data` object
* (even if it wasn't previously existing).
* Mutates err.
* Returns err for convenience, so you can re-throw it directly.
*
* @example
*
* try {} catch (err) {
* throw _errorDataAppend(err, {
* backendResponseStatusCode: 401,
* })
* }
*/
export function _errorDataAppend(err, data) {
if (!data)
return err;
err.data ||= {}; // create err.data if it doesn't exist
// Using Object.assign instead of ...data to not override err.data's non-enumerable properties
Object.assign(err.data, data);
return err;
}
/**
* Base class for all our (not system) errors.
*
* message - "technical" message. Frontend decides to show it or not.
* data - optional "any" payload.
* data.userFriendly - if present, will be displayed to the User as is.
*
* Based on: https://medium.com/@xpl/javascript-deriving-from-error-properly-8d2f8f315801
*/
export class AppError extends Error {
data;
/**
* `cause` here is normalized to be an ErrorObject
*/
cause;
/**
* Experimental alternative static constructor.
*/
static of(opt) {
return new AppError(opt.message, opt.data, {
name: opt.name,
cause: opt.cause,
});
}
constructor(message, data = {}, opt = {}) {
super(message);
// Here we default to `this.constructor.name` on Node, but to 'AppError' on the Frontend
// because Frontend tends to minify class names, so `constructor.name` is not reliable
const { name = isServerSide() ? this.constructor.name : 'AppError', cause } = opt;
Object.defineProperties(this, {
name: {
value: name,
configurable: true,
writable: true,
},
data: {
value: data,
writable: true,
configurable: true,
enumerable: false,
},
});
if (cause) {
Object.defineProperty(this, 'cause', {
value: _anyToErrorObject(cause),
writable: true,
configurable: true,
enumerable: true, // unlike standard - setting it to true for "visibility"
});
}
else {
delete this.cause; // otherwise it's printed as `cause: undefined`
}
// this is to allow changing this.constuctor.name to a non-minified version
Object.defineProperty(this.constructor, 'name', {
value: name,
configurable: true,
writable: true,
});
// todo: check if it's needed at all!
// if (Error.captureStackTrace) {
// Error.captureStackTrace(this, this.constructor)
// } else {
// Object.defineProperty(this, 'stack', {
// value: new Error().stack, // eslint-disable-line unicorn/error-message
// writable: true,
// configurable: true,
// })
// }
}
}
/**
* Error that is thrown when Http Request was made and returned an error.
* Thrown by, for example, Fetcher.
*
* On the Frontend this Error class represents the error when calling the API,
* contains all the necessary request and response information.
*
* On the Backend, similarly, it represents the error when calling some 3rd-party API
* (backend-to-backend call).
* On the Backend it often propagates all the way to the Backend error handler,
* where it would be wrapped in BackendErrorResponseObject.
*
* Please note that `ErrorData.backendResponseStatusCode` is NOT exactly the same as
* `HttpRequestErrorData.responseStatusCode`.
* E.g 3rd-party call may return 401, but our Backend will still wrap it into an 500 error
* (by default).
*/
export class HttpRequestError extends AppError {
constructor(message, data, opt) {
if (data.response) {
Object.defineProperty(data, 'response', {
enumerable: false,
});
}
super(message, data, { ...opt, name: 'HttpRequestError' });
}
}
export class AssertionError extends AppError {
constructor(message, data) {
super(message, data, { name: 'AssertionError' });
}
}
export class JsonParseError extends AppError {
constructor(data) {
const message = ['Failed to parse', data.text && _truncateMiddle(data.text, 200)]
.filter(Boolean)
.join(': ');
super(message, data, { name: 'JsonParseError' });
}
}
export class TimeoutError extends AppError {
constructor(message, data, opt) {
super(message, data, { ...opt, name: 'TimeoutError' });
}
}
/**
* It is thrown when Error was expected, but didn't happen
* ("pass" happened instead).
* "Pass" means "no error".
*/
export class UnexpectedPassError extends AppError {
constructor(message) {
super(message || 'expected error was not thrown', {}, {
name: 'UnexpectedPassError',
});
}
}