UNPKG

pubnub

Version:

Publish & Subscribe Real-time Messaging with PubNub

376 lines (331 loc) 14.5 kB
/** * Default console-based logger. * * **Important:** This logger is always added as part of {@link LoggerManager} instance configuration and can't be * removed. * * @internal */ import { NetworkResponseLogMessage, NetworkRequestLogMessage, ObjectLogMessage, ErrorLogMessage, LogLevelString, LogMessage, LogLevel, Logger, } from '../core/interfaces/logger'; import { queryStringFromObject } from '../core/utils'; import { Payload } from '../core/types/api'; /** * Custom {@link Logger} implementation to show a message in the native console. */ export class ConsoleLogger implements Logger { /** * Binary data decoder. */ private static readonly decoder = new TextDecoder(); /** * Process a `debug` level message. * * @param message - Message which should be handled by custom logger implementation. */ debug(message: LogMessage): void { this.log(message); } /** * Process a `error` level message. * * @param message - Message which should be handled by custom logger implementation. */ error(message: LogMessage): void { this.log(message); } /** * Process an `info` level message. * * @param message - Message which should be handled by custom logger implementation. */ info(message: LogMessage): void { this.log(message); } /** * Process a `trace` level message. * * @param message - Message which should be handled by custom logger implementation. */ trace(message: LogMessage): void { this.log(message); } /** * Process an `warn` level message. * * @param message - Message which should be handled by custom logger implementation. */ warn(message: LogMessage): void { this.log(message); } /** * Stringify logger object. * * @returns Serialized logger object. */ toString(): string { return `ConsoleLogger {}`; } /** * Process log message object. * * @param message - Object with information which can be used to identify level and prepare log entry payload. */ private log(message: LogMessage) { const logLevelString = LogLevel[message.level]; const level = logLevelString.toLowerCase() as LogLevelString; console[level === 'trace' ? 'debug' : level]( `${message.timestamp.toISOString()} PubNub-${message.pubNubId} ${logLevelString.padEnd(5, ' ')}${ message.location ? ` ${message.location}` : '' } ${this.logMessage(message)}`, ); } /** * Get a pre-formatted log message. * * @param message - Log message which should be stringified. * * @returns String formatted for log entry in console. */ private logMessage(message: LogMessage): string { if (message.messageType === 'text') return message.message; else if (message.messageType === 'object') return `${message.details ? `${message.details}\n` : ''}${this.formattedObject(message)}`; else if (message.messageType === 'network-request') { const showOnlyBasicInfo = !!message.canceled || !!message.failed; const headersList = message.minimumLevel === LogLevel.Trace && !showOnlyBasicInfo ? this.formattedHeaders(message) : undefined; const request = message.message; const queryString = request.queryParameters && Object.keys(request.queryParameters).length > 0 ? queryStringFromObject(request.queryParameters) : undefined; const url = `${request.origin}${request.path}${queryString ? `?${queryString}` : ''}`; const formattedBody = !showOnlyBasicInfo ? this.formattedBody(message) : undefined; let action = 'Sending'; if (showOnlyBasicInfo) action = `${!!message.canceled ? 'Canceled' : 'Failed'}${message.details ? ` (${message.details})` : ''}`; const padding = (formattedBody?.formData ? 'FormData' : 'Method').length; return `${action} HTTP request:\n ${this.paddedString('Method', padding)}: ${ request.method }\n ${this.paddedString('URL', padding)}: ${url}${ headersList ? `\n ${this.paddedString('Headers', padding)}:\n${headersList}` : '' }${formattedBody?.formData ? `\n ${this.paddedString('FormData', padding)}:\n${formattedBody.formData}` : ''}${ formattedBody?.body ? `\n ${this.paddedString('Body', padding)}:\n${formattedBody.body}` : '' }`; } else if (message.messageType === 'network-response') { const headersList = message.minimumLevel === LogLevel.Trace ? this.formattedHeaders(message) : undefined; const formattedBody = this.formattedBody(message); const padding = (formattedBody?.formData ? 'Headers' : 'Status').length; const response = message.message; return `Received HTTP response:\n ${this.paddedString('URL', padding)}: ${ response.url }\n ${this.paddedString('Status', padding)}: ${response.status}${ headersList ? `\n ${this.paddedString('Headers', padding)}:\n${headersList}` : '' }${formattedBody?.body ? `\n ${this.paddedString('Body', padding)}:\n${formattedBody.body}` : ''}`; } else if (message.messageType === 'error') { const formattedStatus = this.formattedErrorStatus(message); const error = message.message; return `${error.name}: ${error.message}${formattedStatus ? `\n${formattedStatus}` : ''}`; } return '<unknown log message data>'; } /** * Get a pre-formatted object (dictionary / array). * * @param message - Log message which may contain an object for formatting. * * @returns String formatted for log entry in console or `undefined` if a log message doesn't have suitable data. */ private formattedObject(message: ObjectLogMessage): string | undefined { const stringify = ( obj: Record<string, unknown> | unknown[] | unknown, level: number = 1, skipIndentOnce: boolean = false, ) => { const maxIndentReached = level === 10; const targetIndent = ' '.repeat(level * 2); const lines: string[] = []; const isIgnored = (key: string, obj: Record<string, unknown>) => { if (!message.ignoredKeys) return false; if (typeof message.ignoredKeys === 'function') return message.ignoredKeys(key, obj); return message.ignoredKeys.includes(key); }; if (typeof obj === 'string') lines.push(`${targetIndent}- ${obj}`); else if (typeof obj === 'number') lines.push(`${targetIndent}- ${obj}`); else if (typeof obj === 'boolean') lines.push(`${targetIndent}- ${obj}`); else if (obj === null) lines.push(`${targetIndent}- null`); else if (obj === undefined) lines.push(`${targetIndent}- undefined`); else if (typeof obj === 'function') lines.push(`${targetIndent}- <function>`); else if (typeof obj === 'object') { if (!Array.isArray(obj) && typeof obj.toString === 'function' && obj.toString().indexOf('[object') !== 0) { lines.push(`${skipIndentOnce ? '' : targetIndent}${obj.toString()}`); skipIndentOnce = false; } else if (Array.isArray(obj)) { for (const element of obj) { const indent = skipIndentOnce ? '' : targetIndent; if (element === null) lines.push(`${indent}- null`); else if (element === undefined) lines.push(`${indent}- undefined`); else if (typeof element === 'function') lines.push(`${indent}- <function>`); else if (typeof element === 'object') { const isArray = Array.isArray(element); const entry = maxIndentReached ? '...' : stringify(element, level + 1, !isArray); lines.push(`${indent}-${isArray && !maxIndentReached ? '\n' : ' '}${entry}`); } else lines.push(`${indent}- ${element}`); skipIndentOnce = false; } } else { const object = obj as Record<string, unknown>; const keys = Object.keys(object); const maxKeyLen = keys.reduce((max, key) => Math.max(max, isIgnored(key, object) ? max : key.length), 0); for (const key of keys) { if (isIgnored(key, object)) continue; const indent = skipIndentOnce ? '' : targetIndent; const raw = object[key]; const paddedKey = key.padEnd(maxKeyLen, ' '); if (raw === null) lines.push(`${indent}${paddedKey}: null`); else if (raw === undefined) lines.push(`${indent}${paddedKey}: undefined`); else if (typeof raw === 'function') lines.push(`${indent}${paddedKey}: <function>`); else if (typeof raw === 'object') { const isArray = Array.isArray(raw); const isEmptyArray = isArray && raw.length === 0; const isEmptyObject = !isArray && !(raw instanceof String) && Object.keys(raw).length === 0; const hasToString = !isArray && typeof raw.toString === 'function' && raw.toString().indexOf('[object') !== 0; const entry = maxIndentReached ? '...' : isEmptyArray ? '[]' : isEmptyObject ? '{}' : stringify(raw, level + 1, hasToString); lines.push( `${indent}${paddedKey}:${ maxIndentReached || hasToString || isEmptyArray || isEmptyObject ? ' ' : '\n' }${entry}`, ); } else lines.push(`${indent}${paddedKey}: ${raw}`); skipIndentOnce = false; } } } return lines.join('\n'); }; return stringify(message.message); } /** * Get a pre-formatted headers list. * * @param message - Log message which may contain an object with headers to be used for formatting. * * @returns String formatted for log entry in console or `undefined` if a log message not related to the network data. */ private formattedHeaders(message: NetworkRequestLogMessage | NetworkResponseLogMessage) { if (!message.message.headers) return undefined; const headers = message.message.headers; const maxHeaderLength = Object.keys(headers).reduce((max, key) => Math.max(max, key.length), 0); return Object.keys(headers) .map((key) => ` - ${key.toLowerCase().padEnd(maxHeaderLength, ' ')}: ${headers[key]}`) .join('\n'); } /** * Get a pre-formatted body. * * @param message - Log message which may contain an object with `body` (request or response). * * @returns Object with formatted string of form data and / or body for log entry in console or `undefined` if a log * message not related to the network data. */ private formattedBody( message: NetworkRequestLogMessage | NetworkResponseLogMessage, ): { body?: string; formData?: string } | undefined { if (!message.message.headers) return undefined; let stringifiedFormData: string | undefined; let stringifiedBody: string | undefined; const headers = message.message.headers; const contentType = headers['content-type'] ?? headers['Content-Type']; const formData = 'formData' in message.message ? message.message.formData : undefined; const body = message.message.body; // The presence of this object means that we are sending `multipart/form-data` (potentially uploading a file). if (formData) { const maxFieldLength = formData.reduce((max, { key }) => Math.max(max, key.length), 0); stringifiedFormData = formData .map(({ key, value }) => ` - ${key.padEnd(maxFieldLength, ' ')}: ${value}`) .join('\n'); } if (!body) return { formData: stringifiedFormData }; if (typeof body === 'string') { stringifiedBody = ` ${body}`; } else if (body instanceof ArrayBuffer || Object.prototype.toString.call(body) === '[object ArrayBuffer]') { if (contentType && (contentType.indexOf('javascript') !== -1 || contentType.indexOf('json') !== -1)) stringifiedBody = ` ${ConsoleLogger.decoder.decode(body as ArrayBuffer)}`; else stringifiedBody = ` ArrayBuffer { byteLength: ${(body as ArrayBuffer).byteLength} }`; } else { stringifiedBody = ` File { name: ${body.name}${ body.contentLength ? `, contentLength: ${body.contentLength}` : '' }${body.mimeType ? `, mimeType: ${body.mimeType}` : ''} }`; } return { body: stringifiedBody, formData: stringifiedFormData }; } /** * Get a pre-formatted status object. * * @param message - Log message which may contain a {@link Status} object. * * @returns String formatted for log entry in console or `undefined` if a log message doesn't have {@link Status} * object. */ private formattedErrorStatus(message: ErrorLogMessage) { if (!message.message.status) return undefined; const status = message.message.status; const errorData = status.errorData; let stringifiedErrorData: string | undefined; if (ConsoleLogger.isError(errorData)) { stringifiedErrorData = ` ${errorData.name}: ${errorData.message}`; if (errorData.stack) { stringifiedErrorData += `\n${errorData.stack .split('\n') .map((line) => ` ${line}`) .join('\n')}`; } } else if (errorData) { try { stringifiedErrorData = ` ${JSON.stringify(errorData)}`; } catch (_) { stringifiedErrorData = ` ${errorData}`; } } return ` Category : ${status.category}\n Operation : ${status.operation}\n Status : ${ status.statusCode }${stringifiedErrorData ? `\n Error data:\n${stringifiedErrorData}` : ''}`; } /** * Append the required amount of space to provide proper padding. * * @param str - Source string which should be appended with necessary number of spaces. * @param maxLength - Maximum length of the string to which source string should be padded. * @returns End-padded string. */ private paddedString(str: string, maxLength: number) { return str.padEnd(maxLength - str.length, ' '); } /** * Check whether passed object is {@link Error} instance. * * @param errorData - Object which should be checked. * * @returns `true` in case if an object actually {@link Error}. */ private static isError(errorData?: Error | Payload): errorData is Error { if (!errorData) return false; return errorData instanceof Error || Object.prototype.toString.call(errorData) === '[object Error]'; } }